diff --git a/CHANGELOG.md b/CHANGELOG.md index 97446365e..f6fcd72db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Agent** mode, giving one session the whole connection window: its sessions, its conversation, and what it ran. +- **Agent** mode: one session with the whole connection window, sessions to start and delete, and what each one ran. - Row previews and the query editor sized to the display on iPad and on iPhone Duo's inner display. - Table list and table browser side by side on iPad and on iPhone Duo's inner display. - **View > Mode**, with **Toggle Agent Mode** on ⌥⇧⌘A. @@ -32,6 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Numbered databases on Valkey 9 clusters with `cluster-databases` above 1. - `DB ` in the Redis editor, running one command on another database. - Explain for Teradata. +- **Actions** menu in the connection window's toolbar, with the commands for the tab you are on and every import format. +- **Actions** menu in the connection window's toolbar, with the commands for the tab you are on. +- **File > Import > Import Data From**, for choosing the import format from the menu bar. +- **File > Session**, with the agent session commands and the assistant's conversation commands. +- Eight more rebindable commands in **Settings > Keyboard**, among them the sidebar's lists and the session commands. ### Changed @@ -43,14 +48,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iCloud sync and usage data on iPhone and iPad stay off until you turn them on. - Group rows in the iOS connection list take swipe actions and show even when no connection is saved. - Typed entry beside the stepper for number settings in the connection form. +- Connection window toolbar trimmed to eight controls that follow the tab and the mode. +- Connection window toolbar cut to eight controls that follow the tab and the mode, the rest in **Customize Toolbar**. +- **Tables** and **Favorites** chooser moved from the toolbar to the top of the sidebar. +- One header for the Inspector and the Assistant, with a picker between them and their commands in its menu. +- **Fields** / **JSON** and the JSON view's options moved into the Inspector's header menu. +- Pencil for the Inspector's unsaved-edit marker and a spinner for the AI chat's typing indicator. +- Middle-dot separators dropped from the CSV inspector's status bar and the query history rows. +- Connection marked with a tinted symbol rather than a color dot in the query history rows. +- Safe Mode list offering only the levels a connection allows, with the reason under it and in the toolbar tooltip. ### Removed - **Refresh from iCloud**, **Sync Now** and the toolbar sync button on iPhone and iPad. - **Manage Groups**, the **Clear** button on **Recent**, and the **More** menu's tag filter on iPhone and iPad. +- Throughput readout in the toolbar; the connection switcher still shows it. +- Back, Forward, New Tab, Open Quickly, Add Row and Restore Previous Values from the default toolbar. +- Toolbar **Assistant** button; the trailing pane's own picker chooses between the inspector and the assistant. ### Fixed +- **Save Changes** and ⌘S dim on a Users & Roles tab with staged changes. +- A deleted connection's inspector and assistant choice left behind, and inherited by a new connection with its id. +- **Show Results** enabled on tabs that have no results pane. +- **Restore Previous Values…** missing from **Settings > Keyboard**, with no shortcut to bind. +- Toolbar **History** button enabled over a window that never connected. +- Toolbar **Inspector** button dim on macOS 13 over a pane left open when the connection dropped. +- **Auto-show inspector on row select** replacing the Assistant you left the pane on. +- Current conversation in the Assistant's history not announced by VoiceOver. +- Lines an AI walkthrough step highlighted staying highlighted for good when switched away from before they cleared. +- Titlebar file icon left over from a query tab the window was no longer showing. +- No tooltip on the Inspector's **Choose Type** and **Choose Value** buttons. - A column resized moments before its table was dropped saving the layout back over the clear. - A connection left pointing at a database that was dropped. - A dropped table's saved filters, column layout, highlight rules, value formats and label columns coming back on a table recreated with its name. diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index ed2d55a78..38c5affae 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -634,13 +634,14 @@ extension DatabaseManager { /// The user picking a level from the toolbar or the Database menu. /// - /// A level below the connection's floor is not on offer, and picking the level already in - /// force changes nothing: writing it would replace the level the user saved, which is the one - /// that comes back once the floor lifts. + /// Judged against the floor Agent mode raises as well as the connection's own, which the menu + /// offers from. The connection's own floor cannot see the mode, so a weaker level picked in Agent + /// mode used to be stored while the session was held at Alert: the pick changed nothing on + /// screen and came back as the user's level once the mode ended. `SafeModeStatus.accepts` is + /// the rule, and every choice it takes moves the level in force. func chooseSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { guard let connection = activeSessions[connectionId]?.connection, - level != connection.safeModeLevel, - connection.safeModeFloor?.allows(level) ?? true + AgentModeSafeModeFloor.status(for: connection).accepts(level) else { return } setSafeModeLevel(level, for: connectionId) } diff --git a/TablePro/Core/Menu/AgentSessionMenuDelegate.swift b/TablePro/Core/Menu/AgentSessionMenuDelegate.swift new file mode 100644 index 000000000..1ea7de70c --- /dev/null +++ b/TablePro/Core/Menu/AgentSessionMenuDelegate.swift @@ -0,0 +1,64 @@ +// +// AgentSessionMenuDelegate.swift +// TablePro +// + +import AppKit + +/// The sessions the connection on screen owns, filled when the menu opens. +/// +/// Filled on open rather than when the menu is built, because the list is the connection's and a +/// window changes connection without rebuilding its menu bar. It is also the one list in the app that +/// changes while the menu is closed: a reply finishing moves a session to the top, and closing one +/// from the rail takes it out of the set the command can act on. +/// +/// Every entry carries no target and names its session in `representedObject`, which is the shape +/// `MainSplitViewController.agentSessionTarget(for:)` already reads, so an entry is validated and +/// carried out against the session it names rather than against the rail's highlight. +/// +/// The sessions are listed in every mode, not only in Agent mode. They exist either way, and a list +/// that reported "None Available" over five live sessions would be describing the mode rather than +/// the connection; what browsing takes away is the ability to act, which the window's own validation +/// says by dimming every entry. +/// +/// Built on the same shape as `ImportFormatMenuDelegate`, including the responder-chain lookup. +/// `NSMenu.delegate` is weak, so whoever builds a menu keeps the delegate alive alongside it. +@MainActor +internal final class AgentSessionMenuDelegate: NSObject, NSMenuDelegate { + internal static let action = #selector(MainSplitViewController.openAgentSession(_:)) + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + let controller = NSApp.target(forAction: Self.action, to: nil, from: nil) as? MainSplitViewController + let sessions = controller?.listedAgentSessions ?? [] + guard !sessions.isEmpty else { + menu.addItem(MenuPlaceholder.item()) + return + } + let displayed = controller?.displayedAgentSessionId + for session in sessions { + menu.addItem(Self.item(for: session, isDisplayed: session.id == displayed)) + } + } + + /// The tick marks the session the window is drawing, which is what the rail marks too. Outside + /// Agent mode the window draws none, so nothing is ticked and nothing claims to be open. + internal static func item(for session: AgentSession, isDisplayed: Bool) -> NSMenuItem { + let item = NSMenuItem(title: session.displayTitle, action: action, keyEquivalent: "") + item.target = nil + item.representedObject = session.id + item.state = isDisplayed ? .on : .off + return item + } + + /// Keeps AppKit's key-equivalent search from rebuilding the menu on every modified keystroke, + /// which would walk the responder chain for items that carry no key equivalent. + func menuHasKeyEquivalent( + _ menu: NSMenu, + for event: NSEvent, + target: AutoreleasingUnsafeMutablePointer, + action: UnsafeMutablePointer + ) -> Bool { + false + } +} diff --git a/TablePro/Core/Menu/ContentModeMenuDelegate.swift b/TablePro/Core/Menu/ContentModeMenuDelegate.swift new file mode 100644 index 000000000..c72cbbd67 --- /dev/null +++ b/TablePro/Core/Menu/ContentModeMenuDelegate.swift @@ -0,0 +1,44 @@ +// +// ContentModeMenuDelegate.swift +// TablePro +// + +import AppKit + +/// Browse and Agent as a pair of menu items, filled when the menu opens. +/// +/// Each entry names its mode in `representedObject`, and that is load-bearing twice over: +/// `setContentModeFromMenu(_:)` reads it to know which mode was chosen and does nothing without +/// it, and the window's `validateMenuItem` reads it to put the checkmark on the mode the +/// connection is in. An entry without it validates enabled, acts on nothing and ticks nothing. +/// +/// Driven from the enum rather than a hand copy, the way View > Mode is, so a third mode cannot be +/// offered in one menu and missing from the other. +@MainActor +internal final class ContentModeMenuDelegate: NSObject, NSMenuDelegate { + internal static let action = #selector(MainSplitViewController.setContentModeFromMenu(_:)) + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + for mode in ConnectionWorkspaceContentMode.allCases { + menu.addItem(Self.item(for: mode)) + } + } + + internal static func item(for mode: ConnectionWorkspaceContentMode) -> NSMenuItem { + let item = NSMenuItem(title: mode.localizedTitle, action: action, keyEquivalent: "") + item.target = nil + item.representedObject = mode.rawValue + return item + } + + /// Keeps AppKit's key-equivalent search from rebuilding the menu on every modified keystroke. + func menuHasKeyEquivalent( + _ menu: NSMenu, + for event: NSEvent, + target: AutoreleasingUnsafeMutablePointer, + action: UnsafeMutablePointer + ) -> Bool { + false + } +} diff --git a/TablePro/Core/Menu/ConversationHistoryMenuDelegate.swift b/TablePro/Core/Menu/ConversationHistoryMenuDelegate.swift new file mode 100644 index 000000000..e4954b890 --- /dev/null +++ b/TablePro/Core/Menu/ConversationHistoryMenuDelegate.swift @@ -0,0 +1,58 @@ +// +// ConversationHistoryMenuDelegate.swift +// TablePro +// + +import AppKit + +/// The assistant's stored conversations for the connection on screen, filled when the menu opens. +/// +/// The set changes with every reply, so a list baked in when the menu bar was built would be the +/// conversations of whichever connection happened to be open at launch. This is the second place the +/// list is offered, beside the trailing pane header's own menu, and both put the choice through the +/// same window selector so the two cannot act on different connections. +/// +/// Every entry carries no target and names its conversation in `representedObject`, which +/// `switchAIConversation(_:)` reads. The current one carries the menu's own checkmark, which +/// VoiceOver reads as selected. +/// +/// `NSMenu.delegate` is weak, so whoever builds a menu keeps the delegate alive alongside it. +@MainActor +internal final class ConversationHistoryMenuDelegate: NSObject, NSMenuDelegate { + internal static let action = #selector(MainSplitViewController.switchAIConversation(_:)) + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + let controller = NSApp.target(forAction: Self.action, to: nil, from: nil) as? MainSplitViewController + guard let viewModel = controller?.assistantConversationModel, !viewModel.conversations.isEmpty else { + menu.addItem(MenuPlaceholder.item()) + return + } + let active = viewModel.activeConversationID + for conversation in viewModel.conversations { + menu.addItem(Self.item(for: conversation, isActive: conversation.id == active)) + } + } + + /// A conversation is titled from its first exchange, so one the user sent nothing in has no + /// title at all. The pane's own list names it the same way rather than drawing a blank row. + internal static func item(for conversation: AIConversation, isActive: Bool) -> NSMenuItem { + let title = conversation.title.isEmpty ? String(localized: "Untitled") : conversation.title + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.target = nil + item.representedObject = conversation.id + item.state = isActive ? .on : .off + return item + } + + /// Keeps AppKit's key-equivalent search from rebuilding the menu on every modified keystroke, + /// which would walk the responder chain for items that carry no key equivalent. + func menuHasKeyEquivalent( + _ menu: NSMenu, + for event: NSEvent, + target: AutoreleasingUnsafeMutablePointer, + action: UnsafeMutablePointer + ) -> Bool { + false + } +} diff --git a/TablePro/Core/Menu/EditMenuBuilder.swift b/TablePro/Core/Menu/EditMenuBuilder.swift index 6171a8e6d..fdeac2da1 100644 --- a/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/TablePro/Core/Menu/EditMenuBuilder.swift @@ -84,7 +84,9 @@ enum EditMenuBuilder { /// reversal of something already committed is a separate, named command. MenuItemFactory.item( String(localized: "Restore Previous Values…"), - action: #selector(MainSplitViewController.restorePreviousValues(_:)) + action: #selector(MainSplitViewController.restorePreviousValues(_:)), + shortcut: .restorePreviousValues, + keyboard: keyboard ), MenuItemFactory.separator, tabularEditingSubmenu(keyboard: keyboard) diff --git a/TablePro/Core/Menu/FileMenuBuilder.swift b/TablePro/Core/Menu/FileMenuBuilder.swift index b5b7829d7..3225fc4b5 100644 --- a/TablePro/Core/Menu/FileMenuBuilder.swift +++ b/TablePro/Core/Menu/FileMenuBuilder.swift @@ -9,6 +9,9 @@ import AppKit enum FileMenuBuilder { /// Retained for the menu's lifetime, which is the app's: `NSMenu.delegate` is unowned. private static let closeTitleDelegate = CloseCommandMenuDelegate() + private static let importFormatDelegate = ImportFormatMenuDelegate() + private static let agentSessionDelegate = AgentSessionMenuDelegate() + private static let conversationHistoryDelegate = ConversationHistoryMenuDelegate() static func build(keyboard: KeyboardSettings) -> NSMenuItem { let file = MenuItemFactory.menu(String(localized: "File"), items: [ @@ -28,6 +31,7 @@ enum FileMenuBuilder { shortcut: .newTab, keyboard: keyboard ), + sessionSubmenu(keyboard: keyboard), MenuItemFactory.item( String(localized: "Manage Connections"), action: #selector(AppDelegate.manageConnections(_:)), @@ -127,6 +131,78 @@ enum FileMenuBuilder { return file } + /// Agent mode's four session commands and the assistant's three conversation commands, which + /// between them had no menu-bar home at all: the rail's buttons and the pane header's menu were + /// the only routes, so none of them could be found by search, rebound, or reached by a user who + /// had the rail collapsed. They sit in File because a session and a conversation are things this + /// window opens, closes and throws away, which is what the rest of this menu is about. + /// + /// Each command has exactly one item here, and each item leaves `target` nil, so the window + /// validates it through the responder chain and dims what the mode cannot run. + private static func sessionSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { + MenuItemFactory.submenu(String(localized: "Session"), items: [ + MenuItemFactory.item( + String(localized: "New Session"), + action: #selector(MainSplitViewController.newAgentSession(_:)), + shortcut: .newAgentSession, + keyboard: keyboard + ), + /// Opens the session the rail has highlighted, which is what Return on the rail does. + /// A separate leaf rather than the list's own row: AppKit ignores a key equivalent on an + /// item that owns a submenu, so making this the list would hand Settings a binding that + /// records, reads back and never fires. That is the same trap Import Data… is split + /// around, and it is why the list is a row of its own below. + MenuItemFactory.item( + String(localized: "Open Session"), + action: #selector(MainSplitViewController.openAgentSession(_:)), + shortcut: .openAgentSession, + keyboard: keyboard + ), + recentSessionsSubmenu(), + MenuItemFactory.item( + String(localized: "Close Session"), + action: #selector(MainSplitViewController.closeAgentSession(_:)), + shortcut: .closeAgentSession, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Delete Session…"), + action: #selector(MainSplitViewController.deleteAgentSession(_:)), + shortcut: .deleteAgentSession, + keyboard: keyboard + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "New Conversation"), + action: #selector(MainSplitViewController.newAIConversation(_:)), + shortcut: .newAIConversation, + keyboard: keyboard + ), + conversationHistorySubmenu(), + MenuItemFactory.item( + String(localized: "Clear Recents…"), + action: #selector(MainSplitViewController.clearAIConversations(_:)) + ) + ]) + } + + /// Every session the connection on screen owns, latest first, filled when it opens. A session + /// list built at menu-build time would be one window's sessions frozen at launch. + private static func recentSessionsSubmenu() -> NSMenuItem { + let container = MenuItemFactory.submenu(String(localized: "Recent Sessions"), items: []) + container.submenu?.delegate = agentSessionDelegate + return container + } + + /// The assistant's stored conversations, filled when it opens for the same reason: the set + /// changes with every reply. The pane header's own menu offers the same list, and both put the + /// choice through the window so neither can act on a connection the other is showing. + private static func conversationHistorySubmenu() -> NSMenuItem { + let container = MenuItemFactory.submenu(String(localized: "Conversation History"), items: []) + container.submenu?.delegate = conversationHistoryDelegate + return container + } + private static func importSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { let container = MenuItemFactory.submenu(String(localized: "Import"), items: [ MenuItemFactory.item( @@ -152,6 +228,7 @@ enum FileMenuBuilder { ) ]) container.submenu?.insertItem(.separator(), at: 0) + container.submenu?.insertItem(importFormatsSubmenu(), at: 0) container.submenu?.insertItem( MenuItemFactory.item( String(localized: "Import Data…"), @@ -164,6 +241,16 @@ enum FileMenuBuilder { return container } + /// Every format the connection imports from. Import Data… above it takes the first one, which + /// left the menu bar with no route to any other: the toolbar's Import item was the only one, and + /// a toolbar item is not a menu-bar command. The Actions pull-down offers the same list under the + /// same title, and the two are filled by the same class when they open. + private static func importFormatsSubmenu() -> NSMenuItem { + let container = MenuItemFactory.submenu(String(localized: "Import Data From"), items: []) + container.submenu?.delegate = importFormatDelegate + return container + } + private static func exportSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { MenuItemFactory.submenu(String(localized: "Export"), items: [ MenuItemFactory.item( diff --git a/TablePro/Core/Menu/ImportFormatMenuDelegate.swift b/TablePro/Core/Menu/ImportFormatMenuDelegate.swift new file mode 100644 index 000000000..e75074dc7 --- /dev/null +++ b/TablePro/Core/Menu/ImportFormatMenuDelegate.swift @@ -0,0 +1,60 @@ +// +// ImportFormatMenuDelegate.swift +// TablePro +// + +import AppKit + +/// The formats the connection's driver imports from, filled when the menu opens. +/// +/// Filled on open rather than when the menu is built, because the list is the driver's and a window +/// changes driver with every connection switch. The menu this replaces was rebuilt by hand on each +/// repoint, and a copy built at any other moment kept the formats of the connection it was built for. +/// +/// Every entry carries no target and names its format in `representedObject`, so AppKit resolves it +/// through the responder chain to the window's controller and validates it there, the way the menu +/// bar's own commands are. The entries this replaces targeted the toolbar object, which is not a +/// responder, so a menu resolved through the chain could not have reached them at all. +/// +/// One class serves every place the list is offered: File > Import > Import Data From, the Actions +/// pull-down's row of the same name, and the Import item a user can add from Customize Toolbar. Each +/// asks the key window when it opens, so none of them can list another connection's formats. +/// +/// Built on the same shape as `SafeModeMenuDelegate`, including the responder-chain lookup that +/// finds the window the chosen format will import into. `NSMenu.delegate` is weak, so whoever builds +/// a menu keeps the delegate alive alongside it. +@MainActor +internal final class ImportFormatMenuDelegate: NSObject, NSMenuDelegate { + internal static let action = #selector(MainSplitViewController.importDataFormat(_:)) + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + let controller = NSApp.target(forAction: Self.action, to: nil, from: nil) as? MainSplitViewController + let formats = controller?.commandActions?.availableImportFormats ?? [] + guard !formats.isEmpty else { + menu.addItem(MenuPlaceholder.item()) + return + } + for format in formats { + menu.addItem(Self.item(for: format)) + } + } + + internal static func item(for format: ImportFormatOption) -> NSMenuItem { + let item = NSMenuItem(title: format.formatLabel, action: action, keyEquivalent: "") + item.target = nil + item.representedObject = format.id + return item + } + + /// Keeps AppKit's key-equivalent search from rebuilding the menu on every modified keystroke, + /// which would walk the responder chain for items that carry no key equivalent. + func menuHasKeyEquivalent( + _ menu: NSMenu, + for event: NSEvent, + target: AutoreleasingUnsafeMutablePointer, + action: UnsafeMutablePointer + ) -> Bool { + false + } +} diff --git a/TablePro/Core/Menu/MenuFootnote.swift b/TablePro/Core/Menu/MenuFootnote.swift new file mode 100644 index 000000000..2c80691e0 --- /dev/null +++ b/TablePro/Core/Menu/MenuFootnote.swift @@ -0,0 +1,51 @@ +// +// MenuFootnote.swift +// TablePro +// + +import AppKit + +/// A disabled line of explanation at the foot of a menu, wrapped to a width the menu already has. +/// +/// An `NSMenuItem` title is one line however long it is: a floor's reason, set as a plain title, +/// widened the Safe Mode list from 132pt to 585pt. An attributed title does keep its line breaks, +/// so the text is broken where TextKit would break it at `wrapWidth` and set as an attributed +/// title in the small menu font, which put the same list at 241pt over three lines. Measured on +/// macOS 27. +internal enum MenuFootnote { + internal static let wrapWidth: CGFloat = 220 + + internal static var font: NSFont { + NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) + } + + internal static func item(_ text: String) -> NSMenuItem { + let item = NSMenuItem(title: text, action: nil, keyEquivalent: "") + item.attributedTitle = NSAttributedString( + string: wrapped(text, font: font, width: wrapWidth), + attributes: [.font: font] + ) + item.isEnabled = false + return item + } + + /// The lines TextKit lays `text` out in at `width`, joined with line breaks. + internal static func wrapped(_ text: String, font: NSFont, width: CGFloat) -> String { + let storage = NSTextStorage(string: text, attributes: [.font: font]) + let layoutManager = NSLayoutManager() + let container = NSTextContainer(size: NSSize(width: width, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + storage.addLayoutManager(layoutManager) + + let nsText = text as NSString + var lines: [String] = [] + layoutManager.enumerateLineFragments( + forGlyphRange: layoutManager.glyphRange(for: container) + ) { _, _, _, glyphRange, _ in + let characters = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + lines.append(nsText.substring(with: characters).trimmingCharacters(in: .whitespacesAndNewlines)) + } + return lines.joined(separator: "\n") + } +} diff --git a/TablePro/Core/Menu/MenuPlaceholder.swift b/TablePro/Core/Menu/MenuPlaceholder.swift new file mode 100644 index 000000000..d46b6d0aa --- /dev/null +++ b/TablePro/Core/Menu/MenuPlaceholder.swift @@ -0,0 +1,23 @@ +// +// MenuPlaceholder.swift +// TablePro +// + +import AppKit + +/// The one row a delegate-filled list shows when it has nothing to list. +/// +/// A menu with no items opens as a sliver with no text, which reads as a broken command, and the row +/// that opens it cannot be dimmed through the responder chain because AppKit gives a submenu's row +/// its own action. So the list says why it is empty instead. +/// +/// One place rather than one per delegate: four lists are filled on open, and a fifth would otherwise +/// arrive with a fourth copy of the same disabled row or with none at all. +@MainActor +internal enum MenuPlaceholder { + internal static func item() -> NSMenuItem { + let item = NSMenuItem(title: String(localized: "None Available"), action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } +} diff --git a/TablePro/Core/Menu/SafeModeMenuDelegate.swift b/TablePro/Core/Menu/SafeModeMenuDelegate.swift index 9c8a6dca2..5acd4a99c 100644 --- a/TablePro/Core/Menu/SafeModeMenuDelegate.swift +++ b/TablePro/Core/Menu/SafeModeMenuDelegate.swift @@ -17,16 +17,26 @@ final class SafeModeMenuDelegate: NSObject, NSMenuDelegate { private static let action = #selector(MainSplitViewController.setSafeModeLevel(_:)) func menuNeedsUpdate(_ menu: NSMenu) { - menu.removeAllItems() let controller = NSApp.target(forAction: Self.action, to: nil, from: nil) as? MainSplitViewController - let current = controller?.commandActions?.coordinator?.toolbarState.safeModeLevel - for level in SafeModeLevel.allCases { - menu.addItem(item(for: level, current: current)) + Self.populate(menu, with: controller?.safeModeStatus) + } + + /// The levels the floor allows and, under them, why the rest are not there. Agent mode raising + /// the floor used to be silent here: every level was listed, a weaker one could be picked, and + /// the level on screen did not move. With no status, which is a window with no session, every + /// level is listed and validation dims them. + internal static func populate(_ menu: NSMenu, with status: SafeModeStatus?) { + menu.removeAllItems() + for level in status?.offeredLevels ?? SafeModeLevel.allCases { + menu.addItem(item(for: level, current: status?.level)) } + guard let explanation = status?.floor?.explanation else { return } + menu.addItem(.separator()) + menu.addItem(MenuFootnote.item(explanation)) } - private func item(for level: SafeModeLevel, current: SafeModeLevel?) -> NSMenuItem { - let item = NSMenuItem(title: level.displayName, action: Self.action, keyEquivalent: "") + private static func item(for level: SafeModeLevel, current: SafeModeLevel?) -> NSMenuItem { + let item = NSMenuItem(title: level.displayName, action: action, keyEquivalent: "") item.target = nil item.representedObject = level.rawValue item.state = level == current ? .on : .off diff --git a/TablePro/Core/Menu/SessionContextMenuDelegate.swift b/TablePro/Core/Menu/SessionContextMenuDelegate.swift index 39813acee..86e6cedae 100644 --- a/TablePro/Core/Menu/SessionContextMenuDelegate.swift +++ b/TablePro/Core/Menu/SessionContextMenuDelegate.swift @@ -12,8 +12,9 @@ import TableProPluginKit /// /// It is a menu rather than a toolbar control because the set is dynamic: a driver may publish /// none, one or several, and `NSToolbar` needs a fixed identifier per item. As hosted SwiftUI -/// inside the connection group these had no overflow entry, no menu command and no shortcut, so a -/// window narrow enough to clip that group left no way to change warehouse or role at all. +/// inside the toolbar group that once held the connection and its database, these had no overflow +/// entry, no menu command and no shortcut, so a window narrow enough to clip that group left no way +/// to change warehouse or role at all. @MainActor final class SessionContextMenuDelegate: NSObject, NSMenuDelegate { private static let action = #selector(MainSplitViewController.switchSessionContext(_:)) @@ -23,7 +24,7 @@ final class SessionContextMenuDelegate: NSObject, NSMenuDelegate { let controller = NSApp.target(forAction: Self.action, to: nil, from: nil) as? MainSplitViewController let contexts = controller?.commandActions?.coordinator?.sessionContexts ?? [] guard !contexts.isEmpty else { - addPlaceholder(to: menu) + menu.addItem(MenuPlaceholder.item()) return } for context in contexts { @@ -43,7 +44,7 @@ final class SessionContextMenuDelegate: NSObject, NSMenuDelegate { submenu.addItem(item) } if context.availableValues.isEmpty { - addPlaceholder(to: submenu) + submenu.addItem(MenuPlaceholder.item()) } let container = NSMenuItem(title: context.label, action: nil, keyEquivalent: "") container.image = NSImage(systemSymbolName: context.iconName, accessibilityDescription: nil) @@ -51,12 +52,6 @@ final class SessionContextMenuDelegate: NSObject, NSMenuDelegate { return container } - private func addPlaceholder(to menu: NSMenu) { - let empty = NSMenuItem(title: String(localized: "None Available"), action: nil, keyEquivalent: "") - empty.isEnabled = false - menu.addItem(empty) - } - /// Keeps AppKit's key-equivalent search from rebuilding the menu on every modified keystroke, /// which would walk the responder chain for items that carry no key equivalent. func menuHasKeyEquivalent( diff --git a/TablePro/Core/Menu/ViewMenuBuilder.swift b/TablePro/Core/Menu/ViewMenuBuilder.swift index 1278441f0..d1e64f5c6 100644 --- a/TablePro/Core/Menu/ViewMenuBuilder.swift +++ b/TablePro/Core/Menu/ViewMenuBuilder.swift @@ -39,16 +39,20 @@ enum ViewMenuBuilder { ), modeSubmenu(keyboard: keyboard), MenuItemFactory.separator, - /// The segmented control in the toolbar was the only route to either of these, so a - /// window whose toolbar was narrow, hidden or customized could not switch what the - /// sidebar lists. The HIG asks that every toolbar item also be a menu-bar command. + /// The sidebar's own scope control is the pointer route to these, and it is on screen + /// only while the sidebar is. These reveal a collapsed sidebar and switch its list in + /// one step, and they write the state the control reads, so the two move together. MenuItemFactory.item( String(localized: "Show Tables"), - action: #selector(MainSplitViewController.showTablesSidebarTab(_:)) + action: #selector(MainSplitViewController.showTablesSidebarTab(_:)), + shortcut: .showTablesList, + keyboard: keyboard ), MenuItemFactory.item( String(localized: "Show Favorites"), - action: #selector(MainSplitViewController.showFavoritesSidebarTab(_:)) + action: #selector(MainSplitViewController.showFavoritesSidebarTab(_:)), + shortcut: .showFavoritesList, + keyboard: keyboard ), connectionSortSubmenu(), MenuItemFactory.separator, @@ -172,9 +176,9 @@ enum ViewMenuBuilder { } /// Browse and Agent as a checked pair sharing one selector, the shape the Result View submenu - /// already uses. The toolbar control is the pointer affordance; the HIG asks that every toolbar - /// item also be a menu-bar command, and it is also the only route a UI test can drive, because a - /// synthetic click on a segment inside an `NSToolbarItemGroup` is measured not to select it. + /// already uses. The toolbar's Actions pull-down offers the same pair, built by + /// `ContentModeMenuDelegate` with the same selector and the same `representedObject`, so the + /// checkmark and the action cannot differ between the two. private static func modeSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { let items = ConnectionWorkspaceContentMode.allCases.map { mode -> NSMenuItem in let item = MenuItemFactory.item( diff --git a/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift b/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift index 459da9922..e17ed6f39 100644 --- a/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift +++ b/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift @@ -6,18 +6,29 @@ import Foundation enum ServerDashboardQueryProviderFactory { + /// Whether an engine has a dashboard, answered without building one. + /// + /// The toolbar and the menu bar ask this on every validation pass, and a pass can follow a + /// keystroke. Asking `provider(for:) != nil` instead built a provider to throw away, which on + /// PostgreSQL is an activity catalog and a metric set. Both functions read `DashboardEngine`, so + /// the list of engines cannot drift between the question and the answer. + static func supportsDashboard(for databaseType: DatabaseType) -> Bool { + DashboardEngine(databaseType) != nil + } + static func provider(for databaseType: DatabaseType, serverVersion: String? = nil) -> ServerDashboardQueryProvider? { - switch databaseType { + guard let engine = DashboardEngine(databaseType) else { return nil } + switch engine { case .postgresql: return PostgreSQLDashboardProvider( activityCatalog: PostgreSQLActivityCatalog(serverVersion: PostgreSQLServerVersion(serverVersion)), metricSet: PostgreSQLDashboardMetricSet(databaseType: databaseType) ) - case .redshift, .cockroachdb: + case .postgresqlCompatible: return PostgreSQLDashboardProvider( metricSet: PostgreSQLDashboardMetricSet(databaseType: databaseType) ) - case .mysql, .mariadb: + case .mysql: return MySQLDashboardProvider() case .mssql: return MSSQLDashboardProvider() @@ -29,6 +40,39 @@ enum ServerDashboardQueryProviderFactory { return SQLiteDashboardProvider() case .typesense: return TypesenseDashboardProvider() + } + } +} + +/// The engines that have a dashboard, and which provider each one takes. +private enum DashboardEngine { + case postgresql + case postgresqlCompatible + case mysql + case mssql + case clickhouse + case duckdb + case sqlite + case typesense + + init?(_ databaseType: DatabaseType) { + switch databaseType { + case .postgresql: + self = .postgresql + case .redshift, .cockroachdb: + self = .postgresqlCompatible + case .mysql, .mariadb: + self = .mysql + case .mssql: + self = .mssql + case .clickhouse: + self = .clickhouse + case .duckdb: + self = .duckdb + case .sqlite: + self = .sqlite + case .typesense: + self = .typesense default: return nil } diff --git a/TablePro/Core/Services/Export/ImportRouting.swift b/TablePro/Core/Services/Export/ImportRouting.swift index bbca28eee..cebcdbe01 100644 --- a/TablePro/Core/Services/Export/ImportRouting.swift +++ b/TablePro/Core/Services/Export/ImportRouting.swift @@ -16,6 +16,14 @@ struct ImportFormatOption: Identifiable, Equatable { var standaloneLabel: String { String(format: String(localized: "Import %@\u{2026}"), name) } + + /// The format's name alone, for a menu whose parent already names the command: Import Data From + /// > CSV…, the shape Keynote and Numbers give Export To. Under that parent `submenuLabel` would + /// read "Import Data From > From CSV…". Not localized, because the format's name is the whole of + /// it and a format name is a technical term. + var formatLabel: String { + "\(name)\u{2026}" + } } enum ImportSheetRoute: Equatable { diff --git a/TablePro/Core/Services/Infrastructure/AgentSessionRegistry.swift b/TablePro/Core/Services/Infrastructure/AgentSessionRegistry.swift index ed885ec5f..e2aa56bf2 100644 --- a/TablePro/Core/Services/Infrastructure/AgentSessionRegistry.swift +++ b/TablePro/Core/Services/Infrastructure/AgentSessionRegistry.swift @@ -33,7 +33,22 @@ internal final class AgentSessionRegistry: ObservableObject { /// Held explicitly rather than derived. Deriving it from `sessions(for:)` answered the oldest /// one every time, so New Session appended a row nothing switched to and Open Session moved a /// timestamp nothing read: both panes stayed bound to the first session for ever. - private var displayedSessionIds: [UUID: UUID] = [:] + /// + /// Showing nothing is a state of its own. Closing or deleting the session on screen with no other + /// live one to hand to used to fall back to the last session in the list, which was the one just + /// closed, so the column went on drawing a stopped session with a composer that still took + /// messages. + private var displayed: [UUID: DisplayedSession] = [:] + + private enum DisplayedSession: Equatable { + case session(UUID) + case nothing + } + + /// Going to work moves a session up the rail, and the rail observes this registry rather than + /// each session, so the registry has to say so, and write the new stamp so the order survives a + /// relaunch. + private var activityCancellables: [UUID: AnyCancellable] = [:] /// Restore runs here, synchronously, which is the whole point of the store being a plain struct. /// A window that opens while a load is suspended finds nothing, mints a session, and is then @@ -58,6 +73,21 @@ internal final class AgentSessionRegistry: ObservableObject { lastActiveAt: record.lastActiveAt ) } + for session in sessions { + observeActivity(of: session) + } + } + + /// `@Published` announces a change before it is stored, so the write waits for the next turn to + /// read the new stamp rather than the one it replaces. + private func observeActivity(of session: AgentSession) { + activityCancellables[session.id] = session.$lastActiveAt + .dropFirst() + .sink { [weak self] _ in + guard let self else { return } + self.objectWillChange.send() + Task { @MainActor [weak self] in self?.persist() } + } } private func makeViewModel(sessionId: UUID, conversationId: UUID?) -> AIChatViewModel { @@ -66,10 +96,18 @@ internal final class AgentSessionRegistry: ObservableObject { // MARK: - Reading + /// A connection's sessions, the one that last went to work first, which is the order the rail + /// lists them in. Two stamped in the same instant keep the order they were added in, newest first. internal func sessions(for connectionId: UUID) -> [AgentSession] { - sessions - .filter { $0.connectionId == connectionId } - .sorted { $0.startedAt < $1.startedAt } + sessions.enumerated() + .filter { $0.element.connectionId == connectionId } + .sorted { lhs, rhs in + guard lhs.element.lastActiveAt != rhs.element.lastActiveAt else { + return lhs.offset > rhs.offset + } + return lhs.element.lastActiveAt > rhs.element.lastActiveAt + } + .map(\.element) } internal func session(id: UUID) -> AgentSession? { @@ -81,13 +119,23 @@ internal final class AgentSessionRegistry: ObservableObject { /// Both the trailing-pane chat and the agent-mode conversation column resolve through here, so /// they cannot end up rendering two different sessions of the same connection. Reading never /// creates: opening a connection window starts no session and loads no transcript. + /// + /// With nothing named it is the latest live session, then the latest of any, which is how a + /// window opened after a relaunch, where every session comes back stopped, carries on with the + /// last one rather than starting another. internal func currentSession(for connectionId: UUID) -> AgentSession? { let owned = sessions(for: connectionId) - if let displayed = displayedSessionIds[connectionId], - let match = owned.first(where: { $0.id == displayed }) { - return match + switch displayed[connectionId] { + case .session(let id)?: + if let match = owned.first(where: { $0.id == id }) { + return match + } + case .nothing?: + return nil + case nil: + break } - return owned.first { !$0.status.isEnded } ?? owned.last + return owned.first { !$0.status.isEnded } ?? owned.first } /// Names the session a connection's two panes render. The pane render key carries it, so a @@ -96,11 +144,15 @@ internal final class AgentSessionRegistry: ObservableObject { guard sessions.contains(where: { $0.id == sessionId && $0.connectionId == connectionId }) else { return } - displayedSessionIds[connectionId] = sessionId + displayed[connectionId] = .session(sessionId) } - internal func displayedSessionId(for connectionId: UUID) -> UUID? { - currentSession(for: connectionId)?.id + /// Shows the latest live session in place of one that is ending or going away, and nothing when + /// no other live one is left. A stopped session is shown only once someone opens it, which + /// resumes it. + private func handOverDisplay(from session: AgentSession) { + let next = sessions(for: session.connectionId).first { $0.id != session.id && !$0.status.isEnded } + displayed[session.connectionId] = next.map { .session($0.id) } ?? .nothing } // MARK: - Writing @@ -114,7 +166,8 @@ internal final class AgentSessionRegistry: ObservableObject { viewModel: makeViewModel(sessionId: id, conversationId: nil) ) sessions.append(session) - displayedSessionIds[connectionId] = id + displayed[connectionId] = .session(id) + observeActivity(of: session) persist() attachRemoteTools(to: session) return session @@ -132,11 +185,19 @@ internal final class AgentSessionRegistry: ObservableObject { return startSession(for: connectionId) } - /// Ends one session and keeps its transcript. The conversation stays in the history. + /// Ends one session and keeps its transcript. The conversation stays in the history and the + /// session stays in the rail, to be opened again. + /// + /// The session on screen is handed over as it stops, which is what Close Session means. Nothing + /// used to tell the panes, so they went on drawing a stopped session with a live composer. internal func stopSession(id: UUID) { guard let session = session(id: id) else { return } + let isShown = currentSession(for: session.connectionId) === session session.stop() detachRemoteTools(from: id) + if isShown { + handOverDisplay(from: session) + } persist() } @@ -161,17 +222,20 @@ internal final class AgentSessionRegistry: ObservableObject { persist() } - /// Discards a session and the conversation behind it. Only an explicit user action reaches here. + /// Discards a session and the conversation behind it. Only an explicit user action reaches here, + /// and it has been asked to confirm by then. internal func removeSession(id: UUID) { guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } let session = sessions[index] + let isShown = currentSession(for: session.connectionId) === session let conversationId = session.conversationId session.viewModel.cancelStream() detachRemoteTools(from: id) AIProviderFactory.resetCopilotConversation(sessionId: id) + activityCancellables[id] = nil sessions.remove(at: index) - if displayedSessionIds[session.connectionId] == id { - displayedSessionIds[session.connectionId] = sessions(for: session.connectionId).last?.id + if isShown { + handOverDisplay(from: session) } if let conversationId { let storage = services.aiChatStorage @@ -180,11 +244,6 @@ internal final class AgentSessionRegistry: ObservableObject { persist() } - internal func markActive(id: UUID) { - session(id: id)?.markActive() - persist() - } - // MARK: - Outside MCP servers /// Connects the outside MCP servers this session's connection allows, and registers their tools. diff --git a/TablePro/Core/Services/Infrastructure/ConnectionWindowPaneResolver.swift b/TablePro/Core/Services/Infrastructure/ConnectionWindowPaneResolver.swift index 47a343920..c8123947c 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionWindowPaneResolver.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionWindowPaneResolver.swift @@ -59,11 +59,51 @@ internal enum ConnectionWindowPaneResolver { } } + /// Which of a workspace's two trees fills the detail column: the browse content or the agent's + /// conversation. + /// + /// Agent mode draws its conversation before a session exists on purpose: the prompt the user + /// typed is the thing they are waiting with, and hiding it until the connect lands means typing + /// into nothing and then watching the conversation flash in. It does not preempt a pane with + /// nothing to connect to, though. A failed, cancelled or disconnected attempt carries the error, + /// the Retry, the sign-in or edit action and Manage Connections, and those live on the browse + /// side's unavailable screen; a composer with none of them is a dead end whichever mode the + /// window is in. + internal static func detailMode( + for pane: ConnectionWindowPane, + contentMode: ConnectionWorkspaceContentMode + ) -> ConnectionWorkspaceContentMode { + switch contentMode { + case .browse: + return .browse + case .agent: + switch pane { + case .connecting, .content: + return .agent + case .unavailable, .empty: + return .browse + } + } + } + /// The tab strip's band is a list of tabs, so it appears only when there is a list worth /// showing: content behind it, and more than one tab in it. A window with a single tab keeps /// the chrome it always had, which is what the system does too. - internal static func showsTabStrip(for pane: ConnectionWindowPane, tabCount: Int) -> Bool { - pane == .content && tabCount > 1 + /// + /// Agent mode never shows it. The tabs belong to the browse content, which the conversation has + /// replaced in the detail column, so a band over the conversation offered tabs a click could + /// select without anything on screen changing. + internal static func showsTabStrip( + for pane: ConnectionWindowPane, + tabCount: Int, + contentMode: ConnectionWorkspaceContentMode + ) -> Bool { + switch contentMode { + case .browse: + return pane == .content && tabCount > 1 + case .agent: + return false + } } /// Whether the connections strip stands, given the preference that normally governs it. diff --git a/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift b/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift index 590176135..07e821e48 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift @@ -47,6 +47,15 @@ internal final class ConnectionWorkspace { /// on a table. internal var contentMode: ConnectionWorkspaceContentMode = .browse + /// What Browse had collapsed, so entering Agent mode can reveal its three columns and leaving + /// can put the window back the way the user had it. + /// + /// On the workspace rather than in a static keyed by connection id, because two windows can + /// host the same connection and each has its own collapsed sidebar and inspector. Shared, the + /// second window to enter Agent mode overwrote what the first had recorded, and the first then + /// 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. @@ -56,6 +65,17 @@ internal final class ConnectionWorkspace { /// at a time by swapping which of these is the split items' child. internal let panes = WorkspacePanes() + /// Where this connection's agent sessions live. The app has one registry and every workspace it + /// builds names it; the seam is here, beside the panes that draw the sessions, so a workspace + /// handed a registry of its own renders and starts sessions in that one and in nothing else. + /// The trailing pane state the window builds for it takes the same registry, which is what + /// keeps the assistant and Agent mode on one set of sessions. + internal let agentSessions: AgentSessionRegistry + + /// The session rail's highlight, per window rather than per connection like the sessions + /// themselves: two windows showing one connection each have a rail of their own to move through. + internal let agentRail = AgentSessionRailState() + /// The containers this connection has open, one connections-strip entry each. /// /// A container is open from the moment the user browses to it until they close its entry, which @@ -96,7 +116,8 @@ internal final class ConnectionWorkspace { session: ConnectionSession?, sessionState: SessionStateFactory.SessionState?, trailingPaneState: TrailingPaneState?, - phase: ConnectionWindowPhase + phase: ConnectionWindowPhase, + agentSessions: AgentSessionRegistry = .shared ) { self.connectionId = connectionId self.payload = payload @@ -106,6 +127,7 @@ internal final class ConnectionWorkspace { self.sessionState = sessionState self.trailingPaneState = trailingPaneState self.phase = phase + self.agentSessions = agentSessions self.undoManager = UndoManager() observeBrowsedContainer() recordBrowsedContainer() @@ -232,6 +254,19 @@ internal final class ConnectionWorkspace { ) } + /// Which tree the detail column draws, which is the mode's own except over a connection that + /// cannot be reached. `ConnectionWindowPaneResolver.detailMode` says why. + internal var detailMode: ConnectionWorkspaceContentMode { + ConnectionWindowPaneResolver.detailMode(for: resolvedPane, contentMode: resolvedContentMode) + } + + /// The session the agent panes draw, and nil whenever the connection is browsing: nothing on + /// screen shows a session then, so nothing may be named or rebuilt after one. + internal var displayedAgentSession: AgentSession? { + guard resolvedContentMode == .agent else { return nil } + return agentSessions.currentSession(for: connectionId) + } + /// Everything the panes are built from, compared against `panes.renderedKey` to decide whether /// they have to be built at all. internal var paneRenderKey: WorkspacePaneRenderKey { @@ -240,9 +275,7 @@ internal final class ConnectionWorkspace { connection: connection, sessionRevision: sessionRevision, contentMode: resolvedContentMode, - agentSessionId: resolvedContentMode == .agent - ? AgentSessionRegistry.shared.displayedSessionId(for: connectionId) - : nil + agentSessionId: displayedAgentSession?.id ) } @@ -321,8 +354,9 @@ internal final class ConnectionWorkspace { /// 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. let connectionId = self.connectionId + let agentSessions = self.agentSessions Task { @MainActor in - AgentSessionRegistry.shared.stopSessionsIfUnhosted(for: connectionId) + agentSessions.stopSessionsIfUnhosted(for: connectionId) } } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+AIConversations.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+AIConversations.swift new file mode 100644 index 000000000..d31ca6a32 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+AIConversations.swift @@ -0,0 +1,59 @@ +// +// MainSplitViewController+AIConversations.swift +// TablePro +// + +import AppKit + +/// The assistant's three conversation commands, and the one place each of them is carried out. +/// +/// They had no `@objc` surface at all: `AIChatViewModel` is a plain `ObservableObject` reached only +/// from SwiftUI through `AssistantState`, so New Conversation, Conversation History and Clear Recents +/// existed solely as buttons in the trailing pane's header menu. That put them out of reach of the +/// menu bar, of Settings > Keyboard, and of a user in Agent mode, where the pane draws the result +/// instead and the header menu is not there at all. +/// +/// The pane header now calls these same selectors, so the two surfaces cannot drift: one place +/// decides what New Conversation does, and one place asks before Clear Recents throws anything away. +/// +/// Written the way `MainSplitViewController+AgentSessions.swift` is, down to the `representedObject` +/// rule: a menu listing the conversations names one in each of its items, and every other route acts +/// on the one the assistant is showing. +internal extension MainSplitViewController { + @objc func newAIConversation(_ sender: Any?) { + assistantConversationModel?.startNewConversation() + } + + @objc func switchAIConversation(_ sender: Any?) { + guard let id = (sender as? NSMenuItem)?.representedObject as? UUID else { return } + assistantConversationModel?.switchConversation(to: id) + } + + @objc func clearAIConversations(_ sender: Any?) { + guard assistantConversationModel != nil else { return } + Task { await requestClearAIConversations() } + } + + /// Asks first: the alert is what stands between this command and every stored conversation. The + /// model is read again after the answer, because a sheet is a wait and the connection on screen + /// can change while it is up. + func requestClearAIConversations() async { + guard await confirmClearConversations(view.window) else { return } + assistantConversationModel?.clearConversation() + } + + /// The conversation the window's assistant commands act on: the one the connection's session + /// holds, whichever column is drawing it. It is the same object the trailing pane's assistant + /// resolves, so the pane's menu and the menu bar act on one conversation rather than two. + /// + /// Nil until something opens the assistant, which is what dims the three commands, exactly as the + /// pane's own menu is dimmed there. Reading it starts nothing: a session is only created by + /// revealing the surface or by a command that asks for one. + /// + /// Nil with the AI feature off, too. A session restored from disk outlives the setting, so + /// without this the three commands would answer for a surface no window can draw. + var assistantConversationModel: AIChatViewModel? { + guard AppSettingsManager.shared.ai.enabled, let workspace = workspaces.selected else { return nil } + return workspace.agentSessions.currentSession(for: workspace.connectionId)?.viewModel + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+AgentPanes.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+AgentPanes.swift new file mode 100644 index 000000000..91978a64a --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+AgentPanes.swift @@ -0,0 +1,111 @@ +// +// MainSplitViewController+AgentPanes.swift +// TablePro +// + +import Combine +import SwiftUI + +/// Agent mode's three panes, built into hosting controllers of their own beside the browse panes. +/// +/// They are written only while the connection is in the mode. A connection that never enters it +/// never builds a rail or a conversation, and one that leaves keeps the rail, the conversation and +/// the result as they were, detached, so coming back is a reparent rather than a rebuild: the +/// result keeps the run it was showing, its segment and its sort. The transcript does not keep +/// its scroll position. `AIChatPanelView` scrolls to the latest message whenever it appears, which +/// a reparent is, and that is also where a session that went on working while the user browsed +/// has got to. +internal extension MainSplitViewController { + func refreshAgentPanes(of workspace: ConnectionWorkspace) { + guard workspace.resolvedContentMode == .agent else { return } + workspace.panes.agentRail.rootView = AnyView(buildAgentRailView(for: workspace)) + workspace.panes.agentConversation.rootView = AnyView(buildAgentConversationView(for: workspace)) + workspace.panes.agentResult.rootView = AnyView(buildAgentResultView(for: workspace)) + } + + /// Re-arms only when the session naming the window changes, so the title can be asked for on + /// every phase change and mode toggle without stacking subscriptions. + /// + /// `@Published` announces a change before the value is stored, so the title is read back on the + /// next turn of the run loop rather than from inside the announcement. + func followTitle(of session: AgentSession?) { + guard session !== observedAgentSession else { return } + observedAgentSession = session + agentTitleCancellable = session?.$title + .dropFirst() + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.applyWindowTitle() + } + } + + /// Every one of the rail's commands goes back through the window rather than to the registry, so + /// stopping or discarding the session on screen repaints the panes that were drawing it. + @ViewBuilder + private func buildAgentRailView(for workspace: ConnectionWorkspace) -> some View { + if let connection = workspace.connection { + AgentSessionRailView( + connectionId: connection.id, + registry: workspace.agentSessions, + railState: workspace.agentRail, + openSessionId: workspace.displayedAgentSession?.id, + onOpen: { [weak self] sessionId in + self?.openAgentSession(id: sessionId, connectionId: connection.id) + }, + onNewSession: { [weak self] in self?.startAgentSession(for: connection.id) }, + onClose: { [weak self] sessionId in + Task { await self?.requestCloseAgentSession(id: sessionId, connectionId: connection.id) } + }, + onDelete: { [weak self] sessionId in + Task { await self?.requestDeleteAgentSession(id: sessionId, connectionId: connection.id) } + } + ) + .transaction { $0.animation = nil } + } else { + Color.clear + } + } + + /// Built whatever the pane, and parented only for the panes `ConnectionWindowPaneResolver.detailMode` + /// gives it. A connection that drops while the conversation is on screen therefore keeps the + /// conversation, detached, behind the unavailable screen, and a reconnect puts the same one back. + /// + /// The Safe Mode floor is read here rather than in the view, because it is the answer to a + /// question about every window in the app: a connection is in Agent mode while any of them has it + /// in the mode, and only `AgentModeSafeModeFloor` can ask that. + @ViewBuilder + private func buildAgentConversationView(for workspace: ConnectionWorkspace) -> some View { + if let connection = workspace.connection { + AgentConversationView( + connection: connection, + session: workspace.displayedAgentSession, + isConnecting: workspace.resolvedPane == .connecting, + safeModeFloor: AgentModeSafeModeFloor.effectiveFloor(for: connection), + onStartSession: { [weak self] in self?.startAgentSession(for: connection.id) } + ) + .transaction { $0.animation = nil } + } else { + Color.clear + } + } + + /// The session's statements and rows, over a live connection only. + @ViewBuilder + private func buildAgentResultView(for workspace: ConnectionWorkspace) -> some View { + let session = workspace.displayedAgentSession + if let reason = TrailingPaneUnavailableView.Reason.agentResult( + pane: workspace.resolvedPane, + hasSession: session != nil + ) { + TrailingPaneUnavailableView( + surface: .agentResult, + reason: reason, + contentMode: workspace.contentMode, + paneState: nil + ) + } else if let session { + AgentResultPaneView(session: session, connection: workspace.connection, contentMode: workspace.contentMode) + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+AgentSessions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+AgentSessions.swift new file mode 100644 index 000000000..92aa85f8b --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+AgentSessions.swift @@ -0,0 +1,159 @@ +// +// MainSplitViewController+AgentSessions.swift +// TablePro +// + +import AppKit + +/// How a session command puts its question: a sheet on the window it came from in the app, and an +/// answer without one under test, where a modal alert would hold the whole run. +internal typealias AgentSessionConfirming = @MainActor (AgentSessionConfirmation, NSWindow?) async -> Bool + +/// Agent mode's four session commands, and the one place each of them is carried out. +/// +/// The rail's buttons and its context menu end here, and the menu bar's own items will, so a command +/// behaves the same whichever of them asked. A menu item names its session in `representedObject`, +/// which is how a menu listing a connection's sessions will reach one; anything else acts on the +/// session highlighted in the rail, the way a list command acts on the list's selection. +/// +/// Every command ends in `applyContentMode(for:)`. Close used to go from the rail straight to the +/// registry, which stopped the session and told no pane, so the conversation column went on drawing +/// it with a composer that still took messages. +internal extension MainSplitViewController { + @objc func newAgentSession(_ sender: Any?) { + guard let connectionId = workspaces.selectedConnectionId else { return } + startAgentSession(for: connectionId) + } + + @objc func openAgentSession(_ sender: Any?) { + guard let session = agentSessionTarget(for: sender) else { return } + openAgentSession(id: session.id, connectionId: session.connectionId) + } + + @objc func closeAgentSession(_ sender: Any?) { + guard let session = agentSessionTarget(for: sender) else { return } + Task { await requestCloseAgentSession(id: session.id, connectionId: session.connectionId) } + } + + @objc func deleteAgentSession(_ sender: Any?) { + guard let session = agentSessionTarget(for: sender) else { return } + Task { await requestDeleteAgentSession(id: session.id, connectionId: session.connectionId) } + } + + /// The sessions a menu lists: the ones the connection on screen owns, the one that last went to + /// work first, which is the order the rail lists them in. + /// + /// Listed in both modes on purpose. The sessions exist either way, and a list that reported + /// nothing over five live ones would be describing the mode rather than the connection. What + /// browsing takes away is the ability to act on one, and `isAgentSessionCommandEnabled` says so + /// by dimming every entry. + var listedAgentSessions: [AgentSession] { + guard let workspace = workspaces.selected else { return [] } + return workspace.agentSessions.sessions(for: workspace.connectionId) + } + + /// The session this window is drawing, which a menu ticks. Nil while browsing, where the window + /// draws none, so nothing in the list claims to be open. + var displayedAgentSessionId: UUID? { + workspaces.selected?.displayedAgentSession?.id + } + + /// The session a command acts on: the one its menu item names, or the one highlighted in the rail + /// of the connection on screen. Nil outside Agent mode, where no rail is showing to act on, and + /// for a session that belongs to another connection. + func agentSessionTarget(for sender: Any?) -> AgentSession? { + guard let workspace = workspaces.selected, workspace.resolvedContentMode == .agent else { return nil } + let named = (sender as? NSMenuItem)?.representedObject as? UUID + guard let sessionId = named ?? workspace.agentRail.highlightedSessionId, + let session = workspace.agentSessions.session(id: sessionId), + session.connectionId == workspace.connectionId else { return nil } + return session + } + + func startAgentSession(for connectionId: UUID) { + guard let workspace = workspaces.workspace(for: connectionId) else { return } + workspace.agentSessions.startSession(for: connectionId) + applyContentMode(for: workspace) + } + + func openAgentSession(id sessionId: UUID, connectionId: UUID) { + guard let workspace = workspaces.workspace(for: connectionId), + let session = workspace.agentSessions.session(id: sessionId) else { return } + session.resume() + workspace.agentSessions.setDisplayedSession(sessionId, for: connectionId) + applyContentMode(for: workspace) + } + + /// Asks only when the session is busy, since stopping one that is not loses nothing. + func requestCloseAgentSession(id sessionId: UUID, connectionId: UUID) async { + guard let session = workspaces.workspace(for: connectionId)?.agentSessions.session(id: sessionId), + !session.status.isEnded else { return } + if let confirmation = AgentSessionConfirmation.close(session.displayTitle, status: session.status) { + guard await confirmAgentSessionCommand(confirmation, view.window) else { return } + } + guard let workspace = workspaces.workspace(for: connectionId) else { return } + workspace.agentSessions.stopSession(id: sessionId) + repaintEveryWindow(hosting: connectionId) + } + + func requestDeleteAgentSession(id sessionId: UUID, connectionId: UUID) async { + guard let session = workspaces.workspace(for: connectionId)?.agentSessions.session(id: sessionId) else { + return + } + let confirmation = AgentSessionConfirmation.delete(session.displayTitle, status: session.status) + guard await confirmAgentSessionCommand(confirmation, view.window), + let workspace = workspaces.workspace(for: connectionId) else { return } + workspace.agentSessions.removeSession(id: sessionId) + repaintEveryWindow(hosting: connectionId) + } + + /// Closing or deleting a session is the one pair of commands whose result another window cannot + /// discover for itself. + /// + /// The registry can now hand the displayed session over to nothing, which is a value nothing + /// could reach while `removeSession` had no caller outside a test, and a second window's + /// trailing assistant resolves its session once and then observes only that session. Repainting + /// the commanding window alone left that assistant pointed at a session that had gone, and + /// blank until the user switched surface. This is the pane rule in its own words: a workspace is + /// repainted for the phase it ends in, every workspace's, not just the one on screen. + /// + /// This window first and unconditionally, because it is the one the command came from and it is + /// hosted whether or not anything has registered it. + private func repaintEveryWindow(hosting connectionId: UUID) { + repaintAgentRail(hosting: connectionId, in: self) + for host in WindowManager.shared.hostControllers(for: connectionId) where host !== self { + repaintAgentRail(hosting: connectionId, in: host) + } + } + + private func repaintAgentRail(hosting connectionId: UUID, in host: MainSplitViewController) { + guard let workspace = host.workspaces.workspace(for: connectionId) else { return } + if let highlighted = workspace.agentRail.highlightedSessionId, + workspace.agentSessions.session(id: highlighted) == nil { + workspace.agentRail.highlightedSessionId = workspace.displayedAgentSession?.id + } + host.applyContentMode(for: workspace) + } + + /// A sheet on the window the command came from. Only deleting uses the destructive shape, which + /// takes Return off the confirming button; closing a busy session is a step the person asked for. + static func presentAgentSessionConfirmation( + _ confirmation: AgentSessionConfirmation, + in window: NSWindow? + ) async -> Bool { + if confirmation.isDestructive { + return await AlertHelper.confirmDestructive( + title: confirmation.title, + message: confirmation.message, + confirmButton: confirmation.confirmButton, + window: window + ) + } + return await AlertHelper.confirm( + title: confirmation.title, + message: confirmation.message, + confirmButton: confirmation.confirmButton, + window: window + ) + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ContentMode.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ContentMode.swift index 4e92e9d79..0262f3676 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ContentMode.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ContentMode.swift @@ -11,6 +11,12 @@ import AppKit /// own: the sidebar, detail and trailing items already carry `sizingOptions = []`, the detail item's /// `holdingPriority` and the trailing item's explicit macOS 13 thicknesses, and a nested split view /// inside the detail pane would re-raise all three of the split-view bugs those exist for. +/// +/// What it swaps is which of the workspace's hosting controllers each item parents, never what one +/// of them draws. That is what lets a toggle keep the browse content's grid scroll, cell selection, +/// find panel, undo stack and unsaved Create Table definition, and it is why a reparented view sees +/// `onDisappear` then `onAppear` on the same identity: everything a view releases on the first has +/// to come back on the second. internal extension MainSplitViewController { /// Whether a connection is on screen, whether or not it has finished connecting. var hasSelectedWorkspace: Bool { @@ -26,10 +32,6 @@ internal extension MainSplitViewController { setContentMode(mode, for: workspace.connectionId) } - /// What Browse had collapsed, so entering Agent mode can reveal its columns and leaving can put - /// the window back the way the user had it. - private static var browseCollapseState: [UUID: (sidebar: Bool, inspector: Bool)] = [:] - func setContentMode(_ mode: ConnectionWorkspaceContentMode, for connectionId: UUID) { guard let workspace = workspaces.workspace(for: connectionId) else { return } let resolved = ConnectionWorkspaceContentMode.resolved( @@ -42,7 +44,7 @@ internal extension MainSplitViewController { /// Agent mode opens a session so the window has something to draw. Browsing does not stop /// one: leaving the mode is not the user ending a conversation, and coming back continues it. if resolved == .agent { - AgentSessionRegistry.shared.resolveSession(for: connectionId, startingIfNeeded: true) + workspace.agentSessions.resolveSession(for: connectionId, startingIfNeeded: true) } applyColumnVisibility(for: connectionId, mode: resolved) @@ -57,22 +59,33 @@ internal extension MainSplitViewController { /// /// A fresh window starts with the inspector collapsed, and the user may have collapsed the /// sidebar, so swapping the hosted roots alone gave a first-time Agent mode with no Result - /// column and sometimes no Sessions column either. What Browse had is remembered and put back. - private func applyColumnVisibility( + /// column and sometimes no Sessions column either. What Browse had is remembered on the + /// workspace and put back. + /// + /// Returns without touching the window when the workspace is not the one on screen, which is + /// why `applySelectedWorkspace` calls it again: a connection put into Agent mode while another + /// was selected reached the window with its columns still collapsed and nothing to reveal them. + func applyColumnVisibility( for connectionId: UUID, mode: ConnectionWorkspaceContentMode ) { - guard workspaces.selectedConnectionId == connectionId else { return } + guard workspaces.selectedConnectionId == connectionId, + let workspace = workspaces.workspace(for: connectionId) else { return } switch mode { case .agent: - Self.browseCollapseState[connectionId] = ( - sidebar: sidebarSplitItem.isCollapsed, - inspector: inspectorSplitItem.isCollapsed - ) + /// Recorded once per entry into the mode. Recording again on a later selection would + /// save the mode's own revealed columns as the layout to go back to. + if workspace.browseCollapseState == nil { + workspace.browseCollapseState = ( + sidebar: sidebarSplitItem.isCollapsed, + inspector: inspectorSplitItem.isCollapsed + ) + } sidebarSplitItem.animator().isCollapsed = false inspectorSplitItem.animator().isCollapsed = false case .browse: - guard let previous = Self.browseCollapseState.removeValue(forKey: connectionId) else { return } + guard let previous = workspace.browseCollapseState else { return } + workspace.browseCollapseState = nil sidebarSplitItem.animator().isCollapsed = previous.sidebar inspectorSplitItem.animator().isCollapsed = previous.inspector } @@ -82,32 +95,25 @@ internal extension MainSplitViewController { setContentMode(contentMode.toggled) } - /// Repaints one workspace for its current mode, selected or not. + /// Repaints one workspace for its current mode, selected or not, and parents it only if it is + /// the one on screen. /// /// A background workspace owns panes that outlive every switch, so one built for a mode it has /// since left stays wrong until something builds it again. That is the same reason - /// `transition(to:for:)` ends in a sync rather than repainting only what is on screen. + /// `transition(to:for:)` ends in a sync rather than repainting only what is on screen. Parenting + /// is the other half, and a background workspace gets it from `applySelectedWorkspace` when it + /// is selected. + /// + /// The tab strip, the detail column's minimum and the title each describe the tree in the detail + /// column, so all three follow the swap rather than whichever tab is selected behind it. func applyContentMode(for workspace: ConnectionWorkspace) { syncPanes(of: workspace) guard workspaces.selectedConnectionId == workspace.connectionId else { return } + showSelectedContentPanes() showSelectedTrailingPane() + applyDetailMinimumThicknessForSelection() applyPaneChrome() applyWindowTitle() - toolbarOwner?.refreshContentMode() - } - - func startAgentSession(for connectionId: UUID) { - AgentSessionRegistry.shared.startSession(for: connectionId) - guard let workspace = workspaces.workspace(for: connectionId) else { return } - applyContentMode(for: workspace) - } - - func selectAgentSession(_ sessionId: UUID, for connectionId: UUID) { - guard let session = AgentSessionRegistry.shared.session(id: sessionId) else { return } - session.resume() - AgentSessionRegistry.shared.setDisplayedSession(sessionId, for: connectionId) - AgentSessionRegistry.shared.markActive(id: sessionId) - guard let workspace = workspaces.workspace(for: connectionId) else { return } - applyContentMode(for: workspace) + toolbarOwner?.refreshContext() } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift index 8ad5293d9..5ad3a0ddc 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift @@ -24,6 +24,7 @@ extension MainSplitViewController { switcherPresenter.present( from: view.window, anchoredTo: MainWindowToolbar.connection, + hiddenBy: toolbarOwner?.visibility, subject: .connection, contentSize: ConnectionSwitcherPopover.contentSize ) { [selectedConnectionId] dismiss in @@ -63,10 +64,18 @@ extension MainSplitViewController { ) } + /// Reached through the workspace's coordinator rather than `commandActions`, which exists only + /// once the browse content has appeared. A window opened straight into Agent mode never shows + /// that content, so its Safe Mode list had no checkmark and no entry in it did anything. @objc func setSafeModeLevel(_ sender: Any?) { guard let raw = (sender as? NSMenuItem)?.representedObject as? String, let level = SafeModeLevel(rawValue: raw) else { return } - commandActions?.coordinator?.setSafeModeLevel(level) + workspaces.selected?.sessionState?.coordinator.setSafeModeLevel(level) + } + + /// What the Safe Mode list offers for the connection on screen, or nil with no session behind it. + var safeModeStatus: SafeModeStatus? { + workspaces.selected?.sessionState?.coordinator.safeModeStatus } @objc func switchSessionContext(_ sender: Any?) { diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift index af21b61f5..61d0a0af5 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift @@ -137,6 +137,15 @@ extension MainSplitViewController { commandActions?.importTables(formatId: formatId) } + /// One named format, from a list `ImportFormatMenuDelegate` filled. Its own selector rather + /// than a second reading of `importData(_:)`, because that one is the menu bar's Import Data + /// and carries ⇧⌘I: AppKit ignores a key equivalent on an item that owns a submenu, so the + /// shortcut has to stay on a leaf that needs no format. + @objc func importDataFormat(_ sender: Any?) { + guard let formatId = (sender as? NSMenuItem)?.representedObject as? String else { return } + commandActions?.importTables(formatId: formatId) + } + @objc func backupDatabase(_ sender: Any?) { commandActions?.backupDatabase() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Focus.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Focus.swift index 845657b02..151b62461 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Focus.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Focus.swift @@ -58,32 +58,59 @@ internal extension MainSplitViewController { /// A visible inspector with nothing to inspect draws a `ContentUnavailableView` and holds no key /// view, so the command would reveal a pane it cannot focus and report success. While the pane is - /// hidden its content is not built yet, and revealing it is a visible outcome of its own. + /// hidden its content is not built yet, and revealing it is a visible outcome of its own. Agent + /// mode draws no inspector at all, so there the command is dimmed. var canFocusInspector: Bool { - guard canToggleTrailingPane else { return false } + guard TrailingPaneCommandResolver.inspectorFocus(trailingPaneCommandContext) != nil else { return false } guard isInspectorVisible else { return true } return workspaces.selected?.panes.inspector.view.firstKeyViewDescendant != nil } + /// Into the trailing pane while browsing, and into the content column in Agent mode, where the + /// same conversation is drawn. The pane beside it holds the result there, and revealing the + /// assistant first would have written a browse preference and focused a pane with no window. @discardableResult func focusAssistantPane() -> Bool { - guard canFocusAssistant else { return false } - showAssistant() - return focusFirstKeyView(in: workspaces.selected?.panes.assistant.view) + switch TrailingPaneCommandResolver.assistantFocus(trailingPaneCommandContext) { + case .conversation?: + return focusComposer(in: shownConversation) + case .trailingPane?: + showAssistant() + return focusComposer(in: workspaces.selected?.panes.assistant.view) + case nil: + return false + } } + /// The conversation column is checked for a composer because Agent mode draws one only once a + /// session and a provider are there to answer it. var canFocusAssistant: Bool { - canRevealAssistant + switch TrailingPaneCommandResolver.assistantFocus(trailingPaneCommandContext) { + case .conversation?: + return shownConversation?.firstDescendant(of: ChatComposerNSTextView.self) != nil + case .trailingPane?: + return true + case nil: + return false + } } - /// The one answer to "can the assistant be put on screen", shared with the View menu's toggle. - /// The assistant is the single surface a setting can take away, so the command goes with it. - var canRevealAssistant: Bool { - isAssistantVisible || (currentPane == .content && AppSettingsManager.shared.ai.enabled) + /// Asked only while the conversation is the tree in the detail column. A connection that drops + /// in Agent mode hands the column to the unavailable screen and keeps the conversation built + /// behind it, detached, and a search of the pane alone still found its composer there: the + /// command stayed enabled, and `makeFirstResponder` on a view in no window reported success + /// while it moved focus off Retry and onto the window itself. + private var shownConversation: NSView? { + guard let selected = workspaces.selected, selected.detailMode == .agent else { return nil } + return selected.panes.agentConversation.view } + /// Asked only of browse content the window is showing. Agent mode keeps the editor mounted + /// behind the conversation, detached, and a search of the tree alone would find it there and + /// offer to focus a view that is in no window. private var mountedQueryEditor: TextView? { - workspaces.selected?.panes.detail.view.firstDescendant(of: TextView.self) + guard let selected = workspaces.selected, selected.detailMode == .browse else { return nil } + return selected.panes.detail.view.firstDescendant(of: TextView.self) } /// Revealing a pane parents its views on the next layout pass, so the search has to run after @@ -95,4 +122,14 @@ internal extension MainSplitViewController { guard let target = paneView.firstKeyViewDescendant else { return false } return window.makeFirstResponder(target) } + + /// The composer rather than the first view that takes the keyboard. A transcript's messages are + /// selectable text and come first in the tree, and Focus Assistant is a request to type. + private func focusComposer(in paneView: NSView?) -> Bool { + guard let paneView, let window = view.window else { return false } + paneView.layoutSubtreeIfNeeded() + let composer: NSView? = paneView.firstDescendant(of: ChatComposerNSTextView.self) + guard let target = composer ?? paneView.firstKeyViewDescendant else { return false } + return window.makeFirstResponder(target) + } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 587322cff..8c63edccf 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -7,7 +7,7 @@ import AppKit /// Everything the menu bar needs to decide whether a command applies, captured once /// per validation pass. Keeping it a plain value keeps `isEnabled` pure and testable, -/// the same split `MainWindowToolbar+Validation` uses for the toolbar. +/// the same split `ToolbarContextResolver` uses for the toolbar. struct MenuValidationContext: Equatable { /// Comes from the window's own `ConnectionWindowPhase`, never from the presence of a /// coordinator: the coordinator deliberately outlives a lost session so a reconnect keeps @@ -16,6 +16,20 @@ struct MenuValidationContext: Equatable { /// failed to dial can still be dismissed. var hasSelectedWorkspace = false var isConnected = false + /// Whether the connection on screen is showing its agent rather than its objects. The session + /// commands are the rail's, and the rail is only there in Agent mode. + var isAgentMode = false + /// The session a session command acts on: the one its menu item names, or the one the rail has + /// highlighted. Nil when there is none, which is what dims Open, Close and Delete Session. + var agentSessionTarget: AgentSessionStatus? + /// Whether the connection on screen has an assistant conversation for its three commands to act + /// on. False until something opens the assistant, and false with the AI feature off, which is + /// what the pane's own menu already says by dimming the same three. + var hasAssistantConversation = false + /// Whether any conversation has been stored, which is what Conversation History lists and what + /// Clear Recents throws away. Separate from the one above: a conversation started and never sent + /// in has a model and nothing to switch to. + var hasStoredConversations = false var isReadOnly = false var canUseTableResultCommands = false var canUseGridFindCommands = false @@ -135,15 +149,16 @@ extension MainSplitViewController: NSMenuItemValidation { /// green either way: that shipped as Clear Selection, lit on a window with nothing selected and /// nothing to clear. `MenuValidationCoverageTests` reads the nil to say so. static func resolvedEnablement(_ selector: Selector, context: MenuValidationContext) -> Bool? { + if context.isAgentMode, browseContentSelectors.contains(selector) { return false } if let find = isFindCommandEnabled(selector, context: context) { return find } if let query = isQueryCommandEnabled(selector, context: context) { return query } + if let chooser = isContainerCommandEnabled(selector, context: context) { return chooser } switch selector { case #selector(exportTables(_:)), #selector(refreshDatabase(_:)), #selector(openQuickSwitcher(_:)), #selector(toggleQueryHistory(_:)), - #selector(toggleResults(_:)), #selector(showPreviousResult(_:)), #selector(showNextResult(_:)), #selector(closeResultTab(_:)), @@ -189,7 +204,7 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(closeAllTabs(_:)): return context.canCloseAllTabs - case #selector(importData(_:)): + case #selector(importData(_:)), #selector(importDataFormat(_:)): return context.isConnected && !context.isReadOnly && context.hasImportFormats case #selector(backupDatabase(_:)): return context.isConnected && context.supportsBackup @@ -208,6 +223,12 @@ extension MainSplitViewController: NSMenuItemValidation { return context.hasSelectedWorkspace && AppSettingsManager.shared.ai.enabled case #selector(previewSQL(_:)): return context.isConnected && context.hasDataPendingChanges + /// The results pane belongs to the query editor. The shipped rule was `isConnected` alone, + /// so the command was lit on the seven kinds that have no results pane and `toggleResults` + /// then wrote a collapse flag with no tab-kind guard behind it. This is the rule the + /// toolbar's own item answers by. + case #selector(toggleResults(_:)): + return context.isConnected && context.isQueryTab case #selector(addRow(_:)), #selector(duplicateRow(_:)): return context.isConnected && context.isCurrentTabEditable && !context.isReadOnly @@ -251,24 +272,10 @@ extension MainSplitViewController: NSMenuItemValidation { return objectCommandIsEnabled(selector, context: context) case #selector(runMaintenanceOperation(_:)): return context.isConnected && context.hasMaintenanceOperations - case #selector(switchToSchema(_:)): - return context.isConnected && context.supportsSchemaSwitching - case #selector(setFavoriteDatabaseEnvironment(_:)), #selector(removeFavoriteDatabase(_:)): - return context.isConnected && context.canFavoriteActiveDatabase - case #selector(filterDatabases(_:)): - return context.isConnected && context.canFilterDatabases - case #selector(showAllDatabases(_:)): - return context.isConnected && context.canFilterDatabases && context.hasDatabaseFilter - case #selector(openContainerSwitcher(_:)): - return context.isConnected && context.supportsContainerSwitching - case #selector(openSchemaSwitcher(_:)): - return context.isConnected && context.supportsSchemaSwitching case #selector(setSafeModeLevel(_:)): return context.isConnected case #selector(releaseFileLock(_:)): return context.isConnected && context.canReleaseFileLock - case #selector(switchSessionContext(_:)): - return context.isConnected && context.hasSessionContexts case #selector(showServerDashboard(_:)): return context.isConnected && context.supportsServerDashboard case #selector(showUsersAndRoles(_:)): @@ -295,11 +302,40 @@ extension MainSplitViewController: NSMenuItemValidation { } } + /// What the window is pointed at inside the connection: which database, which schema, which + /// session context, and which databases the tree shows at all. Each is a chooser the driver may + /// not offer, so each follows its own capability rather than the session alone. + /// + /// Answered before the main switch rather than inside it, because that switch is at its length + /// limit and this is a domain of its own. + private static func isContainerCommandEnabled( + _ selector: Selector, + context: MenuValidationContext + ) -> Bool? { + switch selector { + case #selector(openContainerSwitcher(_:)): + return context.isConnected && context.supportsContainerSwitching + case #selector(switchToSchema(_:)), #selector(openSchemaSwitcher(_:)): + return context.isConnected && context.supportsSchemaSwitching + case #selector(switchSessionContext(_:)): + return context.isConnected && context.hasSessionContexts + case #selector(setFavoriteDatabaseEnvironment(_:)), #selector(removeFavoriteDatabase(_:)): + return context.isConnected && context.canFavoriteActiveDatabase + case #selector(filterDatabases(_:)): + return context.isConnected && context.canFilterDatabases + case #selector(showAllDatabases(_:)): + return context.isConnected && context.canFilterDatabases && context.hasDatabaseFilter + default: + return nil + } + } + /// The commands the window answers for itself rather than on behalf of the connection it shows. /// /// Each Focus command follows the pane it names, so one that would focus nothing is dimmed /// rather than silently doing nothing: `makeFirstResponder` accepts a view that cannot take the - /// keyboard and reports success. + /// keyboard and reports success. Agent mode's session commands are the window's own too, and are + /// answered by the helper below rather than inline, because this switch is at its length limit. private static func isWindowCommandEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool? { switch selector { case #selector(toggleWorkspaceRail(_:)), @@ -322,10 +358,86 @@ extension MainSplitViewController: NSMenuItemValidation { /// size, which is an app setting and needs no session. A focused diagram claims them first. case #selector(zoomIn(_:)), #selector(zoomOut(_:)): return true - default: return nil + default: return isAgentSessionCommandEnabled(selector, context: context) + } + } + + /// Agent mode's session commands, which need the rail on screen and, New Session apart, a session + /// to act on. None of them needs a live connection: the rail stands in every phase, a session + /// outlives the connection's, and a conversation is worth reading with the database down. + private static func isAgentSessionCommandEnabled( + _ selector: Selector, + context: MenuValidationContext + ) -> Bool? { + switch selector { + case #selector(newAgentSession(_:)): + return context.isAgentMode + case #selector(openAgentSession(_:)), #selector(deleteAgentSession(_:)): + return context.isAgentMode && context.agentSessionTarget != nil + case #selector(closeAgentSession(_:)): + return context.isAgentMode && context.agentSessionTarget?.isEnded == false + default: + return isConversationCommandEnabled(selector, context: context) + } + } + + /// The assistant's three conversation commands, which answer in both modes: the conversation is + /// one thing shown two ways, in the trailing pane while browsing and in the content column in + /// Agent mode, so a command that acts on it applies wherever it is drawn. + /// + /// Each needs the assistant to have been opened, because that is what creates the model they + /// write to. Switching and clearing need a stored conversation on top of that, which is the same + /// pair of conditions the pane header's own menu is dimmed by. + private static func isConversationCommandEnabled( + _ selector: Selector, + context: MenuValidationContext + ) -> Bool? { + switch selector { + case #selector(newAIConversation(_:)): + return context.hasAssistantConversation + case #selector(switchAIConversation(_:)), #selector(clearAIConversations(_:)): + return context.hasAssistantConversation && context.hasStoredConversations + default: + return nil } } + /// The commands that act on the browse content, which Agent mode does not mount. + /// + /// Every one of them has a toolbar twin whose `ToolbarContextResolver` arm answers no in Agent + /// mode, and the menu bar is where most of them now live, so leaving them lit here would be the + /// same defect one surface deeper: Refresh over a grid that is not there, Save over a commit gate + /// frozen at the moment the mode changed, Command Y flipping a persisted flag for a drawer that + /// is not mounted, and New Tab opening a tab behind the conversation. + /// + /// A set rather than an arm each, because the rule is one rule. `MenuContentModeParityTests` + /// holds the two surfaces' answers together and derives this list back out of the toolbar, so a + /// browse-only item added there without an entry here fails rather than ships enabled. + /// + /// What is deliberately not here: Switch Connection, Close Connection, Safe Mode, the two mode + /// commands, the session commands and the conversation commands. Each of those acts on the + /// window or on the session, both of which Agent mode still has. + private static let browseContentSelectors: Set = [ + #selector(refreshDatabase(_:)), + #selector(saveDocument(_:)), + #selector(addRow(_:)), + #selector(restorePreviousValues(_:)), + #selector(previewSQL(_:)), + #selector(toggleResults(_:)), + #selector(toggleQueryHistory(_:)), + #selector(newEditorTab(_:)), + #selector(openQuickSwitcher(_:)), + #selector(exportTables(_:)), + /// Both spellings of one command: the leaf that takes the driver's first format, and the + /// row of the list that names another. One without the other would leave the list live over + /// a leaf that is dim. + #selector(importData(_:)), + #selector(importDataFormat(_:)), + #selector(showServerDashboard(_:)), + #selector(navigateBack(_:)), + #selector(navigateForward(_:)), + ] + /// What AppKit is told. A command this window does not own is left enabled, which is what keeps /// `performClose:` and the rest of the system's own items working. static func isEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool { @@ -416,16 +528,30 @@ extension MainSplitViewController: NSMenuItemValidation { /// The workspace-rail facts come from the window in both branches. They are true of the window, /// not of the connection it happens to be showing, and reading them off a connection that has /// no coordinator left disabled the only menu route to the window's other connections. + /// + /// Focus Assistant is the window's too. A window opened straight into Agent mode draws the + /// conversation in its content column and never mounts the browse content that sets up the + /// command actions, so read from them, the one command that reaches its composer was dimmed. var menuValidationContext: MenuValidationContext { + let conversations = assistantConversationModel guard let actions = commandActions else { return MenuValidationContext( hasSelectedWorkspace: workspaces.selectedConnectionId != nil, + isAgentMode: contentMode == .agent, + agentSessionTarget: agentSessionTarget(for: nil)?.status, + hasAssistantConversation: conversations != nil, + hasStoredConversations: conversations?.conversations.isEmpty == false, + canFocusAssistant: canFocusAssistant, canToggleWorkspaceRail: canToggleWorkspaceRail ) } return MenuValidationContext( hasSelectedWorkspace: workspaces.selectedConnectionId != nil, isConnected: isConnected, + isAgentMode: contentMode == .agent, + agentSessionTarget: agentSessionTarget(for: nil)?.status, + hasAssistantConversation: conversations != nil, + hasStoredConversations: conversations?.conversations.isEmpty == false, isReadOnly: actions.isReadOnly, canUseTableResultCommands: actions.canUseTableResultCommands, canUseGridFindCommands: actions.canUseGridFindCommands, @@ -504,16 +630,37 @@ extension MainSplitViewController: NSMenuItemValidation { /// surfaces need a session to open and none to close. if action == #selector(toggleSidebar(_:)) { return true } if action == #selector(toggleInspector(_:)) { return canToggleTrailingPane } - /// The assistant is the one surface a setting can take away, so its command goes with it - /// rather than staying enabled over a pane that would refuse to open. - if action == #selector(toggleAssistant(_:)) { return canRevealAssistant } + if action == #selector(toggleAssistant(_:)) { return canToggleAssistant } if action == #selector(setResultView(_:)) { return canShowResultView(menuItem) } if action == #selector(setSafeModeLevel(_:)) { return canChooseSafeModeLevel(menuItem) } if action == #selector(requestDisconnect) { return canDisconnect } if action == #selector(retryConnection) { return canReconnect } - return Self.isEnabled(action, context: menuValidationContext) + return Self.isEnabled(action, context: menuValidationContext(naming: menuItem)) + } + + /// The window's context, with the session a session command acts on taken from the item rather + /// than from the rail: a menu that lists a connection's sessions names one in each of its items, + /// and every other route acts on the one the rail has highlighted. + /// + /// Keyed on the action rather than on the type in `representedObject`. A conversation row carries + /// a `UUID` too, and reading that one as a session id resolved a session that does not exist and + /// wrote its absence over the rail's own highlight, so a conversation row in an open menu decided + /// what the session commands beside it reported. + private func menuValidationContext(naming menuItem: NSMenuItem) -> MenuValidationContext { + var context = menuValidationContext + guard let action = menuItem.action, Self.agentSessionSelectors.contains(action) else { return context } + context.agentSessionTarget = agentSessionTarget(for: menuItem)?.status + return context } + /// The commands whose subject is a session, and the only ones that may read a session id out of + /// a menu item. + private static let agentSessionSelectors: Set = [ + #selector(openAgentSession(_:)), + #selector(closeAgentSession(_:)), + #selector(deleteAgentSession(_:)), + ] + private func isCurrentContentMode(_ menuItem: NSMenuItem) -> Bool { guard let raw = menuItem.representedObject as? String, let mode = ConnectionWorkspaceContentMode(rawValue: raw) else { return false } @@ -529,10 +676,12 @@ extension MainSplitViewController: NSMenuItemValidation { switch action { case #selector(toggleSidebar(_:)): setTitle(isSidebarCollapsed ? "Show Sidebar" : "Hide Sidebar", on: menuItem) + /// Both read the surface the pane is drawing, so in Agent mode the pane toggle names the + /// result column it opens and closes instead of offering to hide an inspector nobody sees. case #selector(toggleInspector(_:)): - setTitle(isInspectorVisible ? "Hide Inspector" : "Show Inspector", on: menuItem) + setResolvedTitle(TrailingPaneCommandResolver.paneToggleTitle(trailingPaneCommandContext), on: menuItem) case #selector(toggleAssistant(_:)): - setTitle(isAssistantVisible ? "Hide Assistant" : "Show Assistant", on: menuItem) + setResolvedTitle(TrailingPaneCommandResolver.assistantToggleTitle(trailingPaneCommandContext), on: menuItem) case #selector(toggleWorkspaceRail(_:)): setTitle(isWorkspaceRailEnabled ? "Hide Connections" : "Show Connections", on: menuItem) case #selector(undo(_:)): @@ -595,11 +744,15 @@ extension MainSplitViewController: NSMenuItemValidation { return commandActions?.availableResultsViewModes.contains(mode) ?? false } + /// Read through the same status the list is built from, so an entry the floor rules out cannot + /// validate as a choice. The connection's own floor is blind to Agent mode, and asking it enabled + /// a weaker level the write would then hold at Alert. private func canChooseSafeModeLevel(_ menuItem: NSMenuItem) -> Bool { guard isConnected, let raw = menuItem.representedObject as? String, - let level = SafeModeLevel(rawValue: raw) else { return false } - return commandActions?.coordinator?.connection.safeModeFloor?.allows(level) ?? true + let level = SafeModeLevel(rawValue: raw), + let status = safeModeStatus else { return false } + return status.offers(level) } private func isCurrentResultView(_ menuItem: NSMenuItem) -> Bool { diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift index d570b255e..e1268b0de 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift @@ -40,7 +40,11 @@ internal extension MainSplitViewController { func applyTabStripVisibility() { let tabCount = workspaces.selected?.sessionState?.tabManager.tabs.count ?? 0 tabStripAccessory.setBandVisible( - ConnectionWindowPaneResolver.showsTabStrip(for: currentPane, tabCount: tabCount) + ConnectionWindowPaneResolver.showsTabStrip( + for: currentPane, + tabCount: tabCount, + contentMode: contentMode + ) ) armTabStripObservation() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TrailingPane.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TrailingPane.swift new file mode 100644 index 000000000..ec0d9a410 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TrailingPane.swift @@ -0,0 +1,170 @@ +// +// MainSplitViewController+TrailingPane.swift +// TablePro +// + +import AppKit +import Combine + +/// The window's one trailing pane: which surface it draws, and the commands that open, close and +/// switch it. +/// +/// One split item, one autosave record and one 270pt floor for all three surfaces. A surface change +/// moves only which of the workspace's hosting controllers the item parents, and every question about +/// the pane is answered from `TrailingPaneSurfaceResolver`, through `trailingPaneCommandContext`, so +/// nothing here can report about a surface the window is not drawing. Four separate readings of the +/// stored surface used to disagree with the one that decided what was parented, and only that one knew +/// Agent mode imposes the result. +extension MainSplitViewController: TrailingPaneProxy { + /// Everything the trailing-pane commands decide from, for the connection on screen. + var trailingPaneCommandContext: TrailingPaneCommandResolver.Context { + let selected = workspaces.selected + return TrailingPaneCommandResolver.Context( + contentMode: selected?.contentMode ?? .browse, + storedSurface: selected?.trailingPaneState?.surface ?? .inspector, + isPaneOpen: isTrailingPaneOpen, + isAIEnabled: AppSettingsManager.shared.ai.enabled, + hasContent: currentPane == .content + ) + } + + /// Which surface a workspace's pane draws, whether or not it is the one on screen: the answer + /// decides which of its hosting controllers is parented when it is selected. + func resolvedTrailingSurface(for workspace: ConnectionWorkspace) -> TrailingPaneSurface { + TrailingPaneSurfaceResolver.resolve( + stored: workspace.trailingPaneState?.surface ?? .inspector, + contentMode: workspace.contentMode, + isAIEnabled: AppSettingsManager.shared.ai.enabled + ) + } + + var isTrailingPaneOpen: Bool { + guard let inspectorSplitItem else { return false } + return !inspectorSplitItem.isCollapsed + } + + var isInspectorVisible: Bool { + trailingPaneCommandContext.isShowing(.inspector) + } + + var isAssistantVisible: Bool { + trailingPaneCommandContext.isShowing(.assistant) + } + + /// Opening a trailing surface needs a session to put in it. Closing one the user already has + /// open does not, and the window no longer takes it down on their behalf, so a connection that + /// drops with the inspector open would otherwise leave an empty column with no command to + /// close it. + var canToggleTrailingPane: Bool { + TrailingPaneCommandResolver.canTogglePane(trailingPaneCommandContext) + } + + /// The assistant is the one surface a setting can take away, and the one Agent mode has no pane + /// for, so its command goes with both rather than staying enabled over a pane that would refuse + /// to open. + var canToggleAssistant: Bool { + TrailingPaneCommandResolver.canToggleAssistant(trailingPaneCommandContext) + } + + func showInspector() { + reveal(.inspector) + } + + func showAssistant() { + reveal(.assistant) + } + + func hideTrailingPane() { + inspectorSplitItem?.animator().isCollapsed = true + recomputeWindowMinSize() + } + + func toggleInspector() { + perform(TrailingPaneCommandResolver.paneToggle(trailingPaneCommandContext)) + } + + func toggleAssistant() { + guard let effect = TrailingPaneCommandResolver.assistantToggle(trailingPaneCommandContext) else { return } + perform(effect) + } + + /// Reveals without writing, so a pane that auto-show opened is not recorded as one the user + /// chose. `revealsForSelection` says why it reads the stored surface. + func revealInspectorForSelection() { + guard TrailingPaneCommandResolver.revealsForSelection(trailingPaneCommandContext) else { return } + presentTrailingPane() + } + + /// Parents whichever surface the selected workspace is showing. + /// + /// Measured: swapping the hosted child of an inspector split item leaves its width exactly as + /// the user dragged it, so a surface change costs a view swap and nothing else. Assigning + /// `viewController` on the item itself instead would throw, which is why the pane is a + /// container in the first place. + func showSelectedTrailingPane() { + let selected = workspaces.selected + followStoredSurface(of: selected?.trailingPaneState) + guard let selected else { + inspectorPaneHost.show(nil) + return + } + inspectorPaneHost.show(selected.panes.trailingPane(for: resolvedTrailingSurface(for: selected))) + } + + /// The one writer of the stored surface besides the pane header's picker, and it writes only + /// what `TrailingPaneCommandResolver.reveal` calls a choice. + private func reveal(_ surface: TrailingPaneSurface) { + let decision = TrailingPaneCommandResolver.reveal(surface, trailingPaneCommandContext) + guard decision.opensPane else { return } + if decision.storesChoice { + workspaces.selected?.trailingPaneState?.surface = surface + } + presentTrailingPane() + } + + private func presentTrailingPane() { + rebuildTrailingPanes() + showSelectedTrailingPane() + inspectorSplitItem?.animator().isCollapsed = false + recomputeWindowMinSize() + } + + private func perform(_ effect: TrailingPaneCommandResolver.Effect) { + switch effect { + case .hide: + hideTrailingPane() + case .reveal(let surface): + reveal(surface) + } + } + + /// Follows the stored surface of the connection on screen, which is what the pane header's + /// picker writes, knowing nothing about the window. + /// + /// The new surface is parented on the next turn of the run loop, so the view whose segment was + /// clicked leaves the window after the picker's action has returned rather than from inside it. + /// `@Published` also announces a change before the value is stored, so a synchronous reparent + /// would read the surface being replaced. + private func followStoredSurface(of state: TrailingPaneState?) { + guard state !== observedTrailingPaneState else { return } + observedTrailingPaneState = state + trailingSurfaceCancellable = state?.$surface + .dropFirst() + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.parentStoredSurface() + } + } + + /// A reveal has already parented what it wrote, so this does nothing then. A header choice is + /// drawn with the command actions the window has now, which the pane was built before, the same + /// reason every reveal rebuilds the two surfaces first. + private func parentStoredSurface() { + guard let selected = workspaces.selected else { return } + let target = selected.panes.trailingPane(for: resolvedTrailingSurface(for: selected)) + guard inspectorPaneHost.shown !== target else { return } + rebuildTrailingPanes() + showSelectedTrailingPane() + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift index cd3559bc3..0359960cf 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift @@ -22,8 +22,8 @@ extension MainSplitViewController { activateWorkspace(offsetBy: 1) } - /// Both mode commands. A tolerant sender: AppKit hands the menu item here and the toolbar - /// group's own action hands the group, and neither should be the only one that works. + /// Both routes to a mode, View > Mode and the Actions pull-down, send a menu item that names + /// its mode in `representedObject`. An item without one does nothing. @objc func setContentModeFromMenu(_ sender: Any?) { guard let raw = (sender as? NSMenuItem)?.representedObject as? String, let mode = ConnectionWorkspaceContentMode(rawValue: raw) else { return } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index f2fb0bb14..9ba2c06e4 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -14,7 +14,7 @@ import os import SwiftUI @MainActor -internal final class MainSplitViewController: NSSplitViewController, TrailingPaneProxy { +internal final class MainSplitViewController: NSSplitViewController { nonisolated private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") // MARK: - Payload & Session @@ -42,11 +42,6 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan set { workspaces.selected?.sessionState = newValue } } - private var trailingPaneState: TrailingPaneState? { - get { workspaces.selected?.trailingPaneState } - set { workspaces.selected?.trailingPaneState = newValue } - } - var autoConnect: Bool { workspaces.selected?.autoConnect ?? false } var attemptToken: UUID? { @@ -76,6 +71,12 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan didSet { view.window?.subtitle = windowSubtitle } } + /// The file behind the titlebar's proxy icon, resolved with the title and written through the + /// same guard, so only the tree the window is showing can set it. + var windowRepresentedURL: URL? { + didSet { view.window?.representedURL = windowRepresentedURL } + } + // MARK: - Split View Items internal private(set) var sidebarSplitItem: NSSplitViewItem! @@ -86,7 +87,36 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// Stable containers, one per split item. The pane they show is the selected workspace's own, /// so switching connection is a view swap and every other connection's tree stays built. internal private(set) var detailPaneHost: WorkspacePaneHost! - private var inspectorPaneHost: WorkspacePaneHost! + internal private(set) var inspectorPaneHost: WorkspacePaneHost! + + /// The pane state whose surface the window is following, and the subscription that follows it. + /// It is the selected connection's, re-armed whenever the trailing pane is parented, so a choice + /// made in the pane header's picker reaches the split item without the picker knowing the window. + weak var observedTrailingPaneState: TrailingPaneState? + var trailingSurfaceCancellable: AnyCancellable? + + /// The agent session whose name the window carries, and the subscription that follows it. Only + /// ever the selected connection's displayed session in Agent mode, re-armed by `applyWindowTitle`. + weak var observedAgentSession: AgentSession? + var agentTitleCancellable: AnyCancellable? + + /// How a session command asks before it runs: a sheet on this window. Replaced under test, where + /// a modal alert holds the whole run with nobody there to answer it. + var confirmAgentSessionCommand: AgentSessionConfirming = { confirmation, window in + await MainSplitViewController.presentAgentSessionConfirmation(confirmation, in: window) + } + + /// The same door for Clear Recents, which is the one conversation command that destroys + /// something. The question is the pane header's own, moved here with the command so the menu bar + /// and the pane ask it once and in the same words. + var confirmClearConversations: @MainActor (NSWindow?) async -> Bool = { window in + await AlertHelper.confirmDestructive( + title: String(localized: "Clear All Conversations?"), + message: String(localized: "This will permanently delete all conversation history."), + confirmButton: String(localized: "Clear"), + window: window + ) + } /// The editor tab strip's band. It is a titlebar accessory rather than a split item, so it is /// owned here but installed on the window, and it follows the selected workspace the same way @@ -207,10 +237,13 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan let resolvedConnection = DatabaseManager.shared.activeSessions[connectionId]?.connection ?? ConnectionStorage.shared.loadConnections().first { $0.id == connectionId } + /// One registry for the workspace and the assistant inside its trailing pane, so Agent mode + /// and the assistant draw the same sessions. + let agentSessions = AgentSessionRegistry.shared var state: SessionStateFactory.SessionState? var panelState: TrailingPaneState? if let session = resolvedSession { - panelState = TrailingPaneState(connectionId: session.connection.id) + panelState = TrailingPaneState(connectionId: session.connection.id, sessionRegistry: agentSessions) if let payloadId = payload?.id, let pending = SessionStateFactory.consumePending(for: payloadId) { state = pending @@ -244,7 +277,8 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan session: resolvedSession, sessionState: state, trailingPaneState: panelState, - phase: phase + phase: phase, + agentSessions: agentSessions ) let adopted = workspaces.insert(workspace) @@ -280,6 +314,9 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan self?.navigationSidebar.applyRailWidth(animated: false) self?.recomputeWindowMinSize() } + navigationSidebar.objectBrowser.onScopeSelection = { [weak self] tab in + self?.setSidebarTab(tab) + } sidebarSplitItem = NSSplitViewItem(sidebarWithViewController: navigationSidebar) sidebarSplitItem.canCollapse = true sidebarSplitItem.minimumThickness = Self.sidebarMinThickness @@ -288,7 +325,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan detailPaneHost = WorkspacePaneHost() detailSplitItem = NSSplitViewItem(viewController: detailPaneHost) - detailSplitItem.minimumThickness = Self.resolveDetailMinimumThickness(for: payload?.tabType) + detailSplitItem.minimumThickness = Self.resolveDetailMinimumThickness( + for: payload?.tabType, + contentMode: contentMode + ) detailSplitItem.holdingPriority = .defaultLow addSplitViewItem(detailSplitItem) @@ -327,13 +367,11 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan applyPaneChrome() } - /// A divider dragged all the way in collapses the sidebar without going through - /// `toggleSidebar(_:)`, so the toolbar has to be reconciled here too or its segment stays lit - /// over a sidebar that is no longer on screen. + /// A divider dragged all the way in collapses a pane without going through a command, so the + /// window's minimum width is settled here as well as on the commands that collapse one. override func splitViewDidResizeSubviews(_ notification: Notification) { super.splitViewDidResizeSubviews(notification) recomputeWindowMinSize() - toolbarOwner?.syncSidebarSelection() } override func viewWillAppear() { @@ -342,6 +380,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan window.title = windowTitle window.subtitle = windowSubtitle + window.representedURL = windowRepresentedURL if let sessionState { sessionState.coordinator.trailingPaneProxy = self @@ -437,6 +476,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan } guard repaint else { return } applyWindowTitle() + /// An edit can change the engine, and what the engine can do decides whether the container + /// capsule stands at all. Nothing else would reconsider it for a connection with no + /// session, whose toolbar state never publishes. + toolbarOwner?.refreshContext() } // MARK: - Toolbar @@ -446,8 +489,8 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// /// Attaching it only once a coordinator existed is what made a dozen items arrive at once, a /// second and a half into a connect, over a window that had been on screen the whole time. - /// `MainWindowToolbar` already answers with no subject: `validationContext()` returns nil, so - /// every connection-scoped item validates to disabled and only the window's own commands stay + /// `MainWindowToolbar` already answers with no subject: its context says nothing is connected, + /// so every connection-scoped item validates to disabled and only the window's own commands stay /// live, which is the dimmed-not-absent state the HIG asks for. /// /// The subject is set before the toolbar reaches the window, so a window opening onto a live @@ -462,6 +505,13 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan owner.repoint(to: coordinator) guard window.toolbar !== owner.managedToolbar else { return } window.toolbar = owner.managedToolbar + /// The item set every window starts from is the full one, because `isHidden` is never + /// persisted, so the context is applied the moment the items exist rather than on the first + /// change to it. Measured on macOS 27, that moment is this assignment: `NSToolbar.items` is + /// filled before it returns, with the window not yet shown, and a hide written here holds + /// when the window appears. A repoint cannot be relied on for this: a window opening with + /// no session repoints from nothing to nothing, which returns before it reaches the toolbar. + owner.refreshContext(forcing: true) /// The transparency decision reads `window.toolbar?.isVisible`, so a window that gains its /// toolbar after that decision was taken keeps the opaque titlebar chosen for a /// toolbar-less one, over content that is no longer inset below it. @@ -482,7 +532,6 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// that loses its session still has to reach the right phase, or switching to it later /// would show content for a connection that is already gone. private func handleConnectionStatusChange() { - defer { toolbarOwner?.syncSidebarSelection() } for workspace in workspaces.workspaces { reconcileStatus(of: workspace) } @@ -530,7 +579,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan workspace.session = session if workspace.trailingPaneState == nil { - workspace.trailingPaneState = TrailingPaneState(connectionId: session.connection.id) + workspace.trailingPaneState = TrailingPaneState( + connectionId: session.connection.id, + sessionRegistry: workspace.agentSessions + ) } if workspace.sessionState == nil { let state = SessionStateFactory.create(connection: session.connection, payload: workspace.payload) @@ -608,6 +660,14 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// costs a key comparison when nothing has moved, which is what the record is for. syncSelectedPanes() showSelectedPanes() + + /// A workspace put into Agent mode while another connection was on screen never reached + /// the branch that reveals its columns, because that branch only ever touched the selected + /// one. Selection is where it gets them, which is the same repair-on-selection shape the + /// pane render key already relies on. + if let selected = workspaces.selected { + applyColumnVisibility(for: selected.connectionId, mode: selected.resolvedContentMode) + } applyDetailMinimumThicknessForSelection() applyPaneChrome() applyWindowTitle() @@ -615,6 +675,14 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// Only this window's rail moved, and only its highlight. Broadcasting instead made every /// rail in the app rebuild its whole entry list to answer a question none of them asked. navigationSidebar?.railController.refreshSelection() + + /// The toolbar's shape follows the workspace, not only its coordinator. Pointing the toolbar + /// reaches it through a repoint, and a switch between two workspaces that both have no + /// coordinator is a repoint from nothing to nothing, which returns before it looks. The + /// engine and the mode still differ between the two, so a down SQLite connection left the + /// container capsule hidden over a down PostgreSQL one. When nothing has moved this costs + /// building the key and one comparison. + toolbarOwner?.refreshContext() } private func applyPhase() { @@ -627,9 +695,16 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// Repainted on every phase change for the same reason the panes are. Leaving it out is /// what let a window keep the name of a table it had stopped showing after the session /// underneath it went away. + /// + /// In Agent mode the name is the session's, and a session names itself only once its first + /// question or reply arrives, so the title it is read from is followed rather than read once. internal func applyWindowTitle() { + let agentSession = workspaces.selected?.displayedAgentSession + followTitle(of: agentSession) let resolved = WindowTitleResolver.resolveWindow( pane: currentPane, + contentMode: contentMode, + agentSessionTitle: agentSession?.title, connection: paneConnection, tab: sessionState?.tabManager.selectedTab, hasTabs: !(sessionState?.tabManager.tabs.isEmpty ?? true), @@ -637,6 +712,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan ) windowTitle = resolved.title windowSubtitle = resolved.subtitle + windowRepresentedURL = resolved.representedURL } internal func transition(to next: ConnectionWindowPhase) { @@ -655,12 +731,15 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan let phaseChanged = workspace.phase != next workspace.phase = next syncPanes(of: workspace) - /// `syncPanes` rebuilds both trailing roots but parents neither: which one is hosted is - /// decided by `showSelectedTrailingPane`, and none of its other callers is on the adoption - /// path. A connection whose state is built here, restoring a persisted assistant, would - /// otherwise keep the inspector `viewDidLoad` mounted before that state existed, while - /// every command reported the assistant as the visible surface. + /// `syncPanes` rebuilds the roots but parents none of them: which ones are hosted is decided + /// by the `showSelected` functions, and none of their other callers is on the adoption path. + /// A connection whose state is built here, restoring a persisted assistant, would otherwise + /// keep the inspector `viewDidLoad` mounted before that state existed, while every command + /// reported the assistant as the visible surface. The content columns move with the phase + /// too: in Agent mode a connection that drops hands the detail column from the conversation + /// to the unavailable screen and its Retry, and a retry hands it back. if workspaces.selectedConnectionId == connectionId { + showSelectedContentPanes() showSelectedTrailingPane() } guard phaseChanged else { return } @@ -702,10 +781,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan // MARK: - Pane Construction /// Rebuilds one connection's panes into its own hosting controllers, whether or not it is the - /// one on screen, and records what they were built from. This is the only place all four panes - /// are produced, and the only writer of the record; `rebuildTrailingPanes()` refines the - /// inspector alone once `commandActions` exists, which is a redraw of the same key rather than - /// a different one. + /// one on screen, and records what they were built from. This is the only place the panes are + /// produced, Agent mode's included, and the only writer of the record; `rebuildTrailingPanes()` + /// refines the inspector alone once `commandActions` exists, which is a redraw of the same key + /// rather than a different one. /// /// Reaching a pane that is not on screen is safe and deliberate: a `rootView` write on an /// unparented hosting controller is deferred rather than lost, and the last value written is @@ -717,7 +796,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan workspace.panes.detail.rootView = AnyView(buildDetailView(for: workspace)) workspace.panes.inspector.rootView = AnyView(buildInspectorView(for: workspace)) workspace.panes.assistant.rootView = AnyView(buildAssistantView(for: workspace)) - workspace.panes.agentResult.rootView = AnyView(buildAgentResultView(for: workspace)) + refreshAgentPanes(of: workspace) refreshTabStripPane(of: workspace) workspace.panes.markRendered(workspace.paneRenderKey) guard isShowing(workspace) else { return } @@ -740,17 +819,30 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// Puts the selected connection's already-built panes on screen. This is the whole cost of a /// workspace switch now: three view swaps, with nothing rebuilt and nothing thrown away. private func showSelectedPanes() { - let selected = workspaces.selected - navigationSidebar.objectBrowser.show(selected?.panes.sidebar) - detailPaneHost.show(selected?.panes.detail) + showSelectedContentPanes() showSelectedTrailingPane() showSelectedTabStrip() - if let selected { bindSidebarChrome(to: selected) } + if let selected = workspaces.selected { bindSidebarChrome(to: selected) } } - /// The filter field lives above the object list and belongs to the window, so it follows the - /// connection on screen rather than being owned by one. + /// Parents the sidebar and detail trees the selected connection's mode draws, and does nothing + /// else, so a mode toggle is the same view swap a workspace switch is. + /// + /// Parenting happens here and in no builder, so a connection put into Agent mode while another + /// is on screen has its panes built for the mode at once and parented only when it is selected: + /// `applySelectedWorkspace` calls through here after its sync, which is the same repair a + /// background connect relies on (#2545). + func showSelectedContentPanes() { + let selected = workspaces.selected + navigationSidebar?.objectBrowser.show(selected.map { $0.panes.sidebarPane(for: $0.resolvedContentMode) }) + detailPaneHost?.show(selected.map { $0.panes.detailPane(for: $0.detailMode) }) + } + + /// The scope control and the filter field live above the object list and belong to the window, + /// so they follow the connection on screen rather than being owned by one. Agent mode draws no + /// object list for either of them to act on, so both stand down while it is on. private func bindSidebarChrome(to workspace: ConnectionWorkspace) { + navigationSidebar.objectBrowser.setChromeHidden(workspace.resolvedContentMode == .agent) /// The pane decides, not the session. A reconnect keeps `sessionState` while the object /// list below the field is empty, and a filter that accepts typing for a list nobody can /// see is a control that answers for nothing. @@ -800,19 +892,13 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// None of them carries a SwiftUI `.id` either. Identity was how one shared hosting controller /// was told that its content had become a different connection; each workspace has its own now, /// so the tree is per-connection by construction and an identity would only throw it away. + /// + /// Nor does either of these two read the mode. Agent mode draws into panes of its own, built in + /// `MainSplitViewController+AgentPanes`, so the browse tree keeps its identity, and everything + /// only it holds, however often the mode is toggled. @ViewBuilder private func buildSidebarView(for workspace: ConnectionWorkspace) -> some View { - if workspace.resolvedContentMode == .agent, let connection = workspace.connection { - AgentSessionRailView( - connectionId: connection.id, - registry: AgentSessionRegistry.shared, - selectedSessionId: AgentSessionRegistry.shared.currentSession(for: connection.id)?.id, - onSelect: { [weak self] sessionId in self?.selectAgentSession(sessionId, for: connection.id) }, - onNewSession: { [weak self] in self?.startAgentSession(for: connection.id) }, - onCloseSession: { sessionId in AgentSessionRegistry.shared.stopSession(id: sessionId) } - ) - .transaction { $0.animation = nil } - } else if workspace.resolvedPane == .content, + if workspace.resolvedPane == .content, let session = workspace.session, let sessionState = workspace.sessionState { SidebarView( @@ -831,27 +917,12 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan } } + /// The unavailable screen here is what Agent mode shows too, for a connection that cannot be + /// reached: `ConnectionWindowPaneResolver.detailMode` hands the column back to this tree then. @ViewBuilder private func buildDetailView(for workspace: ConnectionWorkspace) -> some View { let pane = workspace.resolvedPane - /// Agent mode draws before a session exists on purpose: the prompt the user typed is the - /// thing they are waiting with, and hiding it until the connect lands means typing into - /// nothing and then watching the conversation flash in. - /// - /// It does not preempt `.unavailable`, though. A failed, cancelled or disconnected attempt - /// carries the error, the Retry, the sign-in or edit action and Manage Connections, and a - /// composer with none of those is a dead end whichever mode the window is in. - if workspace.resolvedContentMode == .agent, - pane == .connecting || pane == .content, - let connection = workspace.connection { - AgentConversationView( - connection: connection, - session: AgentSessionRegistry.shared.currentSession(for: connection.id), - isConnecting: pane == .connecting, - onStartSession: { [weak self] in self?.startAgentSession(for: connection.id) } - ) - .transaction { $0.animation = nil } - } else if pane == .connecting, let pendingConnection = workspace.connection { + if pane == .connecting, let pendingConnection = workspace.connection { ConnectingStateView(connection: pendingConnection) { [weak self] in self?.cancelConnectionAttempt(for: workspace.connectionId) } @@ -874,6 +945,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan payload: workspace.payload, windowTitle: windowTitleBinding(for: workspace), windowSubtitle: windowSubtitleBinding(for: workspace), + windowRepresentedURL: windowRepresentedURLBinding(for: workspace), sidebarState: SharedSidebarState.forConnection(session.connection.id), pendingTruncates: sessionBinding(for: workspace, get: { $0.pendingTruncates }, set: { $0.pendingTruncates = $1 }, defaultValue: []), pendingDeletes: sessionBinding(for: workspace, get: { $0.pendingDeletes }, set: { $0.pendingDeletes = $1 }, defaultValue: []), @@ -895,18 +967,27 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// moves off `.content`. Reading the session alone left the inspector mounted over the rows of /// a connection that had stopped answering, which only went unseen while the pane was being /// force-collapsed in exactly that state. + /// + /// The raw mode rather than the resolved one goes to the header, which resolves it against the AI + /// setting as it draws: turning the setting off repaints nothing this builder would be asked for. @ViewBuilder private func buildInspectorView(for workspace: ConnectionWorkspace) -> some View { if workspace.resolvedPane == .content, let session = workspace.session, let paneState = workspace.trailingPaneState { RowInspectorView( - state: paneState.inspector, + paneState: paneState, + contentMode: workspace.contentMode, connection: session.connection ) .environment(\.commandActions, workspace.sessionState?.coordinator.commandActions) } else { - TrailingPaneUnavailableView(surface: .inspector) + TrailingPaneUnavailableView( + surface: .inspector, + reason: .notConnected, + contentMode: workspace.contentMode, + paneState: workspace.trailingPaneState + ) } } @@ -917,11 +998,17 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan let paneState = workspace.trailingPaneState { AssistantPaneView( connection: session.connection, - state: paneState.assistant + paneState: paneState, + contentMode: workspace.contentMode ) .environment(\.commandActions, workspace.sessionState?.coordinator.commandActions) } else { - TrailingPaneUnavailableView(surface: .assistant) + TrailingPaneUnavailableView( + surface: .assistant, + reason: .notConnected, + contentMode: workspace.contentMode, + paneState: workspace.trailingPaneState + ) } } @@ -935,43 +1022,6 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan selected.panes.assistant.rootView = AnyView(buildAssistantView(for: selected)) } - /// The agent session's result pane, built per workspace like the other three. - @ViewBuilder - private func buildAgentResultView(for workspace: ConnectionWorkspace) -> some View { - if workspace.resolvedContentMode == .agent, - let connection = workspace.connection, - let session = AgentSessionRegistry.shared.currentSession(for: connection.id) { - AgentResultPaneView(session: session, connection: connection) - } else { - TrailingPaneUnavailableView(surface: .agentResult) - } - } - - /// Parents whichever surface the selected workspace is showing. - /// - /// Measured: swapping the hosted child of an inspector split item leaves its width exactly as - /// the user dragged it, so a surface change costs a view swap and nothing else. Assigning - /// `viewController` on the item itself instead would throw, which is why the pane is a - /// container in the first place. - func showSelectedTrailingPane() { - guard let selected = workspaces.selected else { - inspectorPaneHost.show(nil) - return - } - /// Agent mode owns the trailing pane for as long as it is on, and never writes that over - /// the surface the user chose for browsing: coming back to Browse puts their choice back. - let surface: TrailingPaneSurface - if selected.resolvedContentMode == .agent { - surface = .agentResult - } else { - surface = TrailingPaneSurface.resolved( - selected.trailingPaneState?.surface ?? .inspector, - isAIEnabled: AppSettingsManager.shared.ai.enabled - ) - } - inspectorPaneHost.show(selected.panes.trailingPane(for: surface)) - } - // MARK: - Session Bindings /// Bound to one workspace's session, not to whichever one is on screen. The old binding read @@ -1001,14 +1051,16 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan ) } - /// The window has one titlebar, so only the connection on screen may name it. Every hosted + /// The window has one titlebar, so only the browse tree on screen may name it. Every hosted /// connection's `MainContentView` writes here whenever its selected tab changes, and those - /// writes no longer stop when the user switches away, because the view is still mounted. + /// writes no longer stop when the user switches away, because the view is still mounted. The + /// same is true of the selected connection's own tree while Agent mode draws the conversation + /// over it, and its tab is then not what the window is showing. private func windowTitleBinding(for workspace: ConnectionWorkspace) -> Binding { Binding( get: { [weak self] in self?.windowTitle ?? "" }, set: { [weak self] newValue in - guard let self, self.isShowing(workspace) else { return } + guard let self, self.isShowingBrowseDetail(of: workspace) else { return } self.windowTitle = newValue } ) @@ -1018,93 +1070,59 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan Binding( get: { [weak self] in self?.windowSubtitle ?? "" }, set: { [weak self] newValue in - guard let self, self.isShowing(workspace) else { return } + guard let self, self.isShowingBrowseDetail(of: workspace) else { return } self.windowSubtitle = newValue } ) } - private func isShowing(_ workspace: ConnectionWorkspace) -> Bool { - workspaces.selectedConnectionId == workspace.connectionId - } - - // MARK: - TrailingPaneProxy - - /// Which surface the selected workspace shows, with the assistant resolved away when the - /// setting has taken it: a connection last left on the assistant must not come back to a - /// surface the settings no longer offer, and no notification reaches that restore. - private var resolvedTrailingSurface: TrailingPaneSurface { - TrailingPaneSurface.resolved( - trailingPaneState?.surface ?? .inspector, - isAIEnabled: AppSettingsManager.shared.ai.enabled + /// Guarded like the title for the same reasons. The browse content used to write the window's + /// proxy icon directly, so a connection in the background, or the tree behind an agent + /// conversation, put its own tab's file on a titlebar naming something else. + private func windowRepresentedURLBinding(for workspace: ConnectionWorkspace) -> Binding { + Binding( + get: { [weak self] in self?.windowRepresentedURL }, + set: { [weak self] newValue in + guard let self, self.isShowingBrowseDetail(of: workspace) else { return } + self.windowRepresentedURL = newValue + } ) } - internal var isTrailingPaneOpen: Bool { - guard let inspectorSplitItem else { return false } - return !inspectorSplitItem.isCollapsed - } - - var isInspectorVisible: Bool { - isTrailingPaneOpen && resolvedTrailingSurface == .inspector - } - - var isAssistantVisible: Bool { - isTrailingPaneOpen && resolvedTrailingSurface == .assistant - } - - func showInspector() { - reveal(.inspector) - } - - func showAssistant() { - guard AppSettingsManager.shared.ai.enabled else { return } - reveal(.assistant) + private func isShowing(_ workspace: ConnectionWorkspace) -> Bool { + workspaces.selectedConnectionId == workspace.connectionId } - /// Auto-show follows a grid click, which is not a request for a different surface. Revealing - /// the inspector unconditionally swapped the assistant out from under a half-typed question and - /// persisted the inspector as that connection's surface, on every row the user clicked. - func revealInspectorForSelection() { - guard !isAssistantVisible else { return } - showInspector() + private func isShowingBrowseDetail(of workspace: ConnectionWorkspace) -> Bool { + isShowing(workspace) && workspace.detailMode == .browse } - func hideTrailingPane() { - inspectorSplitItem?.animator().isCollapsed = true - recomputeWindowMinSize() - } + // MARK: - Trailing Pane /// Puts the hosted child back in step with what the settings now allow. /// /// The stored surface is left alone: a user who turns the assistant off and on again gets it - /// back, because `TrailingPaneSurface.resolved` is what hides it in the meantime rather than + /// back, because `TrailingPaneSurfaceResolver` is what hides it in the meantime rather than /// anything overwriting their choice. /// Turning AI off takes Agent mode with it, and that is every pane rather than the trailing one. /// /// Swapping only the trailing child left a window whose sidebar still held the session rail and /// whose detail pane still held a live conversation, with an inspector beside them: the feature - /// was off and half the window had not heard. + /// was off and half the window had not heard. Each workspace therefore goes through the same + /// path a mode toggle takes, which reparents every column the mode decides and renames the + /// window after what it now shows. private func reconcileTrailingSurfaceAvailability() { guard isViewLoaded else { return } for workspace in workspaces.workspaces { - syncPanes(of: workspace) AgentModeSafeModeFloor.reapply(for: workspace.connectionId) + applyContentMode(for: workspace) } - showSelectedTrailingPane() - applyPaneChrome() - toolbarOwner?.refreshContentMode() + toolbarOwner?.refreshContext() toolbarOwner?.managedToolbar.validateVisibleItems() } - private func reveal(_ surface: TrailingPaneSurface) { - trailingPaneState?.surface = surface - rebuildTrailingPanes() - showSelectedTrailingPane() - inspectorSplitItem?.animator().isCollapsed = false - recomputeWindowMinSize() - } - + /// AppKit's own inspector toolbar item sends this as well as the menu, so it is the window's one + /// trailing-pane toggle, and what it toggles is decided in `MainSplitViewController+TrailingPane`. @objc override func toggleInspector(_ sender: Any?) { toggleInspector() } @@ -1178,7 +1196,8 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan // MARK: - Sidebar /// Whether the object browser is off screen, which is the question every caller is really - /// asking: the toolbar's segment, the Show/Hide Sidebar title and the reveal actions. + /// asking: the Show/Hide Sidebar title, the list checkmarks in the View menu and the reveal + /// actions. var isSidebarCollapsed: Bool { sidebarSplitItem?.isCollapsed ?? true } @@ -1195,15 +1214,6 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan navigationSidebar?.railAllowance ?? 0 } - /// Every collapse route reaches AppKit's own `toggleSidebar(_:)`: the View menu sends the - /// selector down the responder chain, and so does the toolbar's sidebar button. Overriding - /// it is the one place that catches them all, so the toolbar's segment can never stay lit - /// over a collapsed sidebar. - override func toggleSidebar(_ sender: Any?) { - super.toggleSidebar(sender) - toolbarOwner?.syncSidebarSelection() - } - func focusSidebarSearch() { expandSidebarIfCollapsed() navigationSidebar.objectBrowser.focusSearchField() @@ -1223,6 +1233,11 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan navigationSidebar.objectBrowser.hasObjectList } + /// Which of the selected connection's two sidebar trees the column is drawing. + var shownSidebarPane: NSViewController? { + navigationSidebar?.objectBrowser.shownPane + } + private func expandSidebarIfCollapsed() { guard sidebarSplitItem?.isCollapsed == true else { return } sidebarSplitItem?.animator().isCollapsed = false @@ -1246,9 +1261,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan state.databaseFilterSelected = [] } - /// Which list the sidebar is showing, or nil while it is collapsed. The segmented control and - /// the two View-menu items both read it, so neither can report a selection the sidebar is not - /// showing. + /// Which list the sidebar is showing, or nil while it is collapsed. The two View-menu items read + /// it for their checkmarks, so neither can report a selection the sidebar is not showing. The + /// scope control reads the connection's state directly, because it is only ever on screen with + /// the sidebar open. var selectedSidebarTab: SidebarTab? { guard sidebarSplitItem?.isCollapsed == false else { return nil } guard let connectionId = currentSession?.connection.id else { return nil } @@ -1256,18 +1272,18 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan } /// Selects a list and leaves the sidebar open, which is what a command called "Show Tables" - /// has to do. `setSidebarTab` is a toggle, correctly so for the segmented control it serves: - /// pressing the segment already selected closes the sidebar. A menu item that did that would - /// be a Show command that hides. + /// has to do. `setSidebarTab` is a toggle: pressing the list already shown closes the sidebar. + /// A menu item that did that would be a Show command that hides. func revealSidebarTab(_ tab: SidebarTab) { guard let connectionId = currentSession?.connection.id else { return } SharedSidebarState.forConnection(connectionId).selectedSidebarTab = tab if sidebarSplitItem?.isCollapsed == true { sidebarSplitItem?.animator().isCollapsed = false } - toolbarOwner?.syncSidebarSelection() } + /// What the sidebar's scope control drives. It writes the same state the View menu's two + /// commands write, and the control reads that state back, so the three cannot disagree. func setSidebarTab(_ tab: SidebarTab) { guard let connectionId = currentSession?.connection.id else { return } let sidebarState = SharedSidebarState.forConnection(connectionId) @@ -1280,7 +1296,6 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan } else { sidebarState.selectedSidebarTab = tab } - toolbarOwner?.syncSidebarSelection() } // MARK: - Dynamic Window Minimum Size @@ -1292,7 +1307,19 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan static let inspectorMinThickness: CGFloat = 270 private static let sidebarMaxThickness: CGFloat = 600 - static func resolveDetailMinimumThickness(for tabType: TabType?) -> CGFloat { + /// A tab's minimum is a contract about the tab's own content, so it holds only while that content + /// fills the detail column. In Agent mode the conversation does, whatever tab was left selected + /// behind it, and a Users & Roles tab set the conversation's floor to the privilege editor's. + static func resolveDetailMinimumThickness( + for tabType: TabType?, + contentMode: ConnectionWorkspaceContentMode + ) -> CGFloat { + switch contentMode { + case .agent: + return defaultDetailMinThickness + case .browse: + break + } guard let tabType else { return defaultDetailMinThickness } switch tabType { case .usersRoles: @@ -1333,15 +1360,15 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan /// a wide tab would raise the visible connection's minimum and the window's own minimum with it. func updateDetailMinimumThickness(for tabType: TabType?, connectionId: UUID) { guard workspaces.selectedConnectionId == connectionId else { return } - let resolved = Self.resolveDetailMinimumThickness(for: tabType) + let resolved = Self.resolveDetailMinimumThickness(for: tabType, contentMode: contentMode) guard let detailSplitItem, detailSplitItem.minimumThickness != resolved else { return } detailSplitItem.minimumThickness = resolved recomputeWindowMinSize() } - /// Re-seeded on every switch, because the item is shared and the value it holds describes - /// whichever connection was last on screen. - private func applyDetailMinimumThicknessForSelection() { + /// Re-seeded on every switch and every mode toggle, because the item is shared and the value it + /// holds describes whichever connection, and whichever of its two trees, was last on screen. + func applyDetailMinimumThicknessForSelection() { guard let selected = workspaces.selected else { return } updateDetailMinimumThickness( for: selected.sessionState?.tabManager.selectedTab?.tabType, @@ -1358,7 +1385,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan sidebarSplitItem.minimumThickness = resolved } - private func recomputeWindowMinSize() { + func recomputeWindowMinSize() { applySidebarMinimumThickness() guard let window = view.window else { return } let sidebarVisible = !(sidebarSplitItem?.isCollapsed ?? true) @@ -1417,14 +1444,6 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan } return super.validateUserInterfaceItem(item) } - - /// Opening a trailing surface needs a session to put in it. Closing one the user already has - /// open does not, and the window no longer takes it down on their behalf, so a connection that - /// drops with the inspector open would otherwise leave an empty column with no command to - /// close it. - internal var canToggleTrailingPane: Bool { - currentPane == .content || isTrailingPaneOpen - } } // MARK: - Inspector Environment diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift index 8532031d3..59b8e8793 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift @@ -62,9 +62,6 @@ extension MainWindowToolbar { coordinator?.commandActions?.showServerDashboard() } - @objc func performToggleAssistant(_ sender: Any?) { - coordinator?.trailingPaneProxy?.toggleAssistant() - } @objc func performToggleHistory(_ sender: Any?) { coordinator?.commandActions?.toggleHistoryPanel() @@ -74,9 +71,9 @@ extension MainWindowToolbar { coordinator?.commandActions?.exportTables() } - @objc func performImportFormat(_ sender: Any?) { - guard let menuItem = sender as? NSMenuItem, - let formatId = menuItem.representedObject as? String else { return } - coordinator?.commandActions?.importTables(formatId: formatId) + /// The Inspector item on macOS 13, forwarded because the action belongs to the split controller + /// and a toolbar item's explicit target has to respond to its own selector to validate. + @objc func forwardToggleInspector(_ sender: Any?) { + host?.toggleInspector(sender) } } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+ContentMode.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+ContentMode.swift deleted file mode 100644 index 51eb0dcc7..000000000 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+ContentMode.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// MainWindowToolbar+ContentMode.swift -// TablePro -// - -import AppKit - -/// The Browse / Agent control, and the overflow menu it owns. -/// -/// Two things this gets right that the sidebar control next to it had to be fixed for. Each segment -/// names itself through its image's `accessibilityDescription`, which is measured to be the only -/// channel an expanded group publishes a segment name on. And the overflow menu form is built here -/// rather than left to AppKit, with each item carrying its segment in `tag`, so choosing a mode from -/// the overflow acts. AppKit's own generated form forwards the group as the sender on macOS 27, but -/// that is undocumented; owning the form makes the OS version stop mattering. -internal extension MainWindowToolbar { - static let contentModeItem = NSToolbarItem.Identifier("contentMode") - - static var contentModes: [ConnectionWorkspaceContentMode] { - ConnectionWorkspaceContentMode.allCases - } - - static func makeContentModeGroup(target: AnyObject?, action: Selector) -> NSToolbarItemGroup { - let modes = contentModes - let images = modes.compactMap { - NSImage(systemSymbolName: $0.symbolName, accessibilityDescription: $0.localizedTitle) - } - let group = NSToolbarItemGroup( - itemIdentifier: contentModeItem, - images: images, - selectionMode: .selectOne, - labels: modes.map(\.localizedTitle), - target: target, - action: action - ) - group.label = String(localized: "Mode") - group.paletteLabel = group.label - group.controlRepresentation = .expanded - /// Not navigational: that flag lets AppKit lift an item out of its declared slot and pin it - /// to the leading edge, which is where the sidebar control ended up before it was cleared. - group.isNavigational = false - group.menuFormRepresentation = makeContentModeMenuForm(target: target, action: action) - return group - } - - /// A "Mode" root with one item per mode, each carrying its index in `tag`. - static func makeContentModeMenuForm(target: AnyObject?, action: Selector) -> NSMenuItem { - let root = NSMenuItem(title: String(localized: "Mode"), action: nil, keyEquivalent: "") - let submenu = NSMenu(title: root.title) - for (index, mode) in contentModes.enumerated() { - let item = NSMenuItem(title: mode.localizedTitle, action: action, keyEquivalent: "") - item.target = target - item.tag = index - submenu.addItem(item) - } - root.submenu = submenu - return root - } - - func makeContentModeToolbarItem(claimsSlot: Bool) -> NSToolbarItem { - let group = Self.makeContentModeGroup(target: self, action: #selector(contentModeChanged(_:))) - bindMenuForm(action: #selector(contentModeChanged(_:)), to: Self.contentModeItem) - guard claimsSlot else { return group } - contentModeGroup = group - refreshContentMode() - return group - } - - /// The Inspector item on macOS 13, forwarded because the action belongs to the split controller - /// and a toolbar item's explicit target has to respond to its own selector to validate. - @objc func forwardToggleInspector(_ sender: Any?) { - modeHost?.toggleInspector(sender) - } - - /// The window's own controller, not the connection's coordinator. - /// - /// A workspace that is still connecting, or disconnected, has no `MainContentCoordinator`, so - /// reaching the split controller through one made the mode control inert in the one state agent - /// mode exists to cover. - var modeHost: MainSplitViewController? { - windowController ?? coordinator?.splitViewController - } - - @objc func contentModeChanged(_ sender: Any?) { - guard let index = Self.segmentIndex(from: sender, group: contentModeGroup), - Self.contentModes.indices.contains(index) else { return } - modeHost?.setContentMode(Self.contentModes[index]) - } - - /// Pushed from the split view controller when the mode changes, and the tick in the overflow - /// menu follows the same pass the segments do rather than being a second channel that can drift. - func refreshContentMode() { - guard let group = contentModeGroup else { return } - let mode = modeHost?.contentMode ?? .browse - let index = Self.contentModes.firstIndex(of: mode) ?? 0 - group.selectedIndex = index - for (itemIndex, item) in (group.menuFormRepresentation?.submenu?.items ?? []).enumerated() { - item.state = itemIndex == index ? .on : .off - } - managedToolbar.validateVisibleItems() - } -} diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift new file mode 100644 index 000000000..0f6f9c545 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift @@ -0,0 +1,75 @@ +// +// MainWindowToolbar+Context.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +internal extension MainWindowToolbar { + /// The window's own controller, not the connection's coordinator. + /// + /// A workspace that is still connecting, or disconnected, has no `MainContentCoordinator`, so + /// reaching the split controller through one made the window's own commands inert in exactly + /// the state a user reaches for them. + var host: MainSplitViewController? { + windowController ?? coordinator?.splitViewController + } + + /// The engine the window is showing, read from the workspace's connection record and never from + /// the coordinator. + /// + /// The coordinator goes with the session, so a capability read through it would take the + /// container capsule out of the titlebar and put it back on every dropped connection, which is + /// the titlebar moving on something transient. + private var databaseType: DatabaseType? { + host?.workspaces.selected?.connection?.type ?? coordinator?.connection.type + } + + /// The eight slow-moving facts, read on their own and never through a whole context. + /// + /// This is what every tab-manager publish pays, and typing publishes one per keystroke, so it + /// reads only what the key holds: the selected tab, the window's mode, four locked reads of the + /// plugin metadata registry and a switch over the engine for the dashboard. Everything + /// transient, and every question that walks the coordinator, is left to `currentContext()`. + func currentVisibilityKey() -> ToolbarContext.VisibilityKey { + let tab = coordinator?.tabManager.selectedTab + let databaseType = self.databaseType + return ToolbarContext.VisibilityKey( + tabKind: tab?.tabType, + resultsMode: tab?.display.resultsViewMode, + contentMode: host?.contentMode ?? .browse, + isFileBased: databaseType.map { PluginManager.shared.connectionMode(for: $0) == .fileBased } ?? false, + supportsContainerSwitching: databaseType.map { PluginManager.shared.supportsContainerSwitching(for: $0) } + ?? false, + supportsImport: databaseType.map { PluginManager.shared.supportsImport(for: $0) } ?? false, + supportsServerDashboard: databaseType.map { ServerDashboardQueryProviderFactory.supportsDashboard(for: $0) } + ?? false, + isAIEnabled: AppSettingsManager.shared.ai.enabled + ) + } + + /// Everything the two resolvers are allowed to know, read once per question. + /// + /// `isConnected` is the session's own liveness, which counts a reconnect in progress as up. + /// The health monitor writes `.connecting` on every attempt of a backoff while the window keeps + /// showing the session's tabs and rows, and dimming the row for that would be noise. + func currentContext() -> ToolbarContext { + let host = self.host + let state = coordinator?.toolbarState + return ToolbarContext( + key: currentVisibilityKey(), + pane: host?.currentPane ?? .empty, + isConnected: state.map { Self.hasLiveSession($0.connectionState) } ?? false, + hasSelectedWorkspace: host?.hasSelectedWorkspace ?? false, + canToggleTrailingPane: host?.canToggleTrailingPane ?? false, + pendingChange: state?.pendingChange, + hasDataPendingChanges: state?.hasDataPendingChanges ?? false, + blocksAllWrites: state?.safeModeLevel.blocksAllWrites ?? false, + canAddRow: coordinator?.canAddRow ?? false, + canRestorePreviousValues: coordinator?.canRewindSelectedTab ?? false, + canNavigateBack: coordinator?.canNavigateBack ?? false, + canNavigateForward: coordinator?.canNavigateForward ?? false + ) + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift index d6b92da58..da546eb9a 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift @@ -7,6 +7,13 @@ import AppKit import os extension MainWindowToolbar { + /// Builds a new item on every call and keeps none of them. + /// + /// Customize Toolbar asks again for every allowed identifier, and a delegate that handed back + /// a cached instance handed back the one the context had hidden: measured on macOS 27, a + /// dragged-in item that was the same instance arrived with `isHidden` still true, took its slot + /// and drew nothing. The palette's stale visibility state rides the item instance as well, so + /// an instance shared across vends would carry it into every later arrangement. internal func toolbar( _ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, @@ -15,15 +22,19 @@ extension MainWindowToolbar { Self.lifecycleLogger.info( "[open] toolbar delegate buildItem id=\(itemIdentifier.rawValue, privacy: .public) hasCoordinator=\(self.coordinator != nil)" ) - guard let item = buildItem(itemIdentifier, willBeInsertedIntoToolbar: flag) else { return nil } + guard let item = buildItem(itemIdentifier) else { return nil } applyVisibilityPriority(to: item) return item } - private func buildItem( - _ itemIdentifier: NSToolbarItem.Identifier, - willBeInsertedIntoToolbar flag: Bool - ) -> NSToolbarItem? { + /// An item the palette is about to drop in has to take the context the window is in, and the + /// hideable ones are the ones that care. Measured, opening and closing the palette without a + /// change posts nothing, so this costs nothing when the user only looks. + internal func toolbarWillAddItem(_ notification: Notification) { + scheduleVisibilityReapply() + } + + private func buildItem(_ itemIdentifier: NSToolbarItem.Identifier) -> NSToolbarItem? { switch itemIdentifier { case Self.inspector: /// AppKit builds `.toggleInspector` itself and never asks the delegate for it, so this @@ -43,10 +54,18 @@ extension MainWindowToolbar { shortcut: .toggleInspector, description: String(localized: "Toggle Inspector") ) - case Self.sidebarToggle: - return makeSidebarToggleItem(claimsSlot: Self.claimsItemSlot(willBeInsertedIntoToolbar: flag)) - case Self.contentModeItem: - return makeContentModeToolbarItem(claimsSlot: Self.claimsItemSlot(willBeInsertedIntoToolbar: flag)) + case Self.connection: + return makeConnectionItem() + case Self.database: + return makeDatabaseItem() + case Self.refresh: + return makeRefreshItem() + case Self.saveChanges: + return makeSaveChangesItem() + case Self.actions: + return makeActionsItem() + case Self.safeMode: + return makeSafeModeItem() case Self.backForwardGroup: /// `isNavigational` is what puts back and forward on the leading edge of the content /// title area, where Finder and Safari keep them, instead of in the slot the identifier @@ -55,44 +74,14 @@ extension MainWindowToolbar { /// Both subitems are installed unconditionally and stay installed. Availability is /// `isEnabled`, written by `validateToolbarItem(_:)`, never presence: measured on three /// running Apple apps, Xcode, Finder in column view and System Settings all keep the - /// 75pt capsule and dim the direction that has nowhere to go. Emptying the group - /// instead put the pair behind state that is `@ObservationIgnored`, so once hidden it - /// did not come back until the user switched tabs. + /// 75pt capsule and dim the direction that has nowhere to go. let group = makeNativeGroup( id: itemIdentifier, label: String(localized: "Navigation"), - subitems: [subitemNavigateBack(), subitemNavigateForward()] + subitems: [makeNavigateBackItem(), makeNavigateForwardItem()] ) group.isNavigational = true return group - case Self.connectionGroup: - /// Native, like every other group here. As a view-backed group it drew a hosted SwiftUI - /// row and its subitems were inert: the header is explicit that a property set on the - /// parent, "such as label or view, apply to the entire item", so neither subitem - /// reached the overflow menu, the customization palette or `validate()`. - /// - /// Not navigational, unlike back and forward. `isNavigational` asks AppKit to lift an - /// item to the leading edge of the content area, which is the opposite of what - /// `centeredItemIdentifiers` asks for, and this group is the centred one. - return makeNativeGroup( - id: itemIdentifier, - label: String(localized: "Connection"), - subitems: [subitemConnection(), subitemDatabase()] - ) - case TransportRateToolbarItem.identifier: - /// Beside the centred pair, never inside it. A group is laid out around its own - /// midpoint, so a readout inside this one pushed the two capsules off centre by half - /// the readout's width; measured as its own adjacent item, the group sits where it - /// sits with no readout at all and the figure lands 6.0pt past its trailing edge. - return transportRateGroup - case Self.safeMode: - return subitemSafeMode() - case Self.editorGroup: - return makeNativeGroup( - id: itemIdentifier, - label: String(localized: "Editor"), - subitems: [subitemNewTab(), subitemQuickSwitcher()] - ) case Self.previewSQL: return menuOnlyItem( id: itemIdentifier, @@ -124,15 +113,6 @@ extension MainWindowToolbar { action: #selector(performShowDashboard(_:)), description: String(localized: "Server Dashboard") ) - case Self.assistant: - return menuOnlyItem( - id: itemIdentifier, - label: String(localized: "Assistant"), - symbol: "sparkles", - action: #selector(performToggleAssistant(_:)), - shortcut: .toggleAssistant, - description: String(localized: "Toggle Assistant") - ) case Self.history: return menuOnlyItem( id: itemIdentifier, @@ -142,21 +122,18 @@ extension MainWindowToolbar { shortcut: .toggleHistory, description: String(localized: "Toggle Query History") ) - case Self.refreshSaveGroup: - return makeNativeGroup( - id: itemIdentifier, - label: String(localized: "Table Actions"), - subitems: [ - subitemRefresh(), subitemSaveChanges(), subitemAddRow(), - subitemRestorePreviousValues(), - ] - ) - case Self.exportImportGroup: - return makeNativeGroup( - id: itemIdentifier, - label: String(localized: "Export & Import"), - subitems: [subitemExport(), subitemImport()] - ) + case Self.exportTables: + return makeExportItem() + case Self.importTables: + return makeImportItem() + case Self.addRow: + return makeAddRowItem() + case Self.restorePreviousValues: + return makeRestorePreviousValuesItem() + case Self.newTab: + return makeNewTabItem() + case Self.quickSwitcher: + return makeQuickSwitcherItem() default: return nil } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift index b885e5a33..bce05d46e 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift @@ -6,7 +6,7 @@ import AppKit extension MainWindowToolbar { - // MARK: - Subitem Builders + // MARK: - Item Builders /// The name of the driver's own query language, so the Preview tooltip says "Preview MQL" on /// MongoDB rather than a generic word the user has to translate. @@ -45,14 +45,6 @@ extension MainWindowToolbar { coordinator?.connection.name ?? "" } - /// Whether the centred group carries a throughput readout at all, and whether there is a second - /// reading worth taking. Read from the connection's configuration rather than from the registry, - /// so it holds for the whole session: the group's shape is settled when the connection is - /// adopted and never changes under a running tunnel. - var carriesMeasuredTransport: Bool { - coordinator?.connection.activeTunnelKind?.carriesMeasuredBytes == true - } - /// The container this control switches, and only that. It briefly read "app › public" on a /// schema-grouped engine while the click still opened the database chooser, which makes the /// word the user aimed at the one thing the control cannot change. The schema has its own @@ -61,7 +53,13 @@ extension MainWindowToolbar { coordinator?.toolbarState.currentDatabase ?? "" } - func subitemConnection() -> NSToolbarItem { + /// The verb the selected tab commits with, for an item vended now. `refreshCommitVerb(for:)` + /// keeps a live one in step, from the same tab kind. + var commitVerb: String { + ToolbarContextResolver.commitVerb(for: coordinator?.tabManager.selectedTab?.tabType) + } + + func makeConnectionItem() -> NSToolbarItem { menuOnlyItem( id: Self.connection, label: String(localized: "Connection"), @@ -75,42 +73,72 @@ extension MainWindowToolbar { } /// A one-of-six chooser that also has to report which one is current, which is - /// `NSMenuToolbarItem` plus a glyph that follows the level. `StatefulToolbarItem.validate()` - /// re-reads `symbolProvider` on every validation pass, and `observeItemState` puts + /// `NSMenuToolbarItem` plus a glyph that follows the level. `SafeModeToolbarItem.validate()` + /// re-reads `statusProvider` on every validation pass, and `observeItemState` puts /// `safeModeLevel` on the list of things that trigger one. - func subitemSafeMode() -> NSToolbarItem { + func makeSafeModeItem() -> NSToolbarItem { let label = String(localized: "Safe Mode") let item = SafeModeToolbarItem(itemIdentifier: Self.safeMode) item.label = label item.paletteLabel = label item.isBordered = true - item.levelProvider = { [weak self] in self?.coordinator?.toolbarState.safeModeLevel ?? .silent } - item.isEnabledProvider = { [weak self] in - guard let self, let context = validationContext() else { return false } - return Self.isEnabled(itemIdentifier: Self.safeMode, context: context) + item.statusProvider = { [weak self] in + self?.coordinator?.safeModeStatus ?? SafeModeStatus(level: .silent, floor: nil) } + item.isEnabledProvider = enablement(of: Self.safeMode) /// The same class the Database menu's submenu uses, so the two lists cannot describe /// different levels, and the checkmark is resolved when the menu opens rather than when /// the item was built. `NSMenu.delegate` is weak, so the toolbar holds this one. - item.menu = safeModeMenu() + item.menu = menu(delegate: safeModeMenuDelegate) /// The overflow entry names the list, not the control, for the same reason the Database /// menu's container does: one of the levels inside it is itself called Safe Mode. let menuItem = NSMenuItem(title: String(localized: "Safe Mode Level"), action: nil, keyEquivalent: "") - menuItem.submenu = safeModeMenu() + menuItem.submenu = menu(delegate: safeModeMenuDelegate) item.menuFormRepresentation = menuItem - /// No `toolTip` here. `levelProvider` already wrote one naming the current level, and - /// overwriting it with the bare label was permanent: `applyLevel` returns early once the - /// level it applied has not changed, so nothing would ever put the level back. + /// No `toolTip` here. `statusProvider` already wrote one naming the current level, and + /// overwriting it with the bare label was permanent: `applyStatus` returns early once the + /// status it applied has not changed, so nothing would ever put the level back. return item } - private func safeModeMenu() -> NSMenu { + /// The long tail of what a context can do, in one control whose menu changes with the tab. + /// + /// `ellipsis.circle` is the glyph Finder gives its own Action pull-down. The menu is built by + /// `ConnectionActionsMenuDelegate` when it opens. The overflow entry is AppKit's own and is + /// left to it: measured on macOS 27, an `NSMenuToolbarItem` answers `menuFormRepresentation` + /// with a fresh item titled with its label over this same menu, whatever was assigned, so a + /// narrow window's overflow offers exactly what the control would. + func makeActionsItem() -> NSToolbarItem { + let label = String(localized: "Actions") + let item = StatefulMenuToolbarItem(itemIdentifier: Self.actions) + item.label = label + item.paletteLabel = label + item.isBordered = true + item.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: label) + item.toolTip = String(localized: "Commands for the current tab and connection") + item.isEnabledProvider = enablement(of: Self.actions) + item.menu = menu(delegate: actionsMenuDelegate) + return item + } + + /// A menu filled by its delegate when it opens. `NSMenu.delegate` is weak, so the delegate is + /// one the toolbar keeps. + private func menu(delegate: any NSMenuDelegate) -> NSMenu { let menu = NSMenu() - menu.delegate = safeModeMenuDelegate + menu.delegate = delegate return menu } + /// The enablement a menu-owning item asks for on each validation pass, answered by the same + /// resolver and from the same pass context as every other item. + private func enablement(of identifier: NSToolbarItem.Identifier) -> @MainActor () -> Bool { + { [weak self] in + guard let self else { return false } + return ToolbarContextResolver.isEnabled(identifier, context: self.validationContext()) + } + } + /// What this driver calls the thing a connection browses, so the item reads "Open Keyspace" on /// Cassandra rather than a word that does not exist there. var containerEntityName: String { @@ -119,7 +147,7 @@ extension MainWindowToolbar { } ?? String(localized: "Database") } - func subitemDatabase() -> NSToolbarItem { + func makeDatabaseItem() -> NSToolbarItem { let containerName = containerEntityName return menuOnlyItem( id: Self.database, @@ -132,7 +160,7 @@ extension MainWindowToolbar { ) } - func subitemNewTab() -> NSToolbarItem { + func makeNewTabItem() -> NSToolbarItem { menuOnlyItem( id: Self.newTab, label: String(localized: "New Tab"), @@ -143,7 +171,7 @@ extension MainWindowToolbar { ) } - func subitemQuickSwitcher() -> NSToolbarItem { + func makeQuickSwitcherItem() -> NSToolbarItem { menuOnlyItem( id: Self.quickSwitcher, label: String(localized: "Open Quickly"), @@ -153,7 +181,7 @@ extension MainWindowToolbar { ) } - func subitemRefresh() -> NSToolbarItem { + func makeRefreshItem() -> NSToolbarItem { menuOnlyItem( id: Self.refresh, label: String(localized: "Refresh"), @@ -166,7 +194,7 @@ extension MainWindowToolbar { /// No text label on either button: the HIG asks for the standard chevrons and says not to /// label a Back control. `chevron.backward` and `chevron.forward` mirror in a right-to-left /// layout, which `chevron.left` and `chevron.right` do not. - func subitemNavigateBack() -> NSToolbarItem { + func makeNavigateBackItem() -> NSToolbarItem { menuOnlyItem( id: Self.navigateBack, label: String(localized: "Back"), @@ -176,7 +204,7 @@ extension MainWindowToolbar { ) } - func subitemNavigateForward() -> NSToolbarItem { + func makeNavigateForwardItem() -> NSToolbarItem { menuOnlyItem( id: Self.navigateForward, label: String(localized: "Forward"), @@ -186,10 +214,13 @@ extension MainWindowToolbar { ) } - func subitemSaveChanges() -> NSToolbarItem { + /// Labelled with the verb the tab commits with, and re-labelled by `refreshCommitVerb(for:)` when + /// the tab kind moves, so the palette, the overflow entry and the tooltip never offer to save a + /// table definition that is about to be created. + func makeSaveChangesItem() -> NSToolbarItem { menuOnlyItem( id: Self.saveChanges, - label: String(localized: "Save Changes"), + label: commitVerb, symbol: "checkmark.circle.fill", action: #selector(performSaveChanges(_:)), shortcut: .saveChanges @@ -197,10 +228,9 @@ extension MainWindowToolbar { } /// A row insert is a change to the data, so it belongs with the other data commands rather than - /// in the status bar, which reports what is on screen. It ships as a subitem of an existing group - /// so a toolbar the user already customized picks it up: `autosavesConfiguration` restores the - /// saved identifier list, and a brand new top-level identifier would never appear for them. - func subitemAddRow() -> NSToolbarItem { + /// in the status bar, which reports what is on screen. Offered by Customize Toolbar and by the + /// Actions pull-down on a table tab showing data. + func makeAddRowItem() -> NSToolbarItem { menuOnlyItem( id: Self.addRow, label: String(localized: "Add Row"), @@ -210,13 +240,10 @@ extension MainWindowToolbar { ) } - /// Rides in the Table Actions group for the same reason Add Row does: a brand new top-level - /// identifier never appears for anyone whose toolbar configuration is already saved. - /// /// It stays enabled without a license. The point of it being here is that someone who has just /// saved the wrong thing finds it, and finding it is what makes the licence worth buying; a /// dimmed item they never notice sells nothing and helps nobody. - func subitemRestorePreviousValues() -> NSToolbarItem { + func makeRestorePreviousValuesItem() -> NSToolbarItem { menuOnlyItem( id: Self.restorePreviousValues, label: String(localized: "Restore Previous Values"), @@ -226,7 +253,7 @@ extension MainWindowToolbar { ) } - func subitemExport() -> NSToolbarItem { + func makeExportItem() -> NSToolbarItem { menuOnlyItem( id: Self.exportTables, label: String(localized: "Export"), @@ -240,45 +267,22 @@ extension MainWindowToolbar { /// `NSMenuToolbarItem` is the toolbar control that opens a menu. A plain `NSToolbarItem` with a /// submenu on its `menuFormRepresentation` only shows that menu in the overflow list. /// - /// It carries no action on purpose. Given one, AppKit splits the control into a body that sends - /// the action and a separate chevron that opens the menu, so clicking the item itself does - /// nothing whenever the driver offers more than one format. With no action the whole control - /// opens the menu, and a single-format driver simply gets a one-item menu. - func subitemImport() -> NSToolbarItem { + /// The formats come from `ImportFormatMenuDelegate` when the menu opens, the same instance the + /// Actions pull-down's Import Data submenu uses, so the two lists cannot differ. The overflow + /// entry is AppKit's, over this same menu, for the reason `makeActionsItem` gives. + func makeImportItem() -> NSToolbarItem { let label = String(localized: "Import") - let item = NSMenuToolbarItem(itemIdentifier: Self.importTables) + let item = StatefulMenuToolbarItem(itemIdentifier: Self.importTables) item.label = label item.paletteLabel = label item.isBordered = true item.image = NSImage(systemSymbolName: "square.and.arrow.down", accessibilityDescription: label) - item.menu = buildImportSubmenu() - - let menuItem = NSMenuItem(title: label, action: nil, keyEquivalent: "") - menuItem.image = item.image - menuItem.submenu = buildImportSubmenu() - item.menuFormRepresentation = menuItem - bindMenuForm(action: #selector(performImportFormat(_:)), to: Self.importTables) - + item.isEnabledProvider = enablement(of: Self.importTables) + item.menu = menu(delegate: importFormatMenuDelegate) bindShortcut(.importData, description: String(localized: "Import Data"), to: item) return item } - func buildImportSubmenu() -> NSMenu { - let menu = NSMenu() - guard let databaseType = coordinator?.connection.type else { return menu } - for format in PluginManager.shared.importFormatOptions(for: databaseType) { - let menuItem = NSMenuItem( - title: format.submenuLabel, - action: #selector(performImportFormat(_:)), - keyEquivalent: "" - ) - menuItem.target = self - menuItem.representedObject = format.id - menu.addItem(menuItem) - } - return menu - } - // MARK: - Helpers /// The label is what the customization palette and the overflow menu show, so it stays short. @@ -353,15 +357,7 @@ extension MainWindowToolbar { /// and container titles took the whole content width and every command went to the overflow /// menu. A truncated container name is a worse loss than Refresh and Save. func applyVisibilityPriority(to item: NSToolbarItem) { - guard item.itemIdentifier != Self.connectionGroup else { return } + guard item.itemIdentifier != Self.connection, item.itemIdentifier != Self.database else { return } item.visibilityPriority = .high } - - /// One slot per identifier, and the slot belongs to the item that is actually in the toolbar. - /// AppKit asks the delegate again with `willBeInsertedIntoToolbar: false` to build the palette - /// copies shown by Customize Toolbar, and a palette copy that took the slot left every later - /// `syncSidebarSelection()` writing into a discarded group. - static func claimsItemSlot(willBeInsertedIntoToolbar: Bool) -> Bool { - willBeInsertedIntoToolbar - } } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift index 582186b31..383d1cb55 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift @@ -4,36 +4,13 @@ // import AppKit -import TableProPluginKit extension MainWindowToolbar: NSToolbarItemValidation { - struct ValidationContext { - /// True whenever the session is alive, which includes a query in flight. A running - /// query is not a reason to disable Refresh or New Tab, and the menu bar already - /// derives its own `isConnected` from the window phase rather than from execution. - let connected: Bool - let isTableTab: Bool - let canAddRow: Bool - let canRestorePreviousValues: Bool - let hasPendingChanges: Bool - let hasDataPendingChanges: Bool - let blocksAllWrites: Bool - let fileBased: Bool - let supportsContainerSwitching: Bool - let supportsImport: Bool - let supportsServerDashboard: Bool - let canNavigateBack: Bool - let canNavigateForward: Bool - /// A connection is on screen, whether or not it has finished connecting. The mode control is - /// the one item that answers to this rather than to `connected`. - var hasSelectedWorkspace: Bool = false - } - /// Listed exhaustively so a new state has to choose a side instead of inheriting "alive". /// /// `.connecting` counts because the health monitor writes it on every reconnect attempt, and /// the window keeps showing the session's tabs and rows throughout. Graying the whole toolbar - /// out for the length of a backoff would take Sidebar Toggle with it. + /// out for the length of a backoff would dim every control for a blip that repairs itself. static func hasLiveSession(_ state: ToolbarConnectionState) -> Bool { switch state { case .connected, .connecting: @@ -43,113 +20,27 @@ extension MainWindowToolbar: NSToolbarItemValidation { } } - static func isEnabled(itemIdentifier: NSToolbarItem.Identifier, context: ValidationContext) -> Bool { - switch itemIdentifier { - case Self.connection, Self.history: - return true - case Self.assistant: - /// The View menu command already gates on the window showing content. Without the same - /// gate here the button stays live over a disconnected session and uncollapses the - /// assistant beside a connection that cannot answer. - return context.connected && AppSettingsManager.shared.ai.enabled - case Self.database: - return context.connected && !context.fileBased && context.supportsContainerSwitching - case Self.safeMode: - /// Safe mode is what stands between a stray keystroke and a live table, so the control - /// that sets it answers for as long as the session does. A window with no session has - /// nothing to protect and nothing to write it to. - return context.connected - case Self.inspector: - return context.connected - case Self.refresh, Self.quickSwitcher, Self.newTab, Self.exportTables, Self.sidebarToggle: - return context.connected - /// Reachable while a connection is still dialling, unlike the rest of these. Agent mode - /// draws the prompt the user typed, so a control gated on `connected` would be dead in the - /// one state that surface exists for. - case Self.contentModeItem: - return context.hasSelectedWorkspace && AppSettingsManager.shared.ai.enabled - case Self.addRow: - return context.connected && context.canAddRow - case Self.restorePreviousValues: - return context.connected && context.canRestorePreviousValues - case Self.navigateBack: - return context.connected && context.canNavigateBack - case Self.navigateForward: - return context.connected && context.canNavigateForward - case Self.saveChanges: - return context.hasPendingChanges && context.connected && !context.blocksAllWrites - case Self.previewSQL: - return context.hasDataPendingChanges && context.connected - case Self.results: - return context.connected && !context.isTableTab - case Self.dashboard: - return context.connected && context.supportsServerDashboard - case Self.importTables: - return context.connected && !context.blocksAllWrites && context.supportsImport - default: - return true - } + /// Every item answers from `ToolbarContextResolver`, which decides with no window and no + /// session, so the answer a test pins is the answer the titlebar draws. + func validateToolbarItem(_ item: NSToolbarItem) -> Bool { + ToolbarContextResolver.isEnabled(item.itemIdentifier, context: validationContext()) } +} - func validationContext() -> ValidationContext? { - guard let state = coordinator?.toolbarState else { - /// A workspace that is still connecting has no coordinator, and returning nil here - /// disabled every item including the mode control, whose whole point is to be reachable - /// in exactly that state. - guard let host = windowController, host.hasSelectedWorkspace else { return nil } - return ValidationContext( - connected: false, - isTableTab: false, - canAddRow: false, - canRestorePreviousValues: false, - hasPendingChanges: false, - hasDataPendingChanges: false, - blocksAllWrites: false, - fileBased: false, - supportsContainerSwitching: false, - supportsImport: false, - supportsServerDashboard: false, - canNavigateBack: false, - canNavigateForward: false, - hasSelectedWorkspace: true - ) +/// The connection window's toolbar, which tells its delegate where a validation pass starts and +/// ends so the pass can be answered from one context. +/// +/// Measured on macOS 27: AppKit's own passes, the ones a window update runs, go through +/// `validateVisibleItems()`, so this override brackets them as well as the ones the app asks for. +/// There is no delegate callback for either edge of a pass, which is why this is a subclass. +@MainActor +internal final class ContextValidatedToolbar: NSToolbar { + override internal func validateVisibleItems() { + guard let owner = delegate as? MainWindowToolbar else { + super.validateVisibleItems() + return } - return ValidationContext( - connected: Self.hasLiveSession(state.connectionState), - isTableTab: state.isTableTab, - canAddRow: coordinator?.canAddRow ?? false, - canRestorePreviousValues: coordinator?.canRewindSelectedTab ?? false, - hasPendingChanges: state.hasPendingChanges, - hasDataPendingChanges: state.hasDataPendingChanges, - blocksAllWrites: state.safeModeLevel.blocksAllWrites, - fileBased: PluginManager.shared.connectionMode(for: state.databaseType) == .fileBased, - supportsContainerSwitching: PluginManager.shared.supportsContainerSwitching(for: state.databaseType), - supportsImport: PluginManager.shared.supportsImport(for: state.databaseType), - supportsServerDashboard: coordinator?.commandActions?.supportsServerDashboard ?? false, - canNavigateBack: coordinator?.canNavigateBack ?? false, - canNavigateForward: coordinator?.canNavigateForward ?? false, - hasSelectedWorkspace: modeHost?.hasSelectedWorkspace ?? false - ) - } - - /// Switch Connection is the window's, so it answers before a subject is required. Every other - /// item here needs the coordinator that presents it, and enabling one of those without a - /// subject would leave a live-looking button that does nothing, so no subject still disables - /// the rest of the toolbar. - /// - /// The sidebar item is not an exception, however window-owned the sidebar itself now is: it is - /// the Tables/Favorites segmented control, `sidebarSegmentChanged` reaches - /// `coordinator?.splitViewController`, and the tab it selects is per-connection state that a - /// window with no session has nowhere to write. Show/Hide Sidebar is the command that answers - /// in every phase, and it lives in the View menu and on the divider rather than here. - static func isWindowScoped(_ itemIdentifier: NSToolbarItem.Identifier) -> Bool { - itemIdentifier == Self.connection - } - - func validateToolbarItem(_ item: NSToolbarItem) -> Bool { - if Self.isWindowScoped(item.itemIdentifier) { return true } - guard let context = validationContext() else { return false } - return Self.isEnabled(itemIdentifier: item.itemIdentifier, context: context) + owner.withinValidationPass { super.validateVisibleItems() } } } @@ -159,11 +50,12 @@ extension MainWindowToolbar: NSToolbarItemValidation { /// New Tab, Open Quickly, Export, Database, Results and Dashboard live in the overflow menu of a /// narrow window while the same buttons were disabled on a wide one, and clicking one did nothing. /// The mapping now comes from the factory that built the item, so it cannot fall behind again. +/// +/// The Actions pull-down never reaches here. Its entries carry no target, so AppKit resolves them +/// through the responder chain to the window's controller, and that is the one validator they get. extension MainWindowToolbar: NSMenuItemValidation { func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { guard let itemIdentifier = itemIdentifier(forMenuFormAction: menuItem.action) else { return true } - if Self.isWindowScoped(itemIdentifier) { return true } - guard let context = validationContext() else { return false } - return Self.isEnabled(itemIdentifier: itemIdentifier, context: context) + return ToolbarContextResolver.isEnabled(itemIdentifier, context: validationContext()) } } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 5b66986f2..0f5dee708 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -10,13 +10,23 @@ import os @MainActor internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { private var itemStateObservation: AnyCancellable? + private var tabStateObservation: AnyCancellable? nonisolated internal static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") - /// The autosave name. Bumping it discards every saved arrangement, so it moves only when the - /// default set changes enough that replaying a stored one would be worse than resetting it. The - /// v3 move drops the hosted status item and four commands from the default, and a v2 list still - /// names identifiers the delegate no longer vends. v4 adds the throughput readout: a stored v3 - /// arrangement does not name it, so a reader who had customized the toolbar would never see it. + /// The autosave name, and deliberately not bumped when the default set changes. + /// + /// Measured on macOS 27 across separate process launches under one identifier: AppKit diffs the + /// stored `TB Default Item Identifiers` against the current default list and splices a new + /// identifier in at its position in that list rather than at the end, so a stored + /// `[alpha, charlie]` against a new default `[alpha, bravo, charlie, delta]` came back + /// `[alpha, bravo, charlie]`, and an identifier the delegate stops vending is pruned from the + /// record on the next launch. A user who customized keeps their arrangement and still receives + /// new items where they belong; a bump would throw that away along with their display mode. + /// The record is six keys and `isHidden` is not among them, since it is runtime state that is + /// never persisted, so a context-sensitive item set is no reason to bump either. + /// + /// Bump only for a change that diff cannot express, and say which. Neither fact was measured on + /// macOS 13 or 14. internal static let toolbarIdentifier = NSToolbar.Identifier("com.TablePro.main.toolbar.v4") /// Which connection the toolbar is about. Every item reads this rather than capturing a @@ -53,37 +63,42 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// `shortcutBindings` is: a second hand-written table drifts. private var menuFormIdentifiers: [Selector: NSToolbarItem.Identifier] = [:] - private(set) var sidebarGroup: NSToolbarItemGroup? - internal var contentModeGroup: NSToolbarItemGroup? - - /// The throughput readout. One item per toolbar rather than one per vend: the ticker writes - /// into it directly, so it has to be the instance the toolbar is actually showing. - internal let transportRateItem = TransportRateToolbarItem() - - /// A group holding nothing but the readout, and the reason it exists is that emptying it is how - /// the readout leaves the toolbar. Measured: an item whose view is hidden keeps its 75pt, and a - /// view constrained to zero width still leaves a 24pt gap, so neither hides it cleanly. - /// `NSToolbarItem.isHidden` does, but it is macOS 15 and the app targets 14. An empty group - /// reclaims the space exactly, on every version, with no availability gate. - internal private(set) lazy var transportRateGroup: NSToolbarItemGroup = { - let group = NSToolbarItemGroup(itemIdentifier: TransportRateToolbarItem.identifier) - let label = String(localized: "Throughput") - group.label = label - group.paletteLabel = label - group.subitems = [] - return group - }() - private var transportSampler = TransportRateSampler() - private var transportTicker: Task? + /// What the app last took out of the titlebar, written by `apply(_:)` and by nothing else. + /// + /// The switcher reads this to decide whether its anchor is reachable, because the palette + /// poisons AppKit's own answer for good: see `ToolbarVisibility`. + internal private(set) var visibility = ToolbarVisibility() + + /// The slow-moving half of the context the visibility and the commit verb were last applied + /// from. A keystroke builds a fresh key and compares it with this, and writes nothing unless it + /// moved, which is what keeps the titlebar from moving while the user types. + private var appliedVisibilityKey: ToolbarContext.VisibilityKey? - private static let transportSampleInterval = Duration.seconds(1) + /// The context of the validation pass in progress, and nil between passes. See + /// `validationContext()`. + private var validationPassContext: ToolbarContext? + + /// Coalesces the passes Customize Toolbar asks for, so a drop of several items is one pass. + private var isVisibilityReapplyScheduled = false /// `NSMenu.delegate` is weak, and the safe-mode control's menu is built here rather than by the /// menu bar, so this toolbar is what keeps its delegate alive. internal let safeModeMenuDelegate = SafeModeMenuDelegate() + /// The import formats, filled when the menu opens. One instance serves the Actions pull-down's + /// submenu and the Import item a user can add from Customize Toolbar, so the two cannot list + /// different formats. + internal let importFormatMenuDelegate = ImportFormatMenuDelegate() + + /// Kept for the same reason as the two above. The Actions menu is built on every open from the + /// context the toolbar is pointed at, so it holds the toolbar weakly and asks it. + internal private(set) lazy var actionsMenuDelegate = ConnectionActionsMenuDelegate( + importFormats: importFormatMenuDelegate, + context: { [weak self] in self?.currentContext() ?? ToolbarContext() } + ) + override internal convenience init() { - self.init(managedToolbar: NSToolbar(identifier: Self.toolbarIdentifier)) + self.init(managedToolbar: ContextValidatedToolbar(identifier: Self.toolbarIdentifier)) } internal init(managedToolbar: NSToolbar) { @@ -97,7 +112,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { self.managedToolbar.displayMode = .iconOnly self.managedToolbar.allowsUserCustomization = true self.managedToolbar.autosavesConfiguration = true - self.managedToolbar.centeredItemIdentifiers = [Self.connectionGroup] + self.managedToolbar.centeredItemIdentifiers = [Self.connection, Self.database] /// The hop off `AppSettingsManager.keyboard`'s own `didSet` matters: without it the toolbar /// items are mutated re-entrantly, part way through the settings write that triggered them. AppEvents.shared.keyboardSettingsChanged @@ -166,9 +181,8 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { itemStateObservationGeneration += 1 self.coordinator = coordinator observeItemState() - restartTransportTicker() refreshConnectionScopedItems() - syncSidebarSelection() + refreshContext(forcing: true) managedToolbar.validateVisibleItems() } @@ -179,74 +193,47 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// connection was released has no coordinator to reach it through. windowController?.switcherPresenter.dismiss() itemStateObservationGeneration += 1 - transportTicker?.cancel() - transportTicker = nil - transportRateItem.apply(rate: nil) - sidebarGroup = nil + itemStateObservation = nil + tabStateObservation = nil coordinator = nil } - /// Runs only for a connection whose transport the app carries the bytes for, so an ordinary - /// direct connection costs nothing at all. It writes into the readout's own field rather than - /// asking the toolbar to revalidate: the field is a fixed width, so a new figure is a redraw - /// inside one view and never a layout pass. - private func restartTransportTicker() { - transportTicker?.cancel() - transportSampler = TransportRateSampler() - transportRateItem.apply(rate: nil) - guard carriesMeasuredTransport else { - transportTicker = nil - return - } - - transportTicker = Task { @MainActor [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: Self.transportSampleInterval) - guard !Task.isCancelled, let self else { return } - self.sampleTransportRate() - } - } - } - - private func sampleTransportRate() { - guard let connection = coordinator?.connection else { return } - let totals = TransportActivityRegistry.shared.totals(for: connection.id) - transportRateItem.apply(rate: totals.flatMap { transportSampler.sample($0, at: .now) }) - } - - /// Emptying and refilling the readout's own group is the one structural change AppKit - /// re-measures. It happens when a connection is adopted, never under a running tunnel, so - /// nothing moves while a figure is ticking. - private func syncTransportRateVisibility() { - let carries = !transportRateGroup.subitems.isEmpty - guard carries != carriesMeasuredTransport else { return } - transportRateGroup.subitems = carriesMeasuredTransport ? [transportRateItem] : [] - } - /// What a validation pass depends on beyond the responder chain. `validateVisibleItems()` is /// also what re-runs `StatefulToolbarItem.validate()`, so the safe-mode glyph tracks the level /// through the same channel rather than through an observer of its own. + /// + /// The tab manager is watched for the visibility key alone. It publishes on every edit to a + /// tab, typing included, so its observer builds the key from its eight inputs, compares it and + /// returns: no whole context, no validation pass and no write. private func observeItemState() { + itemStateObservation = nil + tabStateObservation = nil + guard let coordinator else { return } let generation = itemStateObservationGeneration - let coordinatorIdentifier = coordinator.map { ObjectIdentifier($0) } - /// Wakes for any change on the toolbar state rather than only the four properties the - /// tracked closure read. `validateVisibleItems()` is idempotent, so the wider wake set - /// costs a revalidation pass and nothing else. - guard let toolbarState = coordinator?.toolbarState else { return } - itemStateObservation = toolbarState.onMainActorChange { [weak self] in - guard let self, - generation == self.itemStateObservationGeneration, - coordinatorIdentifier == self.coordinator.map({ ObjectIdentifier($0) }) - else { return } + let coordinatorIdentifier = ObjectIdentifier(coordinator) + /// Wakes for any change on the toolbar state rather than only the properties the items + /// read. `validateVisibleItems()` is idempotent and builds one context for the whole pass, + /// so the wider wake set costs a revalidation pass and nothing else. + itemStateObservation = coordinator.toolbarState.onMainActorChange { [weak self] in + guard let self, self.isObserving(generation, coordinatorIdentifier) else { return } + self.refreshContext() self.managedToolbar.validateVisibleItems() } + tabStateObservation = coordinator.tabManager.onMainActorChange { [weak self] in + guard let self, self.isObserving(generation, coordinatorIdentifier) else { return } + self.refreshContext() + } } - /// Items carrying text or a menu derived from the connection. Validation cannot repoint them: - /// a menu built at construction keeps whatever it was built with, and a label is not part of - /// what `validate()` reconsiders. They are pushed here instead. + /// A callback queued before a repoint must not act on the connection that replaced its own. + private func isObserving(_ generation: Int, _ coordinatorIdentifier: ObjectIdentifier) -> Bool { + generation == itemStateObservationGeneration + && coordinatorIdentifier == coordinator.map { ObjectIdentifier($0) } + } + + /// Items carrying text derived from the connection. Validation cannot repoint them: a label is + /// not part of what `validate()` reconsiders. They are pushed here instead. private func refreshConnectionScopedItems() { - syncTransportRateVisibility() for item in allItems() { switch item.itemIdentifier { case Self.connection: @@ -264,15 +251,29 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { case Self.previewSQL: updateShortcutDescription(previewDescription, for: Self.previewSQL) applyShortcutBinding(to: item) - case Self.importTables: - (item as? NSMenuToolbarItem)?.menu = buildImportSubmenu() - item.menuFormRepresentation?.submenu = buildImportSubmenu() default: continue } } } + /// The commit control says what its tab commits: Create Table on a definition tab, Apply + /// Changes on Users & Roles, Save Changes everywhere else. The palette, the overflow menu and the + /// tooltip all read the label, so all three move with it. + /// + /// Run only when the visibility key moves, because the verb is a function of the tab kind the + /// key carries. It used to follow the staged change, which a Create Table draft raises and + /// drops as it becomes valid and invalid, so the label flipped while the user typed and, with + /// labels shown, every flip changed the item's width and reflowed the titlebar. + private func refreshCommitVerb(for tabKind: TabType?) { + let verb = ToolbarContextResolver.commitVerb(for: tabKind) + for item in managedToolbar.items where item.itemIdentifier == Self.saveChanges && item.label != verb { + apply(label: verb, to: item) + updateShortcutDescription(verb, for: Self.saveChanges) + applyShortcutBinding(to: item) + } + } + /// The overflow menu keeps the title it was built with, so a label change has to reach it too. private func apply(label: String, to item: NSToolbarItem) { item.label = label @@ -287,45 +288,133 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { } } + // MARK: - Validation + + /// The context a validation question is answered from: the running pass's when there is one, + /// and a fresh one otherwise. + /// + /// A pass asks once per visible item, and the context walks the coordinator for Add Row, Restore + /// Previous Values and both navigation directions, so building it per item paid that walk once + /// for every item on every pass AppKit runs. AppKit also asks outside a pass, measured on + /// macOS 27: an item is validated once as it joins the toolbar, before any pass, and those + /// questions take the fresh read. + internal func validationContext() -> ToolbarContext { + validationPassContext ?? currentContext() + } + + /// Runs one validation pass over a context built once for all of it. A pass that starts inside + /// another reuses the outer one's, because nothing can move between two items of one pass. + internal func withinValidationPass(_ validate: () -> Void) { + guard validationPassContext == nil else { + validate() + return + } + validationPassContext = currentContext() + defer { validationPassContext = nil } + validate() + } + + // MARK: - Visibility + + /// Re-applies the titlebar's shape and the commit verb when the slow-moving half of the context + /// has moved, and otherwise returns after building the key and comparing it. + /// + /// No whole context is built here. Visibility is a function of the key alone, so the coordinator + /// walk the enablement questions need is never paid for a keystroke. + /// + /// `forcing` is for the moments the shape has to be re-established whatever the key says: a + /// repoint to a different connection, and the toolbar first reaching its window. Every launch + /// starts from an all-visible toolbar, measured, because `isHidden` is never persisted. + internal func refreshContext(forcing: Bool = false) { + let key = currentVisibilityKey() + guard forcing || key != appliedVisibilityKey else { return } + appliedVisibilityKey = key + refreshCommitVerb(for: key.tabKind) + apply(ToolbarContextResolver.visibility(for: key)) + } + + /// Writes the resolver's set onto the items the app placed, and never touches one the user + /// added from the palette. + /// + /// `isHidden` and nothing else. `insertItem` and `removeItem` write the saved arrangement to + /// disk on the spot, and every connection window shares this toolbar's identifier, so a context + /// expressed that way would reach every other window and outlive the app. + /// + /// The validation pass is not optional. Measured on macOS 27, the `isHidden` setter runs no + /// validation, and an item shown again keeps whatever `isEnabled` it had when it went away, + /// through the same run-loop turn, the next one and 1.5s later, until something asks. + /// + /// Below macOS 15 there is no `isHidden`, so the full set stands and the contextual items dim. + /// The record is left empty there, because it describes what the app hid and it hid nothing. + internal func apply(_ visibility: ToolbarVisibility) { + guard #available(macOS 15.0, *) else { return } + self.visibility = visibility + for item in managedToolbar.items where ToolbarContextResolver.hideableIdentifiers.contains(item.itemIdentifier) { + item.isHidden = visibility.hides(item.itemIdentifier) + } + managedToolbar.validateVisibleItems() + } + + /// One pass on the next turn rather than now: the item AppKit announces is not in + /// `NSToolbar.items` until the notification returns. A burst of drops is still one pass. + /// + /// Only the item's own arrival needs it. A delegate that vends a fresh item every time hands + /// back one that is visible, so the failure this covers is a hideable item that should be + /// hidden staying visible until the next tab switch. With an instance cached across vends the + /// failure is worse, measured: the palette hands back the same instance still hidden, and it + /// takes a slot and draws nothing. + internal func scheduleVisibilityReapply() { + guard !isVisibilityReapplyScheduled else { return } + isVisibilityReapplyScheduled = true + Task { @MainActor [weak self] in + guard let self else { return } + self.isVisibilityReapplyScheduled = false + self.apply(self.visibility) + } + } + // MARK: - Identifiers - static let connectionGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.connectionGroup") - static let connection = NSToolbarItem.Identifier("com.TablePro.toolbar.connection") - static let database = NSToolbarItem.Identifier("com.TablePro.toolbar.database") - static let refresh = NSToolbarItem.Identifier("com.TablePro.toolbar.refresh") - static let saveChanges = NSToolbarItem.Identifier("com.TablePro.toolbar.saveChanges") - static let addRow = NSToolbarItem.Identifier("com.TablePro.toolbar.addRow") - static let safeMode = NSToolbarItem.Identifier("com.TablePro.toolbar.safeMode") - static let quickSwitcher = NSToolbarItem.Identifier("com.TablePro.toolbar.quickSwitcher") - static let newTab = NSToolbarItem.Identifier("com.TablePro.toolbar.newTab") - static let previewSQL = NSToolbarItem.Identifier("com.TablePro.toolbar.previewSQL") - static let results = NSToolbarItem.Identifier("com.TablePro.toolbar.results") + /// `nonisolated` throughout: these are immutable strings that name a command, and + /// `ToolbarContextResolver` reads them from off the main actor to answer which items a context + /// shows. Isolating them to this class was incidental to the class being `@MainActor`. + nonisolated static let connection = NSToolbarItem.Identifier("com.TablePro.toolbar.connection") + nonisolated static let database = NSToolbarItem.Identifier("com.TablePro.toolbar.database") + nonisolated static let refresh = NSToolbarItem.Identifier("com.TablePro.toolbar.refresh") + nonisolated static let saveChanges = NSToolbarItem.Identifier("com.TablePro.toolbar.saveChanges") + nonisolated static let addRow = NSToolbarItem.Identifier("com.TablePro.toolbar.addRow") + nonisolated static let safeMode = NSToolbarItem.Identifier("com.TablePro.toolbar.safeMode") + nonisolated static let quickSwitcher = NSToolbarItem.Identifier("com.TablePro.toolbar.quickSwitcher") + nonisolated static let newTab = NSToolbarItem.Identifier("com.TablePro.toolbar.newTab") + nonisolated static let previewSQL = NSToolbarItem.Identifier("com.TablePro.toolbar.previewSQL") + nonisolated static let results = NSToolbarItem.Identifier("com.TablePro.toolbar.results") /// `.toggleInspector` is macOS 14. The identifier only has to be stable and unique, and /// AppKit's own inspector behaviour is not used here, so 13 gets an app-owned one. - static let inspector: NSToolbarItem.Identifier = { + nonisolated static let inspector: NSToolbarItem.Identifier = { if #available(macOS 14.0, *) { return .toggleInspector } return NSToolbarItem.Identifier("com.TablePro.toolbar.inspector") }() - static let assistant = NSToolbarItem.Identifier("com.TablePro.toolbar.assistant") - static let dashboard = NSToolbarItem.Identifier("com.TablePro.toolbar.dashboard") - static let history = NSToolbarItem.Identifier("com.TablePro.toolbar.history") - static let exportTables = NSToolbarItem.Identifier("com.TablePro.toolbar.export") - static let importTables = NSToolbarItem.Identifier("com.TablePro.toolbar.import") - static let refreshSaveGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.refreshSaveGroup") - static let editorGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.editorGroup") - static let restorePreviousValues = NSToolbarItem.Identifier("com.TablePro.toolbar.restorePreviousValues") - static let exportImportGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.exportImportGroup") - static let sidebarToggle = NSToolbarItem.Identifier("com.TablePro.toolbar.sidebarToggle") - static let backForwardGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.backForwardGroup") - static let navigateBack = NSToolbarItem.Identifier("com.TablePro.toolbar.navigateBack") - static let navigateForward = NSToolbarItem.Identifier("com.TablePro.toolbar.navigateForward") + nonisolated static let dashboard = NSToolbarItem.Identifier("com.TablePro.toolbar.dashboard") + nonisolated static let history = NSToolbarItem.Identifier("com.TablePro.toolbar.history") + nonisolated static let exportTables = NSToolbarItem.Identifier("com.TablePro.toolbar.export") + nonisolated static let importTables = NSToolbarItem.Identifier("com.TablePro.toolbar.import") + nonisolated static let restorePreviousValues = NSToolbarItem + .Identifier("com.TablePro.toolbar.restorePreviousValues") + nonisolated static let backForwardGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.backForwardGroup") + nonisolated static let navigateBack = NSToolbarItem.Identifier("com.TablePro.toolbar.navigateBack") + nonisolated static let navigateForward = NSToolbarItem.Identifier("com.TablePro.toolbar.navigateForward") + /// The pull-down that carries the long tail of a context's commands. One control whose menu + /// changes with the tab, instead of one permanent titlebar slot per command. + nonisolated static let actions = NSToolbarItem.Identifier("com.TablePro.toolbar.actions") // MARK: - NSToolbarDelegate - /// Items ahead of `.sidebarTrackingSeparator` lay out in the sidebar's own titlebar strip, - /// so the control that switches what the sidebar shows sits over the pane it drives. + /// Eight controls, and the zones are the panes. AppKit's own sidebar toggle stands alone ahead of + /// `.sidebarTrackingSeparator`, so it lays out in the sidebar's titlebar strip and follows the + /// divider; the connection and its container are centred; Refresh, the commit verb, the Actions + /// pull-down and Safe Mode are the content run; the trailing-pane toggle closes the row. /// /// A tracking separator divides the toolbar into pane-aligned sections; it does not align the /// items inside one. Everything after `.inspectorTrackingSeparator` therefore lays out from @@ -335,53 +424,61 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// WWDC23 session 10054. Keep the toggle last: ahead of the separator it lands in the content /// section and is wrong in both states. /// - /// Four runs, and every item in one belongs to the same job, because adjacent items share one - /// background under Liquid Glass and a run of unrelated singletons reads as scatter. A - /// `NSToolbarItemGroup` is one control however many subitems it holds, so Table Actions and - /// the two editor commands are groups rather than five loose buttons. - /// - /// The centre is what the window is pointed at: the connection and the container, each a - /// titled control that opens its own chooser, which is the shape Xcode gives its scheme and - /// destination. It carries a title rather than a label, so it still reads as words with the - /// toolbar in icon-only mode. The centred item this replaces was an `NSHostingController`, and - /// that is the whole reason it used to disappear: measured, a native centred group is still - /// fully visible at 700pt where the hosted one was dropped at 1000pt, because AppKit can - /// compress a group it draws itself and can only drop a view it does not. + /// The centred pair are two top-level items, not two subitems of one group. Measured on macOS + /// 27, `NSPopover.show(relativeTo:)` on a subitem raised `NSInvalidArgumentException` ("view has + /// no window") whenever its group was hidden or clipped, which Swift cannot catch, while a + /// top-level item raised in none of 16 presentations across the same states and across a + /// Customize Toolbar visit: AppKit anchors it on the titlebar or on the clipped-items indicator + /// instead. As two items they still centre as one adjacent pair, 249pt wide in a 1200pt window + /// against the group's 257pt. Each carries a title rather than a label, so it still reads as + /// words with the toolbar in icon-only mode, which is the shape Xcode gives its scheme and + /// destination. /// /// Nothing here repeats the window title, which names the tab rather than the connection. /// `.inspectorTrackingSeparator` is macOS 14. Without it the divider does not track the /// inspector's edge; the items around it are unchanged. - internal static var defaultItemIdentifiers: [NSToolbarItem.Identifier] { + nonisolated internal static var defaultItemIdentifiers: [NSToolbarItem.Identifier] { var items: [NSToolbarItem.Identifier] = [ - sidebarToggle, + .toggleSidebar, .sidebarTrackingSeparator, - contentModeItem, - backForwardGroup, .flexibleSpace, - connectionGroup, - TransportRateToolbarItem.identifier, + connection, + database, .flexibleSpace, - refreshSaveGroup, - editorGroup, + refresh, + saveChanges, + actions, safeMode, ] if #available(macOS 14.0, *) { items.append(.inspectorTrackingSeparator) } - items.append(contentsOf: [.flexibleSpace, assistant, inspector]) + items.append(contentsOf: [.flexibleSpace, inspector]) return items } - /// `addRow`, `restorePreviousValues`, `quickSwitcher` and `newTab` are absent on purpose: they - /// ride a group as subitems and the delegate vends no standalone item for any of them, so - /// listing one here would offer the customization palette a tile it cannot build. + /// A proper superset of the default list: the commands a user can drag in from Customize + /// Toolbar. Each one is also in the Actions pull-down or the menu bar, so the default set loses + /// nothing by leaving it out, and an item added from here is the user's, so no context hides it. + /// + /// Back and Forward keep their group, because `isNavigational` is what gives the pair the + /// leading-edge placement Finder and Safari use and two loose items cannot have it. /// - internal static let allowedItemIdentifiers: [NSToolbarItem.Identifier] = defaultItemIdentifiers + [ + /// Every identifier here must be one the delegate can build. Asked for one it cannot, the + /// delegate answers nil, and with `autosavesConfiguration` on AppKit prunes it from the saved + /// arrangement. + nonisolated internal static let allowedItemIdentifiers: [NSToolbarItem.Identifier] = defaultItemIdentifiers + [ + backForwardGroup, previewSQL, results, - exportImportGroup, + exportTables, + importTables, dashboard, history, + addRow, + restorePreviousValues, + newTab, + quickSwitcher, ] internal func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { @@ -392,87 +489,3 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { Self.allowedItemIdentifiers } } - -// MARK: - Sidebar Toggle - -extension MainWindowToolbar { - private static let sidebarSegmentTabs: [SidebarTab] = [.tables, .favorites] - - /// A one-of-N segmented toolbar control is `NSToolbarItemGroup` with `.selectOne`. - /// Hand-building two `NSButton`s meant faking selection with border tricks and a - /// deprecated bezel, and polling `@Observable` state to keep them in sync. - /// - /// The group must not be navigational. `isNavigational` lets AppKit lift an item out of its - /// declared slot and pin it to the leading edge of the content title area, the way Finder - /// places back and forward, which put this control past the sidebar divider no matter where - /// `defaultItemIdentifiers` listed it. Without the flag it lays out in the sidebar's own - /// titlebar strip, and follows the divider when the sidebar collapses. - internal static func makeSidebarSegmentGroup(target: AnyObject?, action: Selector) -> NSToolbarItemGroup { - /// Measured on macOS 27: an expanded `selectOne` group publishes a radio group whose buttons - /// take their name from each image's `accessibilityDescription` and never from `labels:`. - /// With nil, VoiceOver read the SF Symbol's own description, so these two announced as - /// "List" and "favorite". - let labels = [String(localized: "Tables"), String(localized: "Favorites")] - let images = zip(["list.bullet", "star"], labels).compactMap { - NSImage(systemSymbolName: $0, accessibilityDescription: $1) - } - let group = NSToolbarItemGroup( - itemIdentifier: sidebarToggle, - images: images, - selectionMode: .selectOne, - labels: labels, - target: target, - action: action - ) - group.label = String(localized: "Sidebar") - group.paletteLabel = group.label - group.controlRepresentation = .expanded - return group - } - - /// `sidebarGroup` is the one handle `syncSidebarSelection()` has on the live control, so only - /// the item actually going into the toolbar may claim it. A Customize Toolbar palette copy - /// that took the slot left every later sync writing into a discarded group, and the segments - /// stopped following the sidebar until the window was reopened. - internal func makeSidebarToggleItem(claimsSlot: Bool) -> NSToolbarItem { - let group = Self.makeSidebarSegmentGroup(target: self, action: #selector(sidebarSegmentChanged(_:))) - bindMenuForm(action: #selector(sidebarSegmentChanged(_:)), to: Self.sidebarToggle) - guard claimsSlot else { return group } - sidebarGroup = group - syncSidebarSelection() - return group - } - - /// Reachable from the control and from its overflow menu, and the two send different senders: - /// the group itself, and an `NSMenuItem`. Reading `selectedIndex` off whatever arrived and - /// giving up when it was not a group meant choosing Tables or Favorites from the overflow did - /// nothing at all, which is every ordinary window width where the control lives there. - /// - /// The group is read from `sidebarGroup` rather than from the sender, so both routes resolve the - /// same selection, and a menu item carries its segment in `tag`. - @objc fileprivate func sidebarSegmentChanged(_ sender: Any?) { - guard let index = Self.segmentIndex(from: sender, group: sidebarGroup), - Self.sidebarSegmentTabs.indices.contains(index) else { return } - coordinator?.splitViewController?.setSidebarTab(Self.sidebarSegmentTabs[index]) - } - - /// Which segment a toolbar group's action is about, whichever route sent it. - internal static func segmentIndex(from sender: Any?, group: NSToolbarItemGroup?) -> Int? { - if let menuItem = sender as? NSMenuItem, menuItem.tag >= 0 { - return menuItem.tag - } - if let sent = sender as? NSToolbarItemGroup { - return sent.selectedIndex - } - return group?.selectedIndex - } - - /// Pushed from the split view controller whenever the sidebar tab or its collapsed - /// state changes, instead of an observation loop watching for it. - internal func syncSidebarSelection() { - guard let group = sidebarGroup else { return } - let tab = coordinator?.splitViewController?.selectedSidebarTab - group.selectedIndex = tab.flatMap(Self.sidebarSegmentTabs.firstIndex(of:)) ?? -1 - managedToolbar.validateVisibleItems() - } -} diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index e4d8c49e4..d2f419374 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -9,22 +9,78 @@ import SwiftUI @MainActor internal final class SidebarContainerViewController: NSViewController { + /// Which list the rows below show. Window chrome like the field under it, so it stands through + /// a connection switch and follows the connection on screen. + private let scopeControl = SidebarScopeControl() private let searchField = NSSearchField() /// Sidebar chrome, like the field it shares a row with, so it survives a connection switch and /// writes settings that are not scoped to one. Hidden on the Favorites tab, whose list draws /// none of what these options settle. private let viewOptionsButton = SidebarViewOptionsButton() + private lazy var filterRow = NSStackView(views: [searchField, viewOptionsButton]) /// The filter field is window chrome and stays put; only the object list below it belongs to a /// connection, so that is the part the window swaps. private let listHost = WorkspacePaneHost() private var sidebarState: SharedSidebarState? private var observationTask: Task? private var filterPopover: NSPopover? + /// Exactly one of these is active. A hidden view keeps its constraints, so hiding the two rows + /// alone would leave the list standing under their height. + private var listBelowChrome: NSLayoutConstraint? + private var listAtTop: NSLayoutConstraint? + private var chromeHidden = false + + /// A list the user picked, reported to the window, which owns the sidebar and whether it is + /// open. + internal var onScopeSelection: ((SidebarTab) -> Void)? internal func show(_ controller: NSViewController?) { listHost.show(controller) } + /// The pane below the chrome, for a caller that has to read back which one is drawn: the + /// object browser, or Agent mode's session rail in its place. + internal var shownPane: NSViewController? { + listHost.shown + } + + /// Which list the scope control has selected, for a caller that has to read the chrome back. + internal var selectedScope: SidebarTab? { + scopeControl.selectedTab + } + + internal var isScopeEnabled: Bool { + scopeControl.isEnabled + } + + internal var isChromeHidden: Bool { + chromeHidden + } + + /// Agent mode puts its session rail where the object list goes, and neither row above it has + /// anything there to act on: the scope would switch a list that is not drawn, and the field + /// would filter one. Both go, and the rail takes their height. + internal func setChromeHidden(_ hidden: Bool) { + guard chromeHidden != hidden else { return } + chromeHidden = hidden + applyChromeVisibility() + } + + /// Recorded before it is applied, so a mode that arrives before the view loads is still the + /// one the view comes up in. + private func applyChromeVisibility() { + guard isViewLoaded else { return } + scopeControl.isHidden = chromeHidden + filterRow.isHidden = chromeHidden + if chromeHidden { + listBelowChrome?.isActive = false + listAtTop?.isActive = true + } else { + listAtTop?.isActive = false + listBelowChrome?.isActive = true + } + } + /// Whether the filter field answers, and what it currently holds. The object list below it /// belongs to a connection; the field belongs to the window and stands whether or not one is /// up, so both of these have to be true of a window with no session as well as one with. @@ -48,6 +104,13 @@ internal final class SidebarContainerViewController: NSViewController { override func loadView() { view = NSView() + scopeControl.translatesAutoresizingMaskIntoConstraints = false + /// Standing and dimmed until a connection is up, for the reason the field below it is. + scopeControl.isEnabled = false + scopeControl.target = self + scopeControl.action = #selector(scopeChanged(_:)) + view.addSubview(scopeControl) + searchField.translatesAutoresizingMaskIntoConstraints = false /// Standing from the window's first frame, disabled until a connection is up. It used to /// be hidden until then, so the sidebar was a bare column for the length of a connect and @@ -67,7 +130,6 @@ internal final class SidebarContainerViewController: NSViewController { /// A stack view rather than two anchored controls, so hiding the button on the Favorites /// tab takes its width with it: `detachesHiddenViews` removes a hidden arranged subview /// from the layout, where a hidden anchored one would keep its gap beside the field. - let filterRow = NSStackView(views: [searchField, viewOptionsButton]) filterRow.translatesAutoresizingMaskIntoConstraints = false filterRow.orientation = .horizontal filterRow.alignment = .centerY @@ -82,25 +144,35 @@ internal final class SidebarContainerViewController: NSViewController { /// The insets are a margin, not an invariant, so they yield rather than break when the /// window narrows the sidebar to the workspace rail and leaves this view no width at all. - let rowLeading = filterRow.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10) - let rowTrailing = filterRow.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10) - rowLeading.priority = .defaultHigh - rowTrailing.priority = .defaultHigh + let insets = [ + scopeControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), + scopeControl.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), + filterRow.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), + filterRow.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), + ] + for inset in insets { + inset.priority = .defaultHigh + } + let listBelowChrome = hostingView.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 5) + self.listBelowChrome = listBelowChrome + listAtTop = hostingView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) - NSLayoutConstraint.activate([ - filterRow.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 5), - rowLeading, - rowTrailing, + NSLayoutConstraint.activate(insets + [ + scopeControl.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 5), + filterRow.topAnchor.constraint(equalTo: scopeControl.bottomAnchor, constant: 6), - hostingView.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 5), + listBelowChrome, hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + applyChromeVisibility() } + /// Asked of the field's ancestors too, because Agent mode hides the row it sits in rather + /// than the field itself, and focusing a field nobody can see puts the keyboard nowhere. func focusSearchField() { - guard !searchField.isHidden else { return } + guard !searchField.isHiddenOrHasHiddenAncestor else { return } view.window?.makeFirstResponder(searchField) } @@ -126,7 +198,7 @@ internal final class SidebarContainerViewController: NSViewController { /// because that is what it scopes, and because the field is the one piece of sidebar chrome /// that outlives a workspace switch. func presentDatabaseFilter(connectionId: UUID, sidebarState: SharedSidebarState) { - guard !searchField.isHidden else { return } + guard !searchField.isHiddenOrHasHiddenAncestor else { return } filterPopover?.close() filterPopover = PopoverPresenter.show( relativeTo: searchField.bounds, @@ -159,13 +231,18 @@ internal final class SidebarContainerViewController: NSViewController { searchField.placeholderString = String(localized: "Filter") searchField.setAccessibilityLabel(String(localized: "Filter")) viewOptionsButton.isHidden = true + scopeControl.isEnabled = false + scopeControl.selectedTab = nil return } searchField.isEnabled = true + scopeControl.isEnabled = true /// Set here rather than left to the observation task, which runs on the next main-actor /// turn: the button would show over the favorites filter for a turn on the way in, and - /// linger for a turn on the way out, with the stack view re-laying the row each time. + /// linger for a turn on the way out, with the stack view re-laying the row each time. The + /// scope follows for the same reason, or it would name the previous connection's list. viewOptionsButton.isHidden = state.selectedSidebarTab != .tables + scopeControl.selectedTab = state.selectedSidebarTab observationTask = Task { @MainActor [weak self] in guard let self else { return } while !Task.isCancelled { @@ -195,7 +272,10 @@ internal final class SidebarContainerViewController: NSViewController { observationTask?.cancel() } + /// Every later change to the list choice arrives here, so View > Show Tables and Show + /// Favorites, which write the same state the scope control does, move the control with them. private func syncFromState(_ state: SharedSidebarState) { + scopeControl.selectedTab = state.selectedSidebarTab let activeText: String let placeholder: String switch state.selectedSidebarTab { @@ -215,6 +295,15 @@ internal final class SidebarContainerViewController: NSViewController { searchField.placeholderString = placeholder searchField.setAccessibilityLabel(placeholder) } + + /// Only a change reaches the window. `NSSegmentedControl` sends its action for a click on the + /// segment that is already selected as well, measured on macOS 27, and the command it drives + /// collapses the sidebar on a second press of the list it is showing. From a control inside the + /// sidebar that would take the control away with the pane it sits in. + @objc private func scopeChanged(_ sender: SidebarScopeControl) { + guard let tab = sender.selectedTab, tab != sidebarState?.selectedSidebarTab else { return } + onScopeSelection?(tab) + } } extension SidebarContainerViewController: NSSearchFieldDelegate { diff --git a/TablePro/Core/Services/Infrastructure/StatefulToolbarItem.swift b/TablePro/Core/Services/Infrastructure/StatefulToolbarItem.swift index e30398cde..6adfd481e 100644 --- a/TablePro/Core/Services/Infrastructure/StatefulToolbarItem.swift +++ b/TablePro/Core/Services/Infrastructure/StatefulToolbarItem.swift @@ -69,43 +69,56 @@ internal final class StatefulToolbarItem: NSToolbarItem { } } +/// A toolbar control that opens a menu and answers for its own enablement. +/// +/// It carries no action on purpose: given one, AppKit splits the control into a body that sends +/// the action and a separate chevron that opens the menu, so a click on the body opens nothing. +/// And `NSToolbarItem`'s own `validate()` only sends `validateToolbarItem(_:)` for an item that +/// has an action, so the toolbar's predicate for this identifier would never be consulted and the +/// control would stay live over a session that had gone. It asks on the validation pass instead, +/// which is also the one channel measured to keep reaching an item while it is hidden. +@MainActor +internal class StatefulMenuToolbarItem: NSMenuToolbarItem { + internal var isEnabledProvider: (@MainActor () -> Bool)? + + override internal func validate() { + super.validate() + guard let isEnabledProvider else { return } + isEnabled = isEnabledProvider() + } +} + /// The safe-mode chooser: one of six levels, and the current one has to be readable without /// opening the menu. `NSMenuToolbarItem` is the toolbar control that opens a menu, and the glyph /// tracks the level through the validation pass `MainWindowToolbar.observeItemState` triggers. @MainActor -internal final class SafeModeToolbarItem: NSMenuToolbarItem { - internal var levelProvider: (@MainActor () -> SafeModeLevel)? { - didSet { applyLevel() } +internal final class SafeModeToolbarItem: StatefulMenuToolbarItem { + /// The level and the floor under it, read on the same validation pass as the enablement, which + /// is also the one pass measured to keep reaching an item while it is hidden. A floor that + /// comes and goes without moving the level, Agent mode over a connection the user already set + /// stricter than Alert, still changes the tooltip. + internal var statusProvider: (@MainActor () -> SafeModeStatus)? { + didSet { applyStatus() } } - /// Its own enablement, because AppKit will not ask for it. This item carries no action, and - /// `NSToolbarItem`'s own `validate()` only sends `validateToolbarItem(_:)` for an item that - /// has one, so the toolbar's predicate for this identifier was never consulted and the control - /// stayed live over a session that had gone. - internal var isEnabledProvider: (@MainActor () -> Bool)? - private var symbolSource = ToolbarSymbolSource() - private var appliedLevel: SafeModeLevel? + private var appliedStatus: SafeModeStatus? override internal func validate() { super.validate() - applyLevel() - if let isEnabledProvider { - isEnabled = isEnabledProvider() - } + applyStatus() } - /// The tooltip carries the level's name because the glyph alone cannot: `lock` and - /// `lock.open` differ by a few pixels, and VoiceOver reads no image at all. - private func applyLevel() { - guard let level = levelProvider?() else { return } + private func applyStatus() { + guard let status = statusProvider?() else { return } + let level = status.level symbolSource.provider = { level.iconName } symbolSource.accessibilityDescription = level.displayName if let pending = symbolSource.pendingImage() { image = pending } - guard level != appliedLevel else { return } - appliedLevel = level - toolTip = String(format: String(localized: "Safe Mode: %@"), level.displayName) + guard status != appliedStatus else { return } + appliedStatus = status + toolTip = status.toolTip } } diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 090a8a1c3..1820aeb45 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -117,6 +117,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { FileDropDestination.register(on: window) window.title = splitVC.windowTitle window.subtitle = splitVC.windowSubtitle + window.representedURL = splitVC.windowRepresentedURL splitVC.installTabStripAccessory(on: window) super.init(window: window) diff --git a/TablePro/Core/Services/Infrastructure/Toolbar/ActionsMenuSpec.swift b/TablePro/Core/Services/Infrastructure/Toolbar/ActionsMenuSpec.swift new file mode 100644 index 000000000..6a212600f --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/Toolbar/ActionsMenuSpec.swift @@ -0,0 +1,75 @@ +// +// ActionsMenuSpec.swift +// TablePro +// + +import AppKit + +/// A submenu whose leaves are resolved when it opens rather than when the menu is built. +/// +/// The resolver stays pure by naming the submenu and stopping there. Import formats come from the +/// connection's driver and the mode list from the window, and both are questions with an answer +/// that can change between two openings of the same menu; `NSMenuDelegate.menuNeedsUpdate` is where +/// they are asked, measured to fire exactly once per real open. +internal enum ActionsSubmenuKind: Equatable, Hashable, Sendable { + case importFormats + case mode +} + +/// One row in the Actions pull-down: a command, or a submenu's own row. +/// +/// A command carries no target. Every one is built with `target = nil` so AppKit routes it through +/// the responder chain and `MainSplitViewController.validateMenuItem` decides it, which is the same +/// path the menu bar already takes. Giving a command an explicit target would hand validation to +/// `MainWindowToolbar.validateMenuItem`, whose unrecognised-action arm returns true, and ship every +/// entry enabled. +internal struct ActionsMenuEntry: Equatable { + /// Two roles rather than optional fields, so a submenu's row cannot declare a selector or a + /// shortcut it would never draw. Measured, assigning `submenu` replaces the row's action with + /// `submenuAction:` and its target with the submenu, and AppKit ignores a key equivalent on an + /// item that owns a submenu, so both would be promises the menu does not keep. + internal enum Role: Equatable { + case command(Selector, shortcut: ShortcutAction?) + case submenu(ActionsSubmenuKind) + } + + internal let title: String + internal let role: Role + + internal init(title: String, selector: Selector, shortcut: ShortcutAction? = nil) { + self.title = title + self.role = .command(selector, shortcut: shortcut) + } + + internal init(title: String, submenu: ActionsSubmenuKind) { + self.title = title + self.role = .submenu(submenu) + } + + /// Nil for a submenu's row. + internal var selector: Selector? { + guard case let .command(selector, _) = role else { return nil } + return selector + } + + /// Nil for a submenu's row, and for a command with no chord. + internal var shortcut: ShortcutAction? { + guard case let .command(_, shortcut) = role else { return nil } + return shortcut + } + + /// Nil for a command. + internal var submenu: ActionsSubmenuKind? { + guard case let .submenu(kind) = role else { return nil } + return kind + } +} + +/// A run of related commands, drawn with a separator between one section and the next. +internal struct ActionsMenuSection: Equatable { + internal let entries: [ActionsMenuEntry] + + internal init(_ entries: [ActionsMenuEntry]) { + self.entries = entries + } +} diff --git a/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuDelegate.swift b/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuDelegate.swift new file mode 100644 index 000000000..6dd2e665b --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuDelegate.swift @@ -0,0 +1,98 @@ +// +// ConnectionActionsMenuDelegate.swift +// TablePro +// + +import AppKit + +/// Builds the Actions pull-down each time it opens, from `ConnectionActionsMenuResolver` and from +/// nothing else. +/// +/// Built on open rather than on every context change. The menu is only read while it is open, and +/// `menuNeedsUpdate` is measured to fire exactly once per real open, so a tab switch costs it +/// nothing and a menu that has been opened once can never describe a tab the window has left. +/// +/// No command carries a target. AppKit resolves each one through the responder chain to the +/// window's controller and asks that controller's `validateMenuItem`, which is the path the menu bar +/// already takes for the same commands, so the pull-down needs no enablement table of its own and +/// cannot disagree with the menu bar. A command targeted at the toolbar would be validated by +/// `MainWindowToolbar.validateMenuItem` instead, which answers true for every action it did not +/// build, and the whole menu would ship enabled. A submenu's own row is the exception AppKit makes +/// itself: measured, assigning `submenu` sets the row's action to `submenuAction:` and its target +/// to the submenu. +@MainActor +internal final class ConnectionActionsMenuDelegate: NSObject, NSMenuDelegate { + private let context: @MainActor () -> ToolbarContext + private let importFormats: ImportFormatMenuDelegate + private let modes = ContentModeMenuDelegate() + + internal init( + importFormats: ImportFormatMenuDelegate, + context: @escaping @MainActor () -> ToolbarContext + ) { + self.importFormats = importFormats + self.context = context + super.init() + } + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + for item in items(for: context(), keyboard: AppSettingsManager.shared.keyboard) { + menu.addItem(item) + } + } + + /// The menu for a context, sections divided by separators. Separate from `menuNeedsUpdate` so + /// the whole shape can be read without an open menu or a window. + internal func items(for context: ToolbarContext, keyboard: KeyboardSettings) -> [NSMenuItem] { + var items: [NSMenuItem] = [] + for (index, section) in ConnectionActionsMenuResolver.sections(context).enumerated() { + if index > 0 { items.append(.separator()) } + items.append(contentsOf: section.entries.map { item(for: $0, keyboard: keyboard) }) + } + return items + } + + /// The chord an entry names is drawn from the user's own binding, so a rebind in Settings + /// reaches this menu on its next open. It is shown, not claimed: `menuHasKeyEquivalent` below + /// keeps AppKit's key-equivalent search out of this menu, so the menu bar stays the one owner. + /// + /// A submenu's row takes no action and no key equivalent, because it can draw neither. That is + /// why Import Data… is a leaf of its own beside the format list, carrying ⇧⌘I, the way File > + /// Import draws the same two rows. + private func item(for entry: ActionsMenuEntry, keyboard: KeyboardSettings) -> NSMenuItem { + switch entry.role { + case let .command(selector, shortcut): + let item = NSMenuItem(title: entry.title, action: selector, keyEquivalent: "") + item.target = nil + if let shortcut { + MenuItemFactory.apply(shortcut: shortcut, keyboard: keyboard, to: item) + } + return item + case let .submenu(kind): + let root = NSMenuItem(title: entry.title, action: nil, keyEquivalent: "") + let submenu = NSMenu(title: entry.title) + submenu.delegate = delegate(for: kind) + root.submenu = submenu + return root + } + } + + private func delegate(for kind: ActionsSubmenuKind) -> any NSMenuDelegate { + switch kind { + case .importFormats: + return importFormats + case .mode: + return modes + } + } + + func menuHasKeyEquivalent( + _ menu: NSMenu, + for event: NSEvent, + target: AutoreleasingUnsafeMutablePointer, + action: UnsafeMutablePointer + ) -> Bool { + false + } +} diff --git a/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuResolver.swift b/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuResolver.swift new file mode 100644 index 000000000..4cbe45a28 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuResolver.swift @@ -0,0 +1,284 @@ +// +// ConnectionActionsMenuResolver.swift +// TablePro +// + +import AppKit + +/// What the Actions pull-down offers in a context. +/// +/// This is where the commands that used to each own a permanent slot in the titlebar now live. One +/// control whose menu changes, rather than seventeen buttons of which most are dim on most tabs. +/// +/// Every entry has a menu-bar twin carrying the same title, and that is a rule rather than a +/// coincidence: a pull-down that grows commands of its own becomes a second, undiscoverable menu +/// bar. A test pins it. +/// +/// Nothing here reads a global. What the driver supports, what the tab is and what the window is +/// doing all arrive in the context, so the whole table is decided by a value and testable without a +/// session. +internal enum ConnectionActionsMenuResolver { + internal static func sections(_ context: ToolbarContext) -> [ActionsMenuSection] { + switch context.contentMode { + case .agent: + return [sessionSection(context), modeSection(context), connectionSection(context)] + .compactMap(\.self) + case .browse: + return browseSections(context) + } + } + + /// What Agent mode has instead of a tab's verbs. The rail carries these too, but the rail is in + /// a pane the user can collapse, and a command reachable only from a collapsible pane is a + /// command with no route when it is closed. + private static func sessionSection(_ context: ToolbarContext) -> ActionsMenuSection? { + guard context.isAIEnabled else { return nil } + return ActionsMenuSection([ + ActionsMenuEntry( + title: String(localized: "New Session"), + selector: NSSelectorFromString("newAgentSession:"), + shortcut: .newAgentSession + ), + ActionsMenuEntry( + title: String(localized: "Open Session"), + selector: NSSelectorFromString("openAgentSession:"), + shortcut: .openAgentSession + ), + ActionsMenuEntry( + title: String(localized: "Close Session"), + selector: NSSelectorFromString("closeAgentSession:"), + shortcut: .closeAgentSession + ), + ActionsMenuEntry( + title: String(localized: "Delete Session…"), + selector: NSSelectorFromString("deleteAgentSession:"), + shortcut: .deleteAgentSession + ), + ActionsMenuEntry( + title: String(localized: "New Conversation"), + selector: NSSelectorFromString("newAIConversation:"), + shortcut: .newAIConversation + ), + ]) + } + + private static func browseSections(_ context: ToolbarContext) -> [ActionsMenuSection] { + guard context.isConnected else { + return [modeSection(context), connectionSection(context)].compactMap(\.self) + } + return [ + rowSection(context), + historySection(context), + dataSection(context), + objectSection(context), + windowSection(context), + editorSection(context), + modeSection(context), + connectionSection(context), + ].compactMap(\.self) + } + + // MARK: - Sections + + /// The commands that act on the rows in front of the user. Add Row asks the same question the + /// grid does, so it follows the results mode rather than the tab kind alone. + private static func rowSection(_ context: ToolbarContext) -> ActionsMenuSection? { + var entries: [ActionsMenuEntry] = [] + if context.tabKind == .table, context.resultsMode == .data { + entries.append( + ActionsMenuEntry( + title: String(localized: "Add Row"), + selector: NSSelectorFromString("addRow:"), + shortcut: .addRow + ) + ) + } + if context.tabKind == .table || context.tabKind == .query { + entries.append( + ActionsMenuEntry( + title: String(localized: "Restore Previous Values…"), + selector: NSSelectorFromString("restorePreviousValues:") + ) + ) + } + return entries.isEmpty ? nil : ActionsMenuSection(entries) + } + + /// Back and Forward walk a table's own browse history, which only a table tab has. + private static func historySection(_ context: ToolbarContext) -> ActionsMenuSection? { + guard context.tabKind == .table else { return nil } + return ActionsMenuSection([ + ActionsMenuEntry( + title: String(localized: "Back"), + selector: NSSelectorFromString("navigateBack:"), + shortcut: .navigateBack + ), + ActionsMenuEntry( + title: String(localized: "Forward"), + selector: NSSelectorFromString("navigateForward:"), + shortcut: .navigateForward + ), + ]) + } + + /// What goes in and what comes out. Preview SQL sits beside the commit verb's own tabs because + /// it answers the question the commit raises. + private static func dataSection(_ context: ToolbarContext) -> ActionsMenuSection? { + var entries: [ActionsMenuEntry] = [] + if context.tabKind == .table || context.tabKind == .query || context.tabKind == .createTable { + entries.append( + ActionsMenuEntry( + title: String(localized: "Preview SQL"), + selector: NSSelectorFromString("previewSQL:"), + shortcut: .previewSQL + ) + ) + } + if context.tabKind == .query { + /// The one context with a results pane to collapse. The shipped rule offered this on + /// the five kinds that have none. + entries.append( + ActionsMenuEntry( + title: String(localized: "Show Results"), + selector: NSSelectorFromString("toggleResults:"), + shortcut: .toggleResults + ) + ) + } + if context.tabKind == .table || context.tabKind == .query { + entries.append( + ActionsMenuEntry( + title: String(localized: "Export Results…"), + selector: NSSelectorFromString("exportQueryResults:") + ) + ) + } + entries.append( + ActionsMenuEntry( + title: String(localized: "Export Tables…"), + selector: NSSelectorFromString("exportTables:"), + shortcut: .export + ) + ) + if context.supportsImport { + /// The command and the format list are two rows, as they are under File > Import. The + /// leaf is the one ⇧⌘I runs and says so, and it takes the driver's first format; a row + /// that owns a submenu can carry neither the action nor the chord. The list is how any + /// other format is reached, filled when it opens. + /// + /// Gated on the driver's capability, which is a registry read, and not on the formats it + /// actually has: counting those activates every lazily loaded import plugin, and retries + /// the load gate of one that failed it, on every ask. The window's validation answers + /// the rest, dimming the leaf when there is nothing to import, and the list says so in + /// its own placeholder. + entries.append( + ActionsMenuEntry( + title: String(localized: "Import Data…"), + selector: NSSelectorFromString("importData:"), + shortcut: .importData + ) + ) + entries.append( + ActionsMenuEntry(title: String(localized: "Import Data From"), submenu: .importFormats) + ) + } + return entries.isEmpty ? nil : ActionsMenuSection(entries) + } + + /// What the selected object is made of. + private static func objectSection(_ context: ToolbarContext) -> ActionsMenuSection? { + guard context.tabKind == .table || context.tabKind == .objectSource else { return nil } + return ActionsMenuSection([ + ActionsMenuEntry( + title: String(localized: "Show DDL"), + selector: NSSelectorFromString("showObjectDDL:") + ), + ActionsMenuEntry( + title: String(localized: "Copy DDL"), + selector: NSSelectorFromString("copyObjectDDL:") + ), + ]) + } + + /// The places in this connection the window can go. Each of these opens a tab or a drawer, so + /// they belong together and away from the commands that change data. + private static func windowSection(_ context: ToolbarContext) -> ActionsMenuSection? { + var entries: [ActionsMenuEntry] = [ + ActionsMenuEntry( + title: String(localized: "Show Query History"), + selector: NSSelectorFromString("toggleQueryHistory:"), + shortcut: .toggleHistory + ), + ActionsMenuEntry( + title: String(localized: "Users & Roles"), + selector: NSSelectorFromString("showUsersAndRoles:") + ), + ActionsMenuEntry( + title: String(localized: "Query Insights"), + selector: NSSelectorFromString("showQueryInsights:") + ), + ] + if context.supportsServerDashboard { + entries.append( + ActionsMenuEntry( + title: String(localized: "Server Dashboard"), + selector: NSSelectorFromString("showServerDashboard:") + ) + ) + } + return ActionsMenuSection(entries) + } + + private static func editorSection(_ context: ToolbarContext) -> ActionsMenuSection? { + ActionsMenuSection([ + ActionsMenuEntry( + title: String(localized: "New Tab"), + selector: NSSelectorFromString("newEditorTab:"), + shortcut: .newTab + ), + ActionsMenuEntry( + title: String(localized: "Open Quickly…"), + selector: NSSelectorFromString("openQuickSwitcher:"), + shortcut: .quickSwitcher + ), + ]) + } + + /// The mode control's new home, now that it no longer holds two permanent segments in the + /// titlebar. Absent with AI off, where Agent mode does not exist. + private static func modeSection(_ context: ToolbarContext) -> ActionsMenuSection? { + guard context.isAIEnabled else { return nil } + return ActionsMenuSection([ + ActionsMenuEntry(title: String(localized: "Mode"), submenu: .mode), + ]) + } + + /// The route out of a window whose connection went away, and the reason the pull-down answers + /// in every phase rather than only over a live session. + private static func connectionSection(_ context: ToolbarContext) -> ActionsMenuSection? { + var entries: [ActionsMenuEntry] = [ + ActionsMenuEntry( + title: String(localized: "Switch Connection…"), + selector: NSSelectorFromString("switchConnection:"), + shortcut: .switchConnection + ), + ] + if !context.isConnected { + /// Declared with no sender, and the menu bar spells it the same way. A selector with a + /// colon reaches nothing and AppKit draws the entry disabled. + entries.append( + ActionsMenuEntry( + title: String(localized: "Reconnect"), + selector: NSSelectorFromString("retryConnection") + ) + ) + } + entries.append( + ActionsMenuEntry( + title: String(localized: "Close Connection"), + selector: NSSelectorFromString("closeConnection:") + ) + ) + return ActionsMenuSection(entries) + } +} diff --git a/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift b/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift new file mode 100644 index 000000000..292a85583 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift @@ -0,0 +1,196 @@ +// +// ToolbarContextResolver.swift +// TablePro +// + +import AppKit + +/// Which of the connection window's toolbar items a context shows, and which of them answer. +/// +/// Two questions with deliberately different inputs, and keeping them apart is what stops the +/// titlebar reflowing while the user types. +/// +/// `visibility(for:)` and `commitVerb(for:)` take `ToolbarContext.VisibilityKey` or a part of it and +/// nothing else, so the type says what the shape may depend on: the tab kind, the results mode, the +/// content mode and the driver's capabilities. Those change on a tab switch, a mode switch or a +/// connection switch and at no other time, which are the three moments a native app's toolbar is +/// expected to change shape. `isEnabled` carries everything transient, so a staged edit, a running +/// query or a reconnect backoff dims a control and never moves or relabels one. +/// +/// Version-free on purpose. `NSToolbarItem.isHidden` is macOS 15, and the caller is what decides +/// whether to apply the set or fall back to dimming; the answer itself does not depend on the OS. +/// +/// Exhaustive over `TabType` with no `default:` arm. CLAUDE.md's "every switch needs `default:`" +/// rule is about `DatabaseType`, which is an open string-based struct; `TabType` is closed, and a +/// ninth kind must not compile without choosing what its toolbar shows. +internal enum ToolbarContextResolver { + /// The identifiers the app may take out of the titlebar, which is exactly the set it puts there. + /// + /// Derived rather than listed, so the rule cannot drift from the toolbar: an item is hideable + /// because the app placed it, and anything else in `NSToolbar.items` was dragged in from the + /// customization palette by the user. That item is opt-in, so it stays where they put it in every + /// context and only dims. Measured on macOS 27 with two palette items inserted into a live + /// toolbar, a pass that hid through this filter wrote neither of them and moved neither. + /// + /// The spaces and tracking separators are in it, and deliberately so. Filtering the standard + /// identifiers out by their `NSToolbar` prefix would also declare the sidebar and inspector + /// toggles unhideable, and a space only ever receives `isHidden = false`, which AppKit returns + /// from early at about 2.5ns a write. + internal static let hideableIdentifiers = Set(MainWindowToolbar.defaultItemIdentifiers) + + /// What this context takes out of the titlebar, already confined to what may be taken. + /// + /// The confinement lives here rather than at the call site, so it is a property of the answer + /// and a second caller cannot skip it. + internal static func visibility(for key: ToolbarContext.VisibilityKey) -> ToolbarVisibility { + ToolbarVisibility(hidden: contextualHidden(key).intersection(hideableIdentifiers)) + } + + /// The commit control's label: the verb its tab commits with. + /// + /// Keyed on the tab kind and never on what is staged. The staged change moves with every edit: + /// a Create Table draft is `.createTable` only while it validates, so a label read from it + /// flipped between Save Changes and Create Table as the user typed, and with labels shown each + /// flip changed the item's width and reflowed the titlebar. The kind moves on a tab switch, + /// when the item set may change anyway. Nothing staged still leaves the control dim, which is + /// `isEnabled`'s answer. + internal static func commitVerb(for tabKind: TabType?) -> String { + switch tabKind { + case .createTable: + String(localized: "Create Table") + case .usersRoles: + String(localized: "Apply Changes") + case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, nil: + String(localized: "Save Changes") + } + } + + private static func contextualHidden(_ key: ToolbarContext.VisibilityKey) -> Set { + var hidden: Set = [] + + /// A file-based engine has one database and it is the file already named beside it, so the + /// second capsule has never been clickable on SQLite or DuckDB. + if key.isFileBased || !key.supportsContainerSwitching { + hidden.insert(MainWindowToolbar.database) + } + + switch key.contentMode { + case .agent: + /// No grid and no object browser are on screen, and the commit control's gate is frozen + /// because the browse content tree is not mounted to write it. + hidden.insert(MainWindowToolbar.refresh) + hidden.insert(MainWindowToolbar.saveChanges) + return hidden + case .browse: + hidden.formUnion(browseHidden(key)) + return hidden + } + } + + private static func browseHidden(_ key: ToolbarContext.VisibilityKey) -> Set { + guard let tabKind = key.tabKind else { return [] } + switch tabKind { + case .createTable: + /// A definition that is not on the server yet has nothing to reload. + return [MainWindowToolbar.refresh] + case .erDiagram, .serverDashboard, .insights, .objectSource: + /// None of these four can stage a change, so the commit control could only ever be dim. + return [MainWindowToolbar.saveChanges] + case .query, .table, .usersRoles: + return [] + } + } + + /// The items that act on the browse content, which Agent mode does not mount. + /// + /// Two of them leave the titlebar there; the rest are opt-in from the customization palette, so a + /// user who put one back would otherwise have a live button over a surface that is not on screen: + /// Refresh with no grid to reload, Query History flipping a persisted flag for a drawer that is + /// not mounted and that then sprang open on the way back to browsing, New Tab opening a tab + /// behind the conversation, and the commit control over a gate frozen at the moment the mode + /// changed. + /// + /// Stated once rather than as a term in fourteen arms, because it is one rule. The menu bar's + /// `browseContentSelectors` is the same list in its own vocabulary, and + /// `MenuContentModeParityTests` derives this one back out of the toolbar and holds the two + /// together, so an item added here without a menu twin fails rather than ships. + private static let browseContentIdentifiers: Set = [ + MainWindowToolbar.refresh, + MainWindowToolbar.saveChanges, + MainWindowToolbar.addRow, + MainWindowToolbar.restorePreviousValues, + MainWindowToolbar.previewSQL, + MainWindowToolbar.results, + MainWindowToolbar.history, + MainWindowToolbar.newTab, + MainWindowToolbar.quickSwitcher, + MainWindowToolbar.exportTables, + MainWindowToolbar.importTables, + MainWindowToolbar.dashboard, + MainWindowToolbar.navigateBack, + MainWindowToolbar.navigateForward, + ] + + /// Whether an item answers in this context. + /// + /// Every command the toolbar vends has an arm. The Back and Forward group has none because it is + /// a container: it carries no action, so AppKit never asks for it, and each of its two subitems + /// answers for itself. The `default:` returns false rather than true because the old + /// unconditional arm is what left Query History live and inert over a window that had never + /// connected, and left every identifier nobody had thought about enabled. + internal static func isEnabled( + _ identifier: NSToolbarItem.Identifier, + context: ToolbarContext + ) -> Bool { + if context.contentMode == .agent, browseContentIdentifiers.contains(identifier) { return false } + switch identifier { + case MainWindowToolbar.connection: + /// Switch Connection is the window's command, so it answers before a session exists. + /// It is the route back from a connection that failed. + return true + case MainWindowToolbar.database: + return context.isConnected && !context.isFileBased && context.supportsContainerSwitching + case MainWindowToolbar.actions: + /// The menu gates its own entries through the responder chain, so this answers only for + /// the window having something to be about at all. A closing window opens no menu. + return context.hasSelectedWorkspace && context.pane != .empty + case MainWindowToolbar.refresh: + return context.isConnected + case MainWindowToolbar.saveChanges: + return context.pendingChange != nil && context.isConnected && !context.blocksAllWrites + case MainWindowToolbar.safeMode: + /// Safe Mode is what stands between a stray keystroke and a live table, so it answers + /// for as long as the session does. A window with no session has nothing to write it to. + return context.isConnected + case MainWindowToolbar.inspector: + /// Not `isConnected`. A connection that drops with the pane open must still be able to + /// close it, which is the state the old rule disabled on the app's minimum OS. + return context.canToggleTrailingPane + case MainWindowToolbar.addRow: + return context.isConnected && context.canAddRow + case MainWindowToolbar.restorePreviousValues: + return context.isConnected && context.canRestorePreviousValues + case MainWindowToolbar.navigateBack: + return context.isConnected && context.canNavigateBack + case MainWindowToolbar.navigateForward: + return context.isConnected && context.canNavigateForward + case MainWindowToolbar.previewSQL: + return context.isConnected && context.hasDataPendingChanges + case MainWindowToolbar.results: + /// The results pane belongs to the query editor. The shipped rule was `!isTableTab`, + /// which enabled it on the five kinds that have no results pane at all and then wrote a + /// collapse flag with no tab-kind guard behind it. + return context.isConnected && context.tabKind == .query + case MainWindowToolbar.history: + return context.isConnected + case MainWindowToolbar.dashboard: + return context.isConnected && context.supportsServerDashboard + case MainWindowToolbar.exportTables, MainWindowToolbar.newTab, MainWindowToolbar.quickSwitcher: + return context.isConnected + case MainWindowToolbar.importTables: + return context.isConnected && !context.blocksAllWrites && context.supportsImport + default: + return false + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarVisibility.swift b/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarVisibility.swift new file mode 100644 index 000000000..607023d56 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarVisibility.swift @@ -0,0 +1,28 @@ +// +// ToolbarVisibility.swift +// TablePro +// + +import AppKit + +/// Which of the connection window's toolbar items the app has taken out of the titlebar. +/// +/// The app's own record, and the only answer to "is this item on screen" anything may act on. +/// AppKit's readings of the same question are measured unsafe on macOS 27: after one visit to +/// Customize Toolbar, `NSToolbar.visibleItems` and `NSToolbarItem.isVisible` over-report for good, +/// listing 6 items against 2 laid out at a 460pt window and calling a hidden item visible, and no +/// resize, re-toggle or re-insert repaired either. The error always runs toward "on screen", which +/// is the direction that hands `NSPopover` an anchor with no window. +/// +/// Its own file so `ToolbarSwitcherPresenter` can take one without reaching into the resolver. +internal struct ToolbarVisibility: Equatable { + internal let hidden: Set + + internal init(hidden: Set = []) { + self.hidden = hidden + } + + internal func hides(_ identifier: NSToolbarItem.Identifier) -> Bool { + hidden.contains(identifier) + } +} diff --git a/TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift b/TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift index 890d1e67e..a3d7ae8d4 100644 --- a/TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift +++ b/TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift @@ -7,12 +7,16 @@ import Foundation -/// How a coordinator asks the window to show one of its two trailing surfaces. +/// How a coordinator asks the window to show one of its trailing surfaces. /// -/// There is one pane and two things that can be in it, so "is it open" is not a single question any -/// more: showing the assistant over an open inspector is a change even though the pane was already -/// visible. Each surface therefore gets its own visibility question and its own toggle, and the -/// toggles are what the menu bar and the toolbar drive. +/// There is one pane and several things that can be in it, so "is it open" is not a single question: +/// showing the assistant over an open inspector is a change even though the pane was already +/// visible. Each surface therefore gets its own visibility question and its own toggle. +/// +/// The toggles are requirements rather than defaults built on the visibility questions, because +/// what a toggle does depends on the mode as well as the surface: in Agent mode the pane draws the +/// session's result whatever was stored, and the window alone knows that. Built on the stored +/// surface, the inspector's toggle collapsed the result column it had mistaken for the inspector. @MainActor internal protocol TrailingPaneProxy: AnyObject { var isInspectorVisible: Bool { get } @@ -20,21 +24,10 @@ internal protocol TrailingPaneProxy: AnyObject { func showInspector() func showAssistant() func hideTrailingPane() + func toggleInspector() + func toggleAssistant() /// Reveals the inspector for a selection the user made somewhere else, and only if that does /// not take the pane away from something they opened deliberately. func revealInspectorForSelection() } - -internal extension TrailingPaneProxy { - /// Toggling the surface already on screen closes the pane; toggling the other swaps to it and - /// reveals the pane if it was closed. That is what makes two commands over one pane read the - /// way two commands over two panes would. - func toggleInspector() { - if isInspectorVisible { hideTrailingPane() } else { showInspector() } - } - - func toggleAssistant() { - if isAssistantVisible { hideTrailingPane() } else { showAssistant() } - } -} diff --git a/TablePro/Core/Services/Infrastructure/TransportRateToolbarItem.swift b/TablePro/Core/Services/Infrastructure/TransportRateToolbarItem.swift deleted file mode 100644 index 8465aa2df..000000000 --- a/TablePro/Core/Services/Infrastructure/TransportRateToolbarItem.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// TransportRateToolbarItem.swift -// TablePro -// - -import AppKit - -/// The throughput readout, beside the centred connection group rather than inside it. -/// -/// Bare text with no capsule, which is what Xcode does with the one comparable thing it ships: -/// measured on a running Xcode, its Window Title/Activity readout draws as plain text next to the -/// Back/Forward capsule and wears no platter of its own. A capsule was tried here and it was wrong -/// twice over. `title` on a group subitem is what earns a subitem its own capsule (measured: two -/// titled subitems give two platters, three untitled ones give a single platter spanning all of -/// them, and `controlRepresentation` changes neither), so the centre became three capsules for two -/// controls and one number, and read as scattered. -/// -/// Sitting outside the group is what keeps the pair centred. A group is laid out around its own -/// midpoint, so a readout inside it pushed the connection and database capsules off centre by half -/// the readout's width. Measured at 1400pt: with the readout as a separate adjacent item the group -/// sits at x=647.0, midX=772.8, byte-identical to having no readout at all, and the text lands 6.0pt -/// past the group's trailing edge. -/// -/// The one place in this toolbar that carries a view, and the reason is that the figure has to hold -/// a constant width. A view-less item would carry it in `title`, and a title re-measures: with the -/// figure written into one and `validateVisibleItems()` called, the group went 219pt, 233pt, 232pt, -/// 251pt across `0 kB/s`, `145 kB/s`, `1.2 MB/s` and `888.8 MB/s`, walking its own midpoint 16pt. -/// A group is laid out around that midpoint, so every one of those steps slides the connection name -/// beside it, once a second. -/// -/// A view pinned to a width settles it. Measured across the same four figures, the group frame and -/// the field frame were byte-identical every time: `group.x=396.0 w=248.0`, `field.x=573.0 w=71.0`. -/// -/// It publishes no action, so AppKit never validates it and it has no menu-bar command of its own. -/// That is the cost of a readout, and it is why the item is only in the group at all for a -/// connection whose bytes the app carries. -@MainActor -internal final class TransportRateToolbarItem: NSToolbarItem { - private let field = NSTextField(labelWithString: "") - - internal init() { - super.init(itemIdentifier: Self.identifier) - let label = String(localized: "Throughput") - self.label = label - paletteLabel = label - field.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) - field.textColor = .secondaryLabelColor - field.alignment = .center - field.lineBreakMode = .byClipping - field.setAccessibilityLabel(label) - field.translatesAutoresizingMaskIntoConstraints = false - field.widthAnchor.constraint(equalToConstant: Self.fieldWidth).isActive = true - view = field - overflowEntry.isEnabled = false - menuFormRepresentation = overflowEntry - apply(rate: nil) - } - - internal static let identifier = NSToolbarItem.Identifier("com.TablePro.toolbar.transportRate") - - /// Measured from the widest figure the label can produce rather than typed in, so a change to - /// the format cannot leave the field a few points too narrow and clip its own text. - private static let fieldWidth: CGFloat = { - let font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) - let widest = TransportRateLabel.widestCandidates - .map { ($0 as NSString).size(withAttributes: [.font: font]).width } - .max() ?? 0 - return ceil(widest) + 8 - }() - - /// The centred group is the first region AppKit sheds into the overflow menu, so the figure - /// gets an entry of its own there rather than disappearing with the controls beside it. It is - /// disabled because there is nothing to click: it reports, it does not do. - private let overflowEntry = NSMenuItem() - - internal func apply(rate: TransportRate?) { - let text = TransportRateLabel.text(for: rate) - guard text != field.stringValue else { return } - - let spoken = TransportRateLabel.accessibilityValue(for: rate) - field.stringValue = text - field.setAccessibilityValue(spoken) - overflowEntry.title = spoken - toolTip = String(format: String(localized: "%@ through this connection's transport"), spoken) - } -} diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 940f6c84f..58f074d47 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -503,6 +503,17 @@ internal final class WindowManager { workspaces(for: connectionId).compactMap { $0.sessionState?.coordinator } } + /// Every window's controller hosting this connection, for a command whose result the other + /// windows have to be told about rather than discover. + /// + /// A workspace nobody selected repairs itself on selection through its pane render key, but a + /// second window showing the same connection has that workspace selected already, so nothing + /// there is about to ask. Closing or deleting an agent session from one window is exactly that + /// case: the other window's assistant is pointed at a session that has gone. + internal func hostControllers(for connectionId: UUID) -> [MainSplitViewController] { + hosts().filter { $0.workspaces.workspace(for: connectionId) != nil } + } + /// The window hosting this connection, whatever state it is in. /// /// Visibility is not the test: a miniaturized window still hosts its connections, so filtering diff --git a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift index f58902bc0..2adad5ac9 100644 --- a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift +++ b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift @@ -10,9 +10,21 @@ import Foundation /// Title and subtitle decided together. Resolving them apart is how a window ended up /// announcing "TablePro - TablePro": two callers each picked the connection name without /// knowing the other had. +/// +/// The proxy icon is decided with them, because it is the rest of what the titlebar says. It +/// used to be written by the browse content alone, which named its own tab's file whatever the +/// window was showing, so a conversation in Agent mode sat under a query file's icon and its +/// Command-click path menu. struct ResolvedWindowTitle: Equatable { let title: String let subtitle: String + let representedURL: URL? + + init(title: String, subtitle: String, representedURL: URL? = nil) { + self.title = title + self.subtitle = subtitle + self.representedURL = representedURL + } } @MainActor @@ -26,8 +38,14 @@ enum WindowTitleResolver { /// after a tab is naming something that is not there: that is how a connecting window came /// to be called "SQL Query", and how a window that lost its session kept the name of the /// table it had stopped displaying. + /// + /// The same holds for the mode. Agent mode puts a conversation in the detail column and the + /// editor tabs behind it, so the window names the session it is showing rather than whichever + /// tab was selected when the mode came on. `agentSessionTitle` is read only then. static func resolveWindow( pane: ConnectionWindowPane, + contentMode: ConnectionWorkspaceContentMode, + agentSessionTitle: String?, connection: DatabaseConnection?, tab: QueryTab?, hasTabs: Bool, @@ -35,6 +53,13 @@ enum WindowTitleResolver { ) -> ResolvedWindowTitle { let connectionName = connection?.name ?? "" + switch ConnectionWindowPaneResolver.detailMode(for: pane, contentMode: contentMode) { + case .agent: + return agentTitle(agentSessionTitle) + case .browse: + break + } + guard pane == .content else { return connectionTitle(connectionName) } @@ -47,13 +72,25 @@ enum WindowTitleResolver { /// HIG's principal item "takes precedent over". The title names the tab, which is a /// different fact and the one a window tab label needs. let title = resolveTitle(tab: tab, connection: connection, queryLanguageName: queryLanguageName) - return ResolvedWindowTitle(title: title, subtitle: "") + return ResolvedWindowTitle(title: title, subtitle: "", representedURL: tab?.content.sourceFileURL) } private static func connectionTitle(_ name: String) -> ResolvedWindowTitle { ResolvedWindowTitle(title: name.isBlank ? fallbackTitle : name, subtitle: "") } + /// A session has no name until its first reply or its first question gives it one, and the + /// window still needs one from the moment the mode comes on, so the mode's own name stands in. + /// No subtitle, for the reason the tab title carries none: the connection is the toolbar's + /// centred item. No proxy icon either, since a conversation is not a file, whatever file the + /// tab behind it was opened from. + private static func agentTitle(_ sessionTitle: String?) -> ResolvedWindowTitle { + guard let sessionTitle, !sessionTitle.isBlank else { + return ResolvedWindowTitle(title: ConnectionWorkspaceContentMode.agent.localizedTitle, subtitle: "") + } + return ResolvedWindowTitle(title: sessionTitle, subtitle: "") + } + static func resolveTitle( payload: EditorTabPayload?, databaseType: DatabaseType?, diff --git a/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift b/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift index 085d2c7c6..0d5a39eac 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift @@ -23,10 +23,12 @@ internal struct WorkspacePaneRenderKey: Equatable { internal let sessionRevision: Int /// A mode toggle changes nothing else in this key: the phase holds, the connection holds, and /// the session is the same one. Without it `syncPanes(of:)` compares equal and silently skips - /// the rebuild, so the window stays on the mode it was already drawing. + /// the rebuild, so the agent panes are never built on the way in and the trailing surfaces keep + /// the header of the mode the window has left. internal let contentMode: ConnectionWorkspaceContentMode - /// Which agent session the panes were built for. Switching session changes nothing else in this - /// key, so without it the conversation and result panes stay bound to the previous one. + /// Which agent session the agent panes were built for. Switching session changes nothing else in + /// this key, so without it the rail, the conversation and the result stay bound to the previous + /// one. internal let agentSessionId: UUID? } @@ -65,6 +67,17 @@ internal final class WorkspacePanes { /// and it gets the `sizingOptions` firewall below by being here. internal let agentResult: NSHostingController + /// Agent mode's session rail and conversation, beside the object browser and the browse content + /// rather than in place of them. + /// + /// Entering or leaving the mode is a reparent of the sidebar and detail hosts, the same view + /// swap a workspace switch is. The two modes used to be two arms of one `@ViewBuilder` + /// conditional in each of those panes, and switching arms is an identity change, so every toggle + /// threw away the browse tree and the state above that no model holds. These hold `Color.clear` + /// until the connection first enters Agent mode, and keep what they drew once it leaves. + internal let agentRail: NSHostingController + internal let agentConversation: NSHostingController + internal let sidebar: NSHostingController /// The editor tab strip. It is a pane like the other three, built and kept alive per /// connection, even though the window shows it in the titlebar accessory rather than in a @@ -85,6 +98,8 @@ internal final class WorkspacePanes { inspector = NSHostingController(rootView: AnyView(Color.clear)) assistant = NSHostingController(rootView: AnyView(Color.clear)) agentResult = NSHostingController(rootView: AnyView(Color.clear)) + agentRail = NSHostingController(rootView: AnyView(Color.clear)) + agentConversation = NSHostingController(rootView: AnyView(Color.clear)) sidebar = NSHostingController(rootView: AnyView(Color.clear)) tabStrip = EditorTabStripPaneController() for pane in panes { @@ -92,8 +107,12 @@ internal final class WorkspacePanes { } } + /// Every hosting controller above, and the only list the firewall and the teardown walk. A + /// stored pane missing from it would publish its content's minimum width to the split view and + /// outlive its connection, which is why `WorkspacePanesFirewallTests` reads the stored ones back + /// rather than trusting this line. private var panes: [NSHostingController] { - [detail, inspector, assistant, agentResult, sidebar] + [detail, inspector, assistant, agentResult, agentRail, agentConversation, sidebar] } /// The controller a trailing surface is drawn by. One split item hosts whichever of these the @@ -106,6 +125,22 @@ internal final class WorkspacePanes { } } + /// The controller the sidebar column draws for a mode: the object browser, or the session rail. + internal func sidebarPane(for mode: ConnectionWorkspaceContentMode) -> NSHostingController { + switch mode { + case .browse: sidebar + case .agent: agentRail + } + } + + /// The controller the detail column draws for a mode: the browse content, or the conversation. + internal func detailPane(for mode: ConnectionWorkspaceContentMode) -> NSHostingController { + switch mode { + case .browse: detail + case .agent: agentConversation + } + } + internal func markRendered(_ key: WorkspacePaneRenderKey) { renderedKey = key } diff --git a/TablePro/Core/Services/Policy/AgentModeSafeModeFloor.swift b/TablePro/Core/Services/Policy/AgentModeSafeModeFloor.swift index de767e730..bb9169a35 100644 --- a/TablePro/Core/Services/Policy/AgentModeSafeModeFloor.swift +++ b/TablePro/Core/Services/Policy/AgentModeSafeModeFloor.swift @@ -35,16 +35,34 @@ internal enum AgentModeSafeModeFloor { .contains { $0.resolvedContentMode == .agent } } - /// The level this connection should run at right now. - internal static func level(for connection: DatabaseConnection) -> SafeModeLevel { - let floor = SafeModeFloor.resolve( + /// The floor that applies to this connection right now, and why. + /// + /// Named rather than computed inline, because the reason is what the user needs: the Safe Mode + /// menu offers only the levels a floor allows and prints its explanation under them, and the + /// toolbar's padlock carries the same sentence in its tooltip. Agent mode used to raise the + /// floor silently, so choosing a weaker level appeared to do nothing and nothing said why. + internal static func effectiveFloor(for connection: DatabaseConnection) -> SafeModeFloor? { + SafeModeFloor.resolve( isEngineReadOnly: PluginMetadataRegistry.shared .snapshot(for: connection.type)?.capabilities.isEngineReadOnly ?? false, opensRemoteDatabaseFile: connection.opensRemoteDatabaseFile, managedMinimum: ManagedPolicyResolver.minimumSafeModeLevel(policy: ManagedPolicyReader.shared), isAgentModeActive: isActive(for: connection.id) ) - return floor?.raising(connection.preferredSafeModeLevel) ?? connection.preferredSafeModeLevel + } + + /// The level this connection should run at right now. + internal static func level(for connection: DatabaseConnection) -> SafeModeLevel { + status(for: connection).level + } + + /// The level in force and the floor under it, which is what a choice of level is judged against. + internal static func status(for connection: DatabaseConnection) -> SafeModeStatus { + let floor = effectiveFloor(for: connection) + return SafeModeStatus( + level: floor?.raising(connection.preferredSafeModeLevel) ?? connection.preferredSafeModeLevel, + floor: floor + ) } /// Recomputes the live session's level after a mode change. diff --git a/TablePro/Core/Storage/ConnectionLocalState.swift b/TablePro/Core/Storage/ConnectionLocalState.swift index 2ded7b2ac..9c4fa1fd0 100644 --- a/TablePro/Core/Storage/ConnectionLocalState.swift +++ b/TablePro/Core/Storage/ConnectionLocalState.swift @@ -28,7 +28,8 @@ internal enum ConnectionLocalState { appSettings: AppSettingsStorage = .shared, tableScopedStores: [any TableScopedSettingsStore] = TableScopedSettingsRegistry.stores, sqlFavorites: SQLFavoriteManager = .shared, - queryHistory: QueryHistoryManager = .shared + queryHistory: QueryHistoryManager = .shared, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults ) { guard !connectionIds.isEmpty else { return } @@ -43,6 +44,7 @@ internal enum ConnectionLocalState { QueryInsightsPreferencesStorage.remove(for: connectionId) MCPServerStore.shared.forgetConnection(connectionId) } + purgeTrailingPaneKeys(connectionIds, defaults: defaults) for store in tableScopedStores { store.purgeConnections(connectionIds, leavesTombstones: origin == .local) @@ -94,6 +96,23 @@ internal enum ConnectionLocalState { } } + /// The trailing pane's two keys, which are written straight onto the defaults object rather + /// than through a store with a `remove(for:)` of its own, so `purge`'s list of stores never + /// reached them. A deleted connection left the surface it was last showing and its inspector's + /// view mode behind, and a connection later given the same id inherited both. + /// + /// Separate from `purge` for the reason `purgeAsyncStores` is: `purge` reaches nine shared + /// singletons and a test cannot call it, while this takes the one thing it writes to. + internal static func purgeTrailingPaneKeys( + _ connectionIds: Set, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults + ) { + for connectionId in connectionIds { + defaults.removeObject(forKey: TrailingPaneState.surfaceKey(connectionId)) + defaults.removeObject(forKey: RowInspectorState.viewModeKey(connectionId)) + } + } + /// The in-memory registries go first. A live `SharedSidebarState` for this connection rewrites /// its own defaults keys on the next mutation, so removing the keys under it achieves nothing. private static func purgeLiveState(_ connectionId: UUID) { diff --git a/TablePro/Core/Transport/TransportRateLabel.swift b/TablePro/Core/Transport/TransportRateLabel.swift deleted file mode 100644 index 38740ab71..000000000 --- a/TablePro/Core/Transport/TransportRateLabel.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// TransportRateLabel.swift -// TablePro -// - -import Foundation - -/// The throughput as one short line: an arrow for the busier direction and a figure. -/// -/// Only one direction is shown. Two rates side by side is more width than the centred toolbar item -/// has, and for a database connection the interesting one is whichever is moving: results coming -/// back, or an import going out. -/// -/// The figure is assembled from a number and a unit rather than handed to `.byteCount`, which -/// cannot produce a shape that holds a constant width. Measured: `allowedUnits: [.kb]` floors -/// nothing, so 512 still comes back "512 bytes", and zero comes back "Zero kB" whatever the units -/// say. Both are four characters wider than the figures around them. The unit is left unlocalized, -/// per the rule that technical terms are; the figure is localized. -/// -/// An idle transport reads `0 kB/s` rather than going blank. Worth knowing before reading much into -/// that figure: polling TablePlus's equivalent readout over an SSH tunnel gave `0 B/s` in 19 of 20 -/// idle samples, and in 37 of 45 taken during two table reloads. A quiet connection is the normal -/// case, not a broken one. -internal enum TransportRateLabel { - /// Every shape the label can take, which is what the toolbar's field measures itself against so - /// its width is settled once and never follows the figure. - internal static let widestCandidates = ["\u{2193}999 kB/s", "\u{2193}999 MB/s", "\u{2193}999 GB/s"] - - private static let bytesPerKilobyte: Double = 1_000 - - internal static func text(for rate: TransportRate?) -> String { - arrow(for: rate) + figureAndUnit(for: rate) - } - - private static func figureAndUnit(for rate: TransportRate?) -> String { - let value = rate.map { max($0.receivedPerSecond, $0.sentPerSecond) } ?? 0 - guard value.isFinite, value > 0 else { return figure(0) + " kB/s" } - - let kilobytes = value / bytesPerKilobyte - guard kilobytes >= 1 else { return figure(0) + " kB/s" } - guard kilobytes >= bytesPerKilobyte else { return figure(kilobytes) + " kB/s" } - - let megabytes = kilobytes / bytesPerKilobyte - guard megabytes >= bytesPerKilobyte else { return figure(megabytes) + " MB/s" } - return figure(megabytes / bytesPerKilobyte) + " GB/s" - } - - /// One fractional digit below ten, none above, so the figure never runs past three characters. - private static func figure(_ value: Double) -> String { - let fractionDigits = value < 10 && value > 0 ? 1 : 0 - return value.formatted(.number.precision(.fractionLength(fractionDigits)).grouping(.never)) - } - - /// Down unless the connection is sending more than it is receiving, which is what an import - /// looks like. An idle transport keeps the down arrow rather than losing a character. - private static func arrow(for rate: TransportRate?) -> String { - guard let rate, rate.sentPerSecond > rate.receivedPerSecond else { return "\u{2193}" } - return "\u{2191}" - } - - /// What VoiceOver reads instead of an arrow it would spell out as a glyph name. - internal static func accessibilityValue(for rate: TransportRate?) -> String { - let figure = figureAndUnit(for: rate) - let sending = (rate?.sentPerSecond ?? 0) > (rate?.receivedPerSecond ?? 0) - return sending - ? String(format: String(localized: "Sending %@"), figure) - : String(format: String(localized: "Receiving %@"), figure) - } -} diff --git a/TablePro/Models/AI/AgentArtifactCache.swift b/TablePro/Models/AI/AgentArtifactCache.swift new file mode 100644 index 000000000..53bed7f82 --- /dev/null +++ b/TablePro/Models/AI/AgentArtifactCache.swift @@ -0,0 +1,103 @@ +// +// AgentArtifactCache.swift +// TablePro +// + +import Combine +import Foundation + +/// The result column's projection of a session's transcript, rebuilt only when the transcript +/// changes in a way the projection can see. +/// +/// `AgentArtifactProjection` walks every turn and `AgentResultDecoder` parses a run's whole result, +/// and the column used to run both from computed properties read in `body`, so every redraw did both +/// again, the ones a streaming reply causes included. The key is what the projection reads and +/// nothing more: the session, the number of turns, and every call and result block with the state it +/// is in. Text arriving in the turn that is streaming moves none of it. +/// +/// A count of turns alone is not enough of a key. A call waiting for its answer sits in the turn that +/// is still open, and a Copilot result lands in the turn that made the call, so a key of turns would +/// hold a proposed statement off the column until some later turn arrived. The blocks are named by +/// their own ids rather than the provider's call ids, because several providers number every round's +/// calls from `call_0`, and two conversations can agree on all of them. +/// +/// The session is in the key because the column outlives a session switch: the pane is one hosting +/// controller per window, drawing whichever session is open, and this is that pane's state. It is an +/// `ObservableObject` only so the pane can keep it as a `@StateObject`. It publishes nothing, because +/// the pane already redraws when the session does, and all this decides is how much work a redraw is. +@MainActor +internal final class AgentArtifactCache: ObservableObject { + /// Nothing here is `@Published`, so the conformance names its publisher itself: with no published + /// property the compiler cannot infer one, and the pane needs the conformance to hold this as a + /// `@StateObject` and get one instance for as long as the column lives. + internal typealias ObjectWillChangePublisher = ObservableObjectPublisher + + internal struct Key: Equatable { + internal let sessionId: UUID + internal let turnCount: Int + internal let toolBlocks: [ToolBlockState] + } + + internal enum ToolBlockState: Equatable { + case call(blockId: UUID, approval: ToolApprovalState) + case result(blockId: UUID) + } + + private let project: @MainActor ([ChatTurn]) -> AgentArtifact + private let decode: (String) -> AgentResultPayload + + private var key: Key? + private var artifact = AgentArtifact() + private var payloads: [String: AgentResultPayload] = [:] + + internal init( + project: @escaping @MainActor ([ChatTurn]) -> AgentArtifact = AgentArtifactProjection.build(from:), + decode: @escaping (String) -> AgentResultPayload = AgentResultDecoder.payload(fromResultJSON:) + ) { + self.project = project + self.decode = decode + } + + /// The session's statements and runs, projected again only when the key has moved. + internal func artifact(for session: AgentSession) -> AgentArtifact { + let turns = session.viewModel.messages + let next = Self.key(sessionId: session.id, turns: turns) + guard next != key else { return artifact } + if key?.sessionId != next.sessionId { + payloads = [:] + } + key = next + artifact = project(turns) + let liveRuns = Set(artifact.runs.map(\.id)) + payloads = payloads.filter { liveRuns.contains($0.key) } + return artifact + } + + /// Decoded the first time the column asks for it and then kept: a run's result does not change + /// once it has landed, and a new transcript brings runs with new ids. + internal func payload(for run: AgentQueryRun) -> AgentResultPayload { + if let cached = payloads[run.id] { + return cached + } + let decoded = decode(run.resultJSON) + payloads[run.id] = decoded + return decoded + } + + internal static func key(sessionId: UUID, turns: [ChatTurn]) -> Key { + var toolBlocks: [ToolBlockState] = [] + for turn in turns { + for block in turn.blocks { + switch block.kind { + case .toolUse(let use): + toolBlocks.append(.call(blockId: block.id, approval: use.approvalState)) + case .toolResult: + toolBlocks.append(.result(blockId: block.id)) + case .text, .attachment, .reasoning, .image, .sqlWalkthrough: + continue + } + } + } + return Key(sessionId: sessionId, turnCount: turns.count, toolBlocks: toolBlocks) + } +} diff --git a/TablePro/Models/AI/AgentResultDecoder.swift b/TablePro/Models/AI/AgentResultDecoder.swift index 0d86ffef4..3260bf986 100644 --- a/TablePro/Models/AI/AgentResultDecoder.swift +++ b/TablePro/Models/AI/AgentResultDecoder.swift @@ -6,42 +6,71 @@ import Foundation import TableProPluginKit -/// Turns a tool result back into rows the data grid can draw. +/// What one query the session ran gave back, in the terms the result column answers in. +/// +/// An optional could say rows or nothing, and nothing was three different answers: a statement that +/// returns no result set, such as an approved UPDATE, a reply the grid cannot read, and a query whose +/// result set was empty. The column read all three as "This query returned no rows.", so a write +/// that changed rows looked like a query that had matched none. +internal enum AgentResultPayload { + /// A result set with rows in it. + case rows(TableRows) + /// A result set with no rows, which is an answer rather than a failure. + case noRows + /// A statement that returns no result set, and the rows it changed when the reply says. The bridge + /// always sends the count, so a nil comes only from a tool that answers in the same shape without + /// one. + case completed(rowsAffected: Int?) + /// A reply that is not a result the grid can read, which only a tool answering in prose sends. + case unreadable +} + +/// Turns a tool result back into what the result column draws. /// /// The transcript is the only record of what a session read, and it holds the tool result as the /// JSON text the model was given. Decoding it here rather than keeping a second copy of the rows is /// what lets a restored session's result pane be correct with no replay. /// -/// Decoding once per run and not per body evaluation matters: the transcript is rewritten every -/// 50ms while a reply streams, so a computed property doing this would re-parse every result the -/// conversation has ever produced, twenty times a second. +/// Decoding once per run and not per body evaluation matters: the transcript changes all the while a +/// reply streams, and a computed property doing this would parse the selected run's whole result on +/// every one of those redraws. `AgentArtifactCache` is what keeps to that: it decodes a run the first +/// time the column asks for it and keeps the answer. internal enum AgentResultDecoder { - /// The bridge answers a query with `{"columns": [...], "rows": [[...]]}`, and encodes a cell as - /// a JSON string, a number, a bool or null. Binary arrives base64-encoded as a string, which is - /// what the grid would show for it anyway. - internal static func tableRows(fromResultJSON json: String) -> TableRows? { + /// The bridge answers with `{"columns": [...], "rows": [[...]], "rows_affected": n}`, and encodes + /// a cell as a JSON string, a number, a bool or null. Binary arrives base64-encoded as a string, + /// which is what the grid would show for it anyway. + /// + /// The columns are what tell a write from an empty query. A statement with no result set comes + /// back with none, and a query that matched nothing still names its columns. + internal static func payload(fromResultJSON json: String) -> AgentResultPayload { guard let data = json.data(using: .utf8), let decoded = try? JSONDecoder().decode(JsonValue.self, from: data), case .object(let payload) = decoded, case .array(let columnValues)? = payload["columns"], - case .array(let rowValues)? = payload["rows"], - !columnValues.isEmpty else { return nil } + case .array(let rowValues)? = payload["rows"] else { return .unreadable } + + guard !columnValues.isEmpty else { + guard rowValues.isEmpty else { return .unreadable } + return .completed(rowsAffected: payload["rows_affected"]?.intValue) + } let columns: [String] = columnValues.map { value in guard case .string(let name) = value else { return "" } return name } - let rows: [[PluginCellValue]] = rowValues.compactMap { rowValue in - guard case .array(let cells) = rowValue else { return nil } - return cells.map(cellValue) + var rows: [[PluginCellValue]] = [] + rows.reserveCapacity(rowValues.count) + for rowValue in rowValues { + guard case .array(let cells) = rowValue else { return .unreadable } + rows.append(cells.map(cellValue)) } - guard !rows.isEmpty else { return nil } + guard !rows.isEmpty else { return .noRows } - return TableRows.from( + return .rows(TableRows.from( queryRows: rows, columns: columns, columnTypes: Array(repeating: ColumnType.text(rawType: nil), count: columns.count) - ) + )) } private static func cellValue(_ value: JsonValue) -> PluginCellValue { diff --git a/TablePro/Models/AI/AgentResultSegment.swift b/TablePro/Models/AI/AgentResultSegment.swift new file mode 100644 index 000000000..a424df25e --- /dev/null +++ b/TablePro/Models/AI/AgentResultSegment.swift @@ -0,0 +1,31 @@ +// +// AgentResultSegment.swift +// TablePro +// + +import Foundation + +/// Which of its two views the result column shows. +/// +/// Two, because two are all a transcript can fill: the statements a session proposed, and the rows +/// its queries read. The column used to offer a plan and a schema view beside them, and neither had +/// anything in `AgentArtifact` to draw from, so half the choice was two empty states that could never +/// fill. +internal enum AgentResultSegment: String, CaseIterable, Hashable { + case sql + case results + + internal var title: String { + switch self { + case .sql: String(localized: "SQL") + case .results: String(localized: "Results") + } + } + + internal var symbolName: String { + switch self { + case .sql: "curlybraces" + case .results: "tablecells" + } + } +} diff --git a/TablePro/Models/AI/AgentSession.swift b/TablePro/Models/AI/AgentSession.swift index 4280ea03e..a2b68570b 100644 --- a/TablePro/Models/AI/AgentSession.swift +++ b/TablePro/Models/AI/AgentSession.swift @@ -27,14 +27,40 @@ internal final class AgentSession: ObservableObject, Identifiable { @Published internal private(set) var status: AgentSessionStatus @Published internal private(set) var title: String - /// When the session was first opened, which is what the rail orders by. internal let startedAt: Date - internal private(set) var lastActiveAt: Date + + /// When the session last went to work: the start of the reply it last began, or its own start. + /// The rail lists the latest first. + /// + /// Opening a session is not work, so it does not move one. It used to, and the rail was ordered + /// by start time while it did, so the stamp changed nothing anyone could see; ordered by this, a + /// session double-clicked in the middle of the list would have jumped to the top from under the + /// pointer that opened it. + @Published internal private(set) var lastActiveAt: Date + + /// Which of the result column's views this session is showing. + /// + /// On the session rather than in the column: the column is one hosting controller per window, + /// drawing whichever session is open, so view state there carried one session's choice into the + /// next. Not stored, so a relaunch opens on the statements. + @Published internal var resultSegment: AgentResultSegment = .sql /// Text typed before the session could send it, which a connect long enough to notice a typo in /// needs. Cleared before it is dispatched so three flush sites still send once. internal var pendingPrompt: String? + /// Hands the pending prompt over once the connection is up, and clears it as it goes. + /// + /// The conversation asks from a `task` keyed on the session, the connect and the prompt, and a + /// reparent re-runs every such task on the same view: a mode toggle and a connection switch are + /// both one. Taking rather than reading is what keeps each of those re-runs from sending the + /// prompt a second time. + internal func takePendingPrompt(isConnecting: Bool) -> String? { + guard !isConnecting, let prompt = pendingPrompt else { return nil } + pendingPrompt = nil + return prompt + } + private var cancellables: Set = [] internal init( @@ -61,6 +87,13 @@ internal final class AgentSession: ObservableObject, Identifiable { viewModel.activeConversationID } + /// What the rail, the conversation and the delete confirmation call the session. A session names + /// itself from its first question, so one that has not been asked anything goes by the command + /// that made it. + internal var displayTitle: String { + title.isEmpty ? String(localized: "New Session") : title + } + /// Republishes the engine's changes as the session's own, so a rail row bound to the session /// redraws when the transcript moves. `AIChatViewModel` is an `ObservableObject` of its own and /// nothing else forwards it. @@ -99,17 +132,20 @@ internal final class AgentSession: ObservableObject, Identifiable { } } - /// Status follows the engine while the session is live. A session the user stopped, or one that - /// failed, keeps the state it ended on: the engine underneath it is idle either way, and idle - /// is not the same answer as stopped. /// A stopped session keeps the state it ended on; a failed one does not. /// /// Retry is offered on a failure and moves the engine back through idle, loading and streaming, /// so freezing on `.failed` left the rail reporting Failed for the whole of a successful retry /// and session resolution still treating it as ended. + /// + /// Going to work is what makes a session the latest, so the rail moves it up as a reply starts + /// rather than as someone looks at it. private func refreshDerivedState() { let engineStatus = derivedStatus() if status != .stopped, status != engineStatus { + if engineStatus == .working { + markActive() + } status = engineStatus } let derivedTitle = derivedTitle() @@ -168,10 +204,12 @@ internal final class AgentSession: ObservableObject, Identifiable { /// Puts a stopped session back to work. Nothing is replayed: a statement that was waiting for an /// answer when the window closed was cancelled by the stop, and the model is asked again rather /// than the call being re-issued behind the user's back. + /// + /// Resuming is opening, not working, so the session keeps its place in the rail until it is + /// asked something. internal func resume() { guard status.isEnded else { return } mark(.ready) - markActive() } /// Settles a card that is still waiting before the transcript is written. diff --git a/TablePro/Models/AI/AgentSessionConfirmation.swift b/TablePro/Models/AI/AgentSessionConfirmation.swift new file mode 100644 index 000000000..5fc633665 --- /dev/null +++ b/TablePro/Models/AI/AgentSessionConfirmation.swift @@ -0,0 +1,80 @@ +// +// AgentSessionConfirmation.swift +// TablePro +// + +import Foundation + +/// What a session command asks before it runs. +/// +/// Deleting always asks, because it throws the conversation away. Closing asks only of a session that +/// is busy: an idle one loses nothing by stopping and keeps its conversation in the rail, while one +/// in the middle of a reply, or waiting on an answer about a statement, has that cut off. What the +/// session is doing is what the question says, since it is the part the person may not have seen: +/// the command can come from the menu bar with the session off screen. +/// +/// Each message is one whole sentence pair rather than a busy clause joined to a common one, because +/// a translation cannot be assembled from parts that were joined by a space in English. +internal struct AgentSessionConfirmation: Equatable { + internal let title: String + internal let message: String + internal let confirmButton: String + /// Only deleting destroys anything, so only deleting takes Return off the confirming button. + internal let isDestructive: Bool + + /// Nil for a session that is not busy, which closes without a question. + internal static func close(_ sessionTitle: String, status: AgentSessionStatus) -> AgentSessionConfirmation? { + let message: String + switch Activity(status) { + case .working?: + message = String( + localized: "The session is still working. Closing it stops the reply, and its conversation stays in the list." + ) + case .waitingOnYou?: + message = String( + localized: "The session is waiting on your answer about a statement. Closing it cancels the statement, and its conversation stays in the list." + ) + case nil: + return nil + } + return AgentSessionConfirmation( + title: String(format: String(localized: "Close “%@”?"), sessionTitle), + message: message, + confirmButton: String(localized: "Close Session"), + isDestructive: false + ) + } + + internal static func delete(_ sessionTitle: String, status: AgentSessionStatus) -> AgentSessionConfirmation { + let message: String + switch Activity(status) { + case .working?: + message = String( + localized: "The session is still working. Deleting it stops the reply and deletes its conversation, which can't be restored." + ) + case .waitingOnYou?: + message = String( + localized: "The session is waiting on your answer about a statement. Deleting it cancels the statement and deletes its conversation, which can't be restored." + ) + case nil: + message = String(localized: "The session and its conversation are deleted, and can't be restored.") + } + return AgentSessionConfirmation( + title: String(format: String(localized: "Delete “%@”?"), sessionTitle), + message: message, + confirmButton: String(localized: "Delete"), + isDestructive: true + ) + } + + /// What a busy session is doing, which is what ending it cuts off. + private enum Activity { + case working + case waitingOnYou + + init?(_ status: AgentSessionStatus) { + guard status.isBusy else { return nil } + self = status == .waitingOnYou ? .waitingOnYou : .working + } + } +} diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 699741f87..5a1832747 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -114,6 +114,11 @@ final class ConnectionToolbarState: ObservableObject { /// Whether there are pending changes (data grid or file) @Published var hasPendingChanges: Bool = false + /// What the commit would commit, which is what decides the verb it says. `hasPendingChanges` + /// stays as the answer to "is anything staged" that its thirty-odd readers already ask; this is + /// the same question answered with the kind attached, written by the same one function. + @Published var pendingChange: PendingChangeKind? + /// Whether there are pending data grid changes (for SQL preview button) @Published var hasDataPendingChanges: Bool = false diff --git a/TablePro/Models/Connection/SafeModeFloor.swift b/TablePro/Models/Connection/SafeModeFloor.swift index e01ea27d4..a68045e83 100644 --- a/TablePro/Models/Connection/SafeModeFloor.swift +++ b/TablePro/Models/Connection/SafeModeFloor.swift @@ -62,6 +62,18 @@ internal struct SafeModeFloor: Equatable, Sendable { allows(candidate) ? candidate : level } + /// The reason in a few words, for a line with no room for the sentence: the agent conversation's + /// context strip carries this beside the level's symbol and keeps `explanation` for its tooltip + /// and for VoiceOver. The menu and the toolbar have the room and print the sentence itself. + var summary: String { + switch reason { + case .readOnlyEngine: return String(localized: "Read-only database") + case .remoteDatabaseFile: return String(localized: "Read-only file copy") + case .managedPolicy: return String(localized: "Required by your organization") + case .agentMode: return String(localized: "Writes wait for you") + } + } + var explanation: String { switch reason { case .readOnlyEngine: diff --git a/TablePro/Models/Connection/SafeModeStatus.swift b/TablePro/Models/Connection/SafeModeStatus.swift new file mode 100644 index 000000000..3560271cb --- /dev/null +++ b/TablePro/Models/Connection/SafeModeStatus.swift @@ -0,0 +1,50 @@ +// +// SafeModeStatus.swift +// TablePro +// + +import Foundation + +/// The Safe Mode level a connection is running at, and the floor holding it there. +/// +/// Everything that offers the level reads this one value: the Database menu's list, the toolbar +/// control's list and its tooltip, the validation of each entry, and the write the choice ends in. +/// Two readings used to disagree. The list, its validation and the write's own guard asked the +/// connection's own floor, which cannot see Agent mode, while the level in force came from the +/// floor that can, so a weaker level picked in Agent mode was stored and then held at Alert, and +/// nothing said why. +internal struct SafeModeStatus: Equatable { + internal let level: SafeModeLevel + internal let floor: SafeModeFloor? + + /// The levels on offer: the floor's own and every stricter one. A level below the floor is left + /// out rather than listed dimmed, the way the connection form's picker leaves it out, and the + /// floor's reason is printed under the list instead. + internal var offeredLevels: [SafeModeLevel] { + SafeModeFloor.levels(allowedBy: floor) + } + + internal func offers(_ candidate: SafeModeLevel) -> Bool { + floor?.allows(candidate) ?? true + } + + /// Whether choosing `candidate` is taken as the user's new level. + /// + /// Only a choice that moves the level in force is. One below the floor is refused rather than + /// stored for later: storing it changed nothing on screen, since the floor raised it straight + /// back, and handed a level the user never saw take effect back to them when the floor lifted. + /// Choosing the level already in force is refused too, because under a floor that level can be + /// the floor's rather than the user's, and writing it would replace the one they chose. + internal func accepts(_ candidate: SafeModeLevel) -> Bool { + candidate != level && offers(candidate) + } + + /// What the toolbar control says it is set to, and when something holds it there, why. The + /// glyph alone cannot carry either: `lock` and `lock.open` differ by a few pixels, and VoiceOver + /// reads no image at all. + internal var toolTip: String { + let current = String(format: String(localized: "Safe Mode: %@"), level.displayName) + guard let floor else { return current } + return current + "\n" + floor.explanation + } +} diff --git a/TablePro/Models/UI/AgentSessionRailState.swift b/TablePro/Models/UI/AgentSessionRailState.swift new file mode 100644 index 000000000..d7b8b0f20 --- /dev/null +++ b/TablePro/Models/UI/AgentSessionRailState.swift @@ -0,0 +1,19 @@ +// +// AgentSessionRailState.swift +// TablePro +// + +import Combine +import Foundation + +/// Which row of a window's session rail is highlighted. +/// +/// A highlighted row is not an open session. The rail moves its highlight on a click and on every +/// arrow key, and opening is a command of its own, so the two are held apart. The window reads the +/// highlight as well as the rail: a session command that names no session acts on the highlighted +/// one, the way a list command acts on the list's selection, and a highlight kept in the rail's own +/// `@State` was out of reach of every command that did not start in the rail. +@MainActor +internal final class AgentSessionRailState: ObservableObject { + @Published internal var highlightedSessionId: UUID? +} diff --git a/TablePro/Models/UI/InspectorSubject.swift b/TablePro/Models/UI/InspectorSubject.swift index 8a883039a..cb1df8b97 100644 --- a/TablePro/Models/UI/InspectorSubject.swift +++ b/TablePro/Models/UI/InspectorSubject.swift @@ -5,11 +5,11 @@ import Foundation -/// What the inspector is currently inspecting, and the two lines its header draws for it. +/// What the inspector is currently inspecting, and the two lines it draws for it above its content. /// /// The pane had no subject at all before: it multiplexed three unrelated tabs, so there was nothing /// one title could name and the header carried a picker instead. Naming the subject is what lets -/// the header say which row of which table is on screen, which is the first thing a reader of an +/// the inspector say which row of which table is on screen, which is the first thing a reader of an /// inspector needs and the thing the old pane never showed. /// /// A schema grid is a first-class case rather than an afterthought. The structure and create-table diff --git a/TablePro/Models/UI/InspectorViewMode.swift b/TablePro/Models/UI/InspectorViewMode.swift index 7c0706537..da538aa43 100644 --- a/TablePro/Models/UI/InspectorViewMode.swift +++ b/TablePro/Models/UI/InspectorViewMode.swift @@ -7,10 +7,11 @@ import Foundation /// Which rendering of the selected row the inspector is showing. /// -/// Both modes describe the same object, which is what makes a segmented control the right -/// affordance for them: the HIG's inspector guidance covers switching between views of the current -/// selection, and fields and JSON are two views of one row. The assistant used to sit beside them -/// as a third segment, which is what made the pane untitleable, and it is now its own surface. +/// Both modes describe the same object, which is what makes them one exclusive choice rather than +/// two commands: the HIG's inspector guidance covers switching between views of the current +/// selection, and fields and JSON are two views of one row. The pane header's menu offers them as a +/// checked pair. The assistant used to sit beside them as a third segment, which is what made the +/// pane untitleable, and it is now its own surface. internal enum InspectorViewMode: String, CaseIterable, Hashable { case fields case json diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index d9c2b450a..6ad73391a 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -105,6 +105,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case clearSelection case addRow case duplicateRow + case restorePreviousValues case truncateTable case toggleHeaderRow case previewFKReference @@ -129,9 +130,16 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case reopenClosedTab case quickSwitcher case toggleTableBrowser + case showTablesList + case showFavoritesList case toggleInspector case toggleAssistant case toggleAgentMode + case newAgentSession + case openAgentSession + case closeAgentSession + case deleteAgentSession + case newAIConversation case toggleFilters case toggleHistory case toggleResults @@ -166,13 +174,16 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { return .editor case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, + .restorePreviousValues, .truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage, .nextPage, .firstPage, .lastPage, .refresh, .export, .importData, .jumpToColumn: return .dataGrid case .navigateBack, .navigateForward, .newTab, .closeTab, .closeOtherTabs, .closeTabsForOtherDatabases, .closeAllTabs, .reopenClosedTab, .quickSwitcher, .toggleTableBrowser, + .showTablesList, .showFavoritesList, .toggleInspector, .toggleAssistant, .toggleAgentMode, .toggleFilters, .toggleHistory, .toggleResults, + .newAgentSession, .openAgentSession, .closeAgentSession, .deleteAgentSession, .newAIConversation, .previousResultTab, .nextResultTab, .pinResultTab, .closeResultTab, .focusSidebarSearch, .focusObjectList, .focusEditor, .focusResults, .focusInspector, .focusAssistant, @@ -196,7 +207,11 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { return .editor case .previousPage, .nextPage, .firstPage, .lastPage, .addRow, .duplicateRow, .delete, .truncateTable, .previewFKReference, .saveAsFavorite, - .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters, .jumpToColumn: + .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters, .jumpToColumn, + /// Named rather than left to the `default:` below. It reverses a row the grid is + /// showing, so it belongs in the grid's context the way Add Row and Delete do, and + /// inheriting `.global` would let the recorder call a grid combo free for it. + .restorePreviousValues: return .dataGrid default: return .global @@ -270,14 +285,22 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .clearSelection: return String(localized: "Clear Selection") case .addRow: return String(localized: "Add Row") case .duplicateRow: return String(localized: "Duplicate Row") + case .restorePreviousValues: return String(localized: "Restore Previous Values") case .truncateTable: return String(localized: "Truncate Table") case .toggleHeaderRow: return String(localized: "Switch First Row Between Header/Data") case .previewFKReference: return String(localized: "Preview FK Reference") case .saveAsFavorite: return String(localized: "Save as Favorite") case .toggleTableBrowser: return String(localized: "Toggle Table Browser") + case .showTablesList: return String(localized: "Show Tables") + case .showFavoritesList: return String(localized: "Show Favorites") case .toggleInspector: return String(localized: "Toggle Inspector") case .toggleAssistant: return String(localized: "Toggle Assistant") case .toggleAgentMode: return String(localized: "Toggle Agent Mode") + case .newAgentSession: return String(localized: "New Session") + case .openAgentSession: return String(localized: "Open Session") + case .closeAgentSession: return String(localized: "Close Session") + case .deleteAgentSession: return String(localized: "Delete Session") + case .newAIConversation: return String(localized: "New Conversation") case .toggleFilters: return String(localized: "Toggle Filters") case .toggleHistory: return String(localized: "Toggle History") case .toggleResults: return String(localized: "Toggle Results") diff --git a/TablePro/Models/UI/PendingChangeKind.swift b/TablePro/Models/UI/PendingChangeKind.swift new file mode 100644 index 000000000..23f2bf68f --- /dev/null +++ b/TablePro/Models/UI/PendingChangeKind.swift @@ -0,0 +1,69 @@ +// +// PendingChangeKind.swift +// TablePro +// + +import Foundation + +/// What the window's Save command would commit, which is also whether it can commit at all. +/// +/// One value rather than five booleans read in five places. `updateToolbarPendingState()` folded +/// four of the five sources into `hasPendingChanges` and never read the fifth, so a Users & Roles +/// tab with staged principals left both the toolbar's commit button and Cmd+S dim while +/// `saveChanges()` already carried the branch that would have applied them. +/// +/// The tab decides the kind, because two kinds can be staged at once and only one of them is the +/// one the user is looking at. It decides nothing the user reads: the commit control's label is +/// `ToolbarContextResolver.commitVerb(for:)`, from the tab kind alone, because this value comes and +/// goes with every edit and a label that followed it moved the titlebar while the user typed. +internal enum PendingChangeKind: Equatable, Hashable, Sendable { + case data + case structure + case createTable + case principals + case file + + /// Nil when nothing is staged for this tab, which is what leaves the commit control dim. + internal static func resolve( + tabType: TabType?, + hasDataChanges: Bool, + hasStructureChanges: Bool, + hasCreateTablePending: Bool, + hasPrincipalChanges: Bool, + isFileDirty: Bool + ) -> PendingChangeKind? { + guard let tabType else { + return contentKind( + hasDataChanges: hasDataChanges, + hasStructureChanges: hasStructureChanges, + isFileDirty: isFileDirty + ) + } + switch tabType { + case .createTable: + /// A definition that is not yet committable is not a pending change: the tab's own + /// validity gate is what `hasCreateTablePending` already answers. + return hasCreateTablePending ? .createTable : nil + case .usersRoles: + return hasPrincipalChanges ? .principals : nil + case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource: + return contentKind( + hasDataChanges: hasDataChanges, + hasStructureChanges: hasStructureChanges, + isFileDirty: isFileDirty + ) + } + } + + /// Structure outranks data, and data outranks a dirty file, because a structure edit rewrites + /// the table the staged rows are going into and has to be named first. + private static func contentKind( + hasDataChanges: Bool, + hasStructureChanges: Bool, + isFileDirty: Bool + ) -> PendingChangeKind? { + if hasStructureChanges { return .structure } + if hasDataChanges { return .data } + return isFileDirty ? .file : nil + } +} diff --git a/TablePro/Models/UI/RowInspectorState.swift b/TablePro/Models/UI/RowInspectorState.swift index bbfb3da93..8a77e45e6 100644 --- a/TablePro/Models/UI/RowInspectorState.swift +++ b/TablePro/Models/UI/RowInspectorState.swift @@ -40,7 +40,7 @@ internal final class RowInspectorState: ObservableObject { /// the reader's expansions and the rows already fetched for them. internal let jsonViewModel = JSONRowInspectorViewModel() - internal init(connectionId: UUID? = nil, defaults: UserDefaults = .standard) { + internal init(connectionId: UUID? = nil, defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { self.connectionId = connectionId self.defaults = defaults if let connectionId, diff --git a/TablePro/Models/UI/ToolbarContext.swift b/TablePro/Models/UI/ToolbarContext.swift new file mode 100644 index 000000000..746b3d825 --- /dev/null +++ b/TablePro/Models/UI/ToolbarContext.swift @@ -0,0 +1,169 @@ +// +// ToolbarContext.swift +// TablePro +// + +import Foundation + +/// Everything the connection window's toolbar is allowed to know about what the window is showing. +/// +/// The toolbar used to carry one tab-shaped fact, `isTableTab`, and no content mode at all, so a +/// single list of identifiers was vended for every tab kind, every pane and both content modes and +/// the only lever anyone had was dimming. Every feature that arrived then had to buy a permanent +/// slot in the titlebar. +/// +/// Nothing global is read inside this struct or inside the resolvers that take it. The toolbar +/// builds it once per validation pass and once per Actions menu open and passes it down, so the +/// resolvers stay pure and testable with no host app and no session. +/// +/// Every field is one a resolver reads. A fact nothing reads is a fact every pass pays to look up. +internal struct ToolbarContext: Equatable { + /// What the window's detail pane is drawing. `nil` when no tab is selected, which is a real + /// state on a window that has just opened. + internal let tabKind: TabType? + internal let resultsMode: ResultsViewMode? + internal let contentMode: ConnectionWorkspaceContentMode + internal let pane: ConnectionWindowPane + + /// True whenever the session is alive, which includes a query in flight. A running query is not + /// a reason to disable Refresh, and the menu bar derives its own answer from the window phase + /// rather than from execution. + internal let isConnected: Bool + /// A connection is on screen, whether or not it has finished connecting. + internal let hasSelectedWorkspace: Bool + internal let canToggleTrailingPane: Bool + + internal let pendingChange: PendingChangeKind? + /// Not a projection of `pendingChange`. The two are computed from different inputs: a dirty + /// query file raises the commit control and leaves this false, which is exactly the distinction + /// Preview SQL is gated on. + internal let hasDataPendingChanges: Bool + internal let blocksAllWrites: Bool + + internal let canAddRow: Bool + internal let canRestorePreviousValues: Bool + internal let canNavigateBack: Bool + internal let canNavigateForward: Bool + + internal let isFileBased: Bool + internal let supportsContainerSwitching: Bool + internal let supportsImport: Bool + internal let supportsServerDashboard: Bool + + internal let isAIEnabled: Bool + + /// The subset of the context that may move an item in or out of the titlebar, or change what + /// one says. + /// + /// This is the whole anti-reflow rule in one type. `isHidden` and the commit control's label are + /// written only from these fields, so the titlebar can change shape on a tab switch, a mode + /// switch or a connection switch and on nothing else; everything transient rides `isEnabled` + /// instead. The toolbar computes this from its eight inputs directly rather than from a whole + /// context, so a keystroke costs the selected-tab lookup, four locked reads of the plugin + /// metadata registry, a switch over the engine for the dashboard and one comparison, and writes + /// nothing. + internal struct VisibilityKey: Equatable { + internal let tabKind: TabType? + internal let resultsMode: ResultsViewMode? + internal let contentMode: ConnectionWorkspaceContentMode + internal let isFileBased: Bool + internal let supportsContainerSwitching: Bool + internal let supportsImport: Bool + internal let supportsServerDashboard: Bool + internal let isAIEnabled: Bool + } + + internal var visibilityKey: VisibilityKey { + VisibilityKey( + tabKind: tabKind, + resultsMode: resultsMode, + contentMode: contentMode, + isFileBased: isFileBased, + supportsContainerSwitching: supportsContainerSwitching, + supportsImport: supportsImport, + supportsServerDashboard: supportsServerDashboard, + isAIEnabled: isAIEnabled + ) + } + + internal init( + tabKind: TabType? = nil, + resultsMode: ResultsViewMode? = nil, + contentMode: ConnectionWorkspaceContentMode = .browse, + pane: ConnectionWindowPane = .empty, + isConnected: Bool = false, + hasSelectedWorkspace: Bool = false, + canToggleTrailingPane: Bool = false, + pendingChange: PendingChangeKind? = nil, + hasDataPendingChanges: Bool = false, + blocksAllWrites: Bool = false, + canAddRow: Bool = false, + canRestorePreviousValues: Bool = false, + canNavigateBack: Bool = false, + canNavigateForward: Bool = false, + isFileBased: Bool = false, + supportsContainerSwitching: Bool = false, + supportsImport: Bool = false, + supportsServerDashboard: Bool = false, + isAIEnabled: Bool = false + ) { + self.tabKind = tabKind + self.resultsMode = resultsMode + self.contentMode = contentMode + self.pane = pane + self.isConnected = isConnected + self.hasSelectedWorkspace = hasSelectedWorkspace + self.canToggleTrailingPane = canToggleTrailingPane + self.pendingChange = pendingChange + self.hasDataPendingChanges = hasDataPendingChanges + self.blocksAllWrites = blocksAllWrites + self.canAddRow = canAddRow + self.canRestorePreviousValues = canRestorePreviousValues + self.canNavigateBack = canNavigateBack + self.canNavigateForward = canNavigateForward + self.isFileBased = isFileBased + self.supportsContainerSwitching = supportsContainerSwitching + self.supportsImport = supportsImport + self.supportsServerDashboard = supportsServerDashboard + self.isAIEnabled = isAIEnabled + } + + /// The whole context over a key the caller already computed, so the eight slow-moving facts + /// are read once per context rather than once for the key and again for the context. + internal init( + key: VisibilityKey, + pane: ConnectionWindowPane, + isConnected: Bool, + hasSelectedWorkspace: Bool, + canToggleTrailingPane: Bool, + pendingChange: PendingChangeKind?, + hasDataPendingChanges: Bool, + blocksAllWrites: Bool, + canAddRow: Bool, + canRestorePreviousValues: Bool, + canNavigateBack: Bool, + canNavigateForward: Bool + ) { + self.init( + tabKind: key.tabKind, + resultsMode: key.resultsMode, + contentMode: key.contentMode, + pane: pane, + isConnected: isConnected, + hasSelectedWorkspace: hasSelectedWorkspace, + canToggleTrailingPane: canToggleTrailingPane, + pendingChange: pendingChange, + hasDataPendingChanges: hasDataPendingChanges, + blocksAllWrites: blocksAllWrites, + canAddRow: canAddRow, + canRestorePreviousValues: canRestorePreviousValues, + canNavigateBack: canNavigateBack, + canNavigateForward: canNavigateForward, + isFileBased: key.isFileBased, + supportsContainerSwitching: key.supportsContainerSwitching, + supportsImport: key.supportsImport, + supportsServerDashboard: key.supportsServerDashboard, + isAIEnabled: key.isAIEnabled + ) + } +} diff --git a/TablePro/Models/UI/TrailingPaneCommandResolver.swift b/TablePro/Models/UI/TrailingPaneCommandResolver.swift new file mode 100644 index 000000000..6f48b44c3 --- /dev/null +++ b/TablePro/Models/UI/TrailingPaneCommandResolver.swift @@ -0,0 +1,187 @@ +// +// TrailingPaneCommandResolver.swift +// TablePro +// + +import Foundation + +/// What the window's trailing-pane commands say, do and allow, decided from the surface the pane is +/// actually drawing. +/// +/// The commands used to read the stored surface with no content-mode term. In Agent mode, where the +/// pane draws the session's result whatever was stored, Show Inspector titled itself Hide Inspector +/// over the result column and collapsed it with no command able to bring it back, and Show Assistant +/// wrote the assistant into the connection's browse preference and changed nothing on screen. Every +/// answer here goes through `TrailingPaneSurfaceResolver` instead, and the View menu, the toolbar and +/// the focus commands all read the same value. +internal enum TrailingPaneCommandResolver { + /// Everything the commands decide from, read once per question. + internal struct Context: Equatable { + internal let contentMode: ConnectionWorkspaceContentMode + internal let storedSurface: TrailingPaneSurface + internal let isPaneOpen: Bool + internal let isAIEnabled: Bool + /// Whether the window has a connection's content behind it. Opening a surface needs one; + /// closing a pane the user left open does not, or a connection that drops with the pane open + /// leaves an empty column with no command to close it. + internal let hasContent: Bool + + internal init( + contentMode: ConnectionWorkspaceContentMode, + storedSurface: TrailingPaneSurface, + isPaneOpen: Bool, + isAIEnabled: Bool, + hasContent: Bool + ) { + self.contentMode = contentMode + self.storedSurface = storedSurface + self.isPaneOpen = isPaneOpen + self.isAIEnabled = isAIEnabled + self.hasContent = hasContent + } + + /// The mode the window draws, which is browsing whenever the AI feature is off. + internal var resolvedMode: ConnectionWorkspaceContentMode { + ConnectionWorkspaceContentMode.resolved(contentMode, isAIEnabled: isAIEnabled) + } + + internal var drawnSurface: TrailingPaneSurface { + TrailingPaneSurfaceResolver.resolve( + stored: storedSurface, + contentMode: contentMode, + isAIEnabled: isAIEnabled + ) + } + + /// A collapsed pane shows nothing, whatever it would draw once revealed. + internal func isShowing(_ surface: TrailingPaneSurface) -> Bool { + isPaneOpen && drawnSurface == surface + } + } + + internal enum Effect: Equatable { + /// Put the pane on screen, drawing this surface. + case reveal(TrailingPaneSurface) + case hide + } + + /// What asking for a surface does to the pane and to the connection's stored preference. + internal struct Reveal: Equatable { + internal let opensPane: Bool + internal let storesChoice: Bool + } + + /// Where a focus command sends the keyboard. + internal enum FocusTarget: Equatable { + /// The trailing pane, revealed on this surface first. + case trailingPane(TrailingPaneSurface) + /// The window's content column, which is where Agent mode draws the conversation. + case conversation + } + + // MARK: - Asking for a Surface + + /// The pane opens only on a surface the mode and the settings let it draw, or it would open on + /// something the user did not ask for: the assistant's commands reach the window from Agent mode, + /// where the conversation they seed is the content column and the pane beside it is the result. + /// + /// Only a choice is stored: a surface the user may pick, asked for while browsing. Agent mode + /// imposes the result and the next read overrides whatever is stored, so a write there changed + /// the connection's browse preference and nothing on screen. + internal static func reveal(_ surface: TrailingPaneSurface, _ context: Context) -> Reveal { + let isDrawn = TrailingPaneSurfaceResolver.draws( + surface, + contentMode: context.contentMode, + isAIEnabled: context.isAIEnabled + ) + return Reveal( + opensPane: isDrawn, + storesChoice: isDrawn && surface.isUserSelectable && context.resolvedMode == .browse + ) + } + + /// Whether a grid click with auto-show on opens the pane. It never stores anything: a click is + /// a suggestion, and Xcode's line between the two applies, where a surface the user picked is + /// remembered and one the app offered is not. + /// + /// It reads the stored surface rather than whether the assistant is on screen, which is false + /// whenever the pane is collapsed. The first click after closing a pane left on the assistant + /// used to open it on the inspector and persist the inspector over the user's choice. + internal static func revealsForSelection(_ context: Context) -> Bool { + context.drawnSurface == .inspector && !context.isPaneOpen + } + + // MARK: - The Pane Toggle + + /// ⌥⌘I, View > Show Inspector and the toolbar's trailing item all send `toggleInspector:`, and + /// that item is AppKit's own, so this is the window's one trailing-pane toggle rather than the + /// inspector's alone. In Agent mode the column it opens and closes is the result, and the title + /// names that column rather than one the window is not drawing. + internal static func paneToggleTitle(_ context: Context) -> String { + switch context.resolvedMode { + case .agent: + return context.isPaneOpen ? String(localized: "Hide Result") : String(localized: "Show Result") + case .browse: + return context.isShowing(.inspector) + ? String(localized: "Hide Inspector") + : String(localized: "Show Inspector") + } + } + + /// Over the other surface it swaps rather than closes, which is what makes two commands over one + /// pane read the way two commands over two panes would. + internal static func paneToggle(_ context: Context) -> Effect { + switch context.resolvedMode { + case .agent: + return context.isPaneOpen ? .hide : .reveal(.agentResult) + case .browse: + return context.isShowing(.inspector) ? .hide : .reveal(.inspector) + } + } + + internal static func canTogglePane(_ context: Context) -> Bool { + context.hasContent || context.isPaneOpen + } + + // MARK: - The Assistant + + internal static func assistantToggleTitle(_ context: Context) -> String { + context.isShowing(.assistant) ? String(localized: "Hide Assistant") : String(localized: "Show Assistant") + } + + /// Nil where the command does not apply, and Agent mode is one of those places: it is dimmed + /// there rather than turned into something else. The mode draws the conversation as the window's + /// content column, which no command hides, and imposes the result on the pane, so there is no + /// assistant surface to show or to hide. Focusing the conversation instead would give one chord two + /// unrelated meanings that one title cannot describe, and Focus Assistant already reaches it. + internal static func assistantToggle(_ context: Context) -> Effect? { + guard context.resolvedMode == .browse else { return nil } + if context.isShowing(.assistant) { return .hide } + guard context.isAIEnabled, context.hasContent else { return nil } + return .reveal(.assistant) + } + + internal static func canToggleAssistant(_ context: Context) -> Bool { + assistantToggle(context) != nil + } + + // MARK: - Focus + + /// Agent mode draws no inspector, so the command has nothing to focus there. Revealing one would + /// have meant writing a browse preference the mode overrides on the next read. + internal static func inspectorFocus(_ context: Context) -> FocusTarget? { + guard context.resolvedMode == .browse, canTogglePane(context) else { return nil } + return .trailingPane(.inspector) + } + + /// The assistant is one conversation shown two ways, so its focus command follows it: into the + /// trailing pane while browsing, and into the content column in Agent mode. + internal static func assistantFocus(_ context: Context) -> FocusTarget? { + switch context.resolvedMode { + case .agent: + return .conversation + case .browse: + return canToggleAssistant(context) ? .trailingPane(.assistant) : nil + } + } +} diff --git a/TablePro/Models/UI/TrailingPaneHeaderModel.swift b/TablePro/Models/UI/TrailingPaneHeaderModel.swift new file mode 100644 index 000000000..b3bfdcae1 --- /dev/null +++ b/TablePro/Models/UI/TrailingPaneHeaderModel.swift @@ -0,0 +1,95 @@ +// +// TrailingPaneHeaderModel.swift +// TablePro +// + +import Foundation + +/// The groups of commands a surface's header menu carries, in the order they are drawn, with a +/// separator between each. +internal enum TrailingPaneMenuSection: Hashable { + /// Fields or JSON, the two renderings of the selected row. + case inspectorRendering + /// Copy Visible, the two expansion commands and Always Expand Foreign Keys. They act on the JSON + /// rendering alone, so they are offered only while it is the one on screen. + case jsonReading + /// New Conversation and the conversation history. + case conversations + /// Clear Recents, kept apart from the rest because it deletes. + case clearRecents + /// Which of its views the result column shows. + case resultView +} + +/// What the trailing pane's header draws above one surface. +/// +/// Each surface draws the header itself, at its own top, rather than one container drawing it over +/// all three: `WorkspacePanes` parents exactly one surface's hosting controller into the split item at +/// a time, and a header outside all three would need a container controller that nothing creates. What +/// keeps the three in step is that they draw it from this one value. The hand-drawn headers it +/// replaced were a title over a subtitle beside a picker, a headline beside two 24pt buttons, and an +/// icon-only picker that was the whole top of the pane, so the pane's top edge changed shape every +/// time the surface did. +internal struct TrailingPaneHeaderModel: Equatable { + internal let surface: TrailingPaneSurface + internal let segments: [TrailingPaneSurface] + internal let menuSections: [TrailingPaneMenuSection] + + /// `hasContent` is false over a connection that is not up. Every command in the menu acts on a + /// row, a conversation or a session the window does not have then, so the menu is not drawn. + /// + /// `inspectorRendering` is the rendering the inspector draws when its selection can be drawn + /// both ways, and nil when it cannot: no row, a table's info, or a schema grid's column + /// definition, which has no JSON form. The choice is left out then rather than dimmed, because a + /// dimmed picker still checks one of its items, and it checked the stored rendering over a pane + /// drawing the other one or neither. + internal init( + surface: TrailingPaneSurface, + contentMode: ConnectionWorkspaceContentMode, + isAIEnabled: Bool, + inspectorRendering: InspectorViewMode? = nil, + hasContent: Bool = true + ) { + self.surface = surface + self.segments = TrailingPaneSurfaceResolver.selectable(contentMode: contentMode, isAIEnabled: isAIEnabled) + self.menuSections = hasContent ? Self.sections(for: surface, inspectorRendering: inspectorRendering) : [] + } + + /// A picker needs two segments, one of them the surface it sits over. Agent mode offers none and + /// the AI setting being off leaves one, and a single segment is a control with nothing to choose, + /// so both draw the surface's name instead. + internal var showsPicker: Bool { + segments.count > 1 && segments.contains(surface) + } + + internal var title: String { + surface.localizedTitle + } + + /// The ellipsis carries no text, so this is both its accessibility name and its tooltip. + internal var menuLabel: String { + switch surface { + case .inspector: String(localized: "Inspector Options") + case .assistant: String(localized: "Assistant Options") + case .agentResult: String(localized: "Result Options") + } + } + + private static func sections( + for surface: TrailingPaneSurface, + inspectorRendering: InspectorViewMode? + ) -> [TrailingPaneMenuSection] { + switch surface { + case .inspector: + switch inspectorRendering { + case .json?: [.inspectorRendering, .jsonReading] + case .fields?: [.inspectorRendering] + case nil: [] + } + case .assistant: + [.conversations, .clearRecents] + case .agentResult: + [.resultView] + } + } +} diff --git a/TablePro/Models/UI/TrailingPaneState.swift b/TablePro/Models/UI/TrailingPaneState.swift index f514889df..813f1ba1a 100644 --- a/TablePro/Models/UI/TrailingPaneState.swift +++ b/TablePro/Models/UI/TrailingPaneState.swift @@ -31,9 +31,13 @@ internal final class TrailingPaneState: ObservableObject { internal let inspector: RowInspectorState internal let assistant: AssistantState + /// `AppStorageEnvironment.shared.defaults` rather than `.standard`, which is what every other + /// per-connection preference already resolves through. Both of this object's keys escaped the + /// UI-test sandbox, so a test run read and wrote the surface and inspector mode of whoever was + /// running it. internal init( connectionId: UUID? = nil, - defaults: UserDefaults = .standard, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults, sessionRegistry: AgentSessionRegistry = .shared ) { self.connectionId = connectionId diff --git a/TablePro/Models/UI/TrailingPaneSurface.swift b/TablePro/Models/UI/TrailingPaneSurface.swift index 57bf97c3b..3cfbe91dc 100644 --- a/TablePro/Models/UI/TrailingPaneSurface.swift +++ b/TablePro/Models/UI/TrailingPaneSurface.swift @@ -9,8 +9,10 @@ import Foundation /// /// The inspector and the assistant are peers, not facets of one another: an inspector shows the /// attributes of the current selection, and a chat is a separate task surface that no selection -/// owns. They therefore get one command each rather than two segments of one control, and the -/// pane's content follows whichever command was used last. +/// owns. Each keeps a command of its own, and the pane's header offers the two as the segments of +/// one picker. That picker chooses what the pane holds, the way Xcode's inspector bar does, rather +/// than rendering the selection a second way, which is what the inspector's old three-way control +/// conflated. The pane's content follows whichever of them the user chose last. /// /// Both share one `NSSplitViewItem` and so one autosaved width. Per-surface minimum thicknesses /// were measured and rejected: raising `minimumThickness` on a live item force-grows the pane past @@ -32,6 +34,16 @@ internal enum TrailingPaneSurface: String, CaseIterable, Hashable { } } + /// The glyph the header's picker names a surface by. Not `sidebar.right`, which every surface + /// used to share: that names the pane, the one thing all three have in common. + internal var symbolName: String { + switch self { + case .inspector: "info.circle" + case .assistant: "sparkles" + case .agentResult: "checklist" + } + } + /// Whether the user may choose this surface for themselves. The result pane belongs to a mode /// rather than to a command, so it never lands in the stored per-connection preference. internal var isUserSelectable: Bool { diff --git a/TablePro/Models/UI/TrailingPaneSurfaceResolver.swift b/TablePro/Models/UI/TrailingPaneSurfaceResolver.swift new file mode 100644 index 000000000..00eae0a6a --- /dev/null +++ b/TablePro/Models/UI/TrailingPaneSurfaceResolver.swift @@ -0,0 +1,58 @@ +// +// TrailingPaneSurfaceResolver.swift +// TablePro +// + +import Foundation + +/// Which surface the window's trailing pane is drawing, and which surfaces the user may pick. +/// +/// The single answer, because four readings of the same question disagreed and only one of them +/// knew Agent mode imposes the result pane. Show Inspector therefore titled itself Hide Inspector +/// over a column the inspector does not own, collapsed it, and left no command that could bring it +/// back; Show Assistant persisted a browse preference the mode overrode on the next read. +internal enum TrailingPaneSurfaceResolver { + /// The content mode is resolved first, so a window left in Agent mode with the AI setting off + /// cannot ask for a surface the pane will never draw. + internal static func resolve( + stored: TrailingPaneSurface, + contentMode: ConnectionWorkspaceContentMode, + isAIEnabled: Bool + ) -> TrailingPaneSurface { + switch ConnectionWorkspaceContentMode.resolved(contentMode, isAIEnabled: isAIEnabled) { + case .agent: + return .agentResult + case .browse: + /// The result pane belongs to Agent mode, so browsing never draws it however the stored + /// value got there. `TrailingPaneSurface.resolved` passes it through, because its own + /// job is the AI setting rather than the mode. + guard stored.isUserSelectable else { return .inspector } + return TrailingPaneSurface.resolved(stored, isAIEnabled: isAIEnabled) + } + } + + /// Whether asking for this surface would put it on screen. A surface the mode or the settings + /// rule out resolves to a different one, and a command that revealed the pane for it anyway would + /// open a column showing something the user did not ask for. + internal static func draws( + _ surface: TrailingPaneSurface, + contentMode: ConnectionWorkspaceContentMode, + isAIEnabled: Bool + ) -> Bool { + resolve(stored: surface, contentMode: contentMode, isAIEnabled: isAIEnabled) == surface + } + + /// What the pane's header offers. Empty in Agent mode, where the mode chose for the user, which + /// is what makes the header draw a plain title rather than a picker with one segment in it. + internal static func selectable( + contentMode: ConnectionWorkspaceContentMode, + isAIEnabled: Bool + ) -> [TrailingPaneSurface] { + switch ConnectionWorkspaceContentMode.resolved(contentMode, isAIEnabled: isAIEnabled) { + case .agent: + return [] + case .browse: + return isAIEnabled ? [.inspector, .assistant] : [.inspector] + } + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 57ca5456c..655f3d2ec 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3831,9 +3831,6 @@ } } } - }, - "%@ through this connection's transport" : { - }, "%@ wants to access '%@' (%@)." : { "localizations" : { @@ -12745,6 +12742,9 @@ }, "A view" : { + }, + "Actions" : { + }, "about %@ row" : { @@ -35943,6 +35943,9 @@ } } } + }, + "Commands for the current tab and connection" : { + }, "Comment Unavailable" : { @@ -39867,6 +39870,7 @@ } }, "Conversation history" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -39901,7 +39905,6 @@ } }, "Conversation History" : { - "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -63183,6 +63186,7 @@ } }, "Export & Import" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -76438,6 +76442,9 @@ } } } + }, + "Import Data From" : { + }, "Import data" : { "extractionState" : "stale", @@ -81792,9 +81799,6 @@ } } } - }, - "JSON view options" : { - }, "JSON Viewer" : { "localizations" : { @@ -122087,9 +122091,6 @@ }, "Received" : { - }, - "Receiving %@" : { - }, "Recent" : { "localizations" : { @@ -122744,6 +122745,9 @@ } } } + }, + "Redis Cluster serves database 0 only, so it cannot switch databases." : { + }, "Redis driver has no TLS fallback. Preferred and Required both force TLS. Use Required for Redis Cloud, Upstash, and AWS ElastiCache encrypted endpoints." : { "localizations" : { @@ -137969,9 +137973,6 @@ } } } - }, - "Sending %@" : { - }, "Sent" : { @@ -152504,6 +152505,7 @@ } }, "Table Actions" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -166923,9 +166925,6 @@ } } } - }, - "Throughput" : { - }, "Throughput is measured for SSH tunnels and SOCKS proxies, the transports TablePro carries the bytes for itself." : { @@ -171964,6 +171963,40 @@ } } }, + "Unknown explain variant '%@'." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알 수 없는 EXPLAIN 변형 '%@'입니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilinmeyen açıklama varyantı '%@'." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Biến thể EXPLAIN không xác định '%@'." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知的 EXPLAIN 变体 '%@'。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知的 EXPLAIN 變體 '%@'。" + } + } + } + }, "Unlicensed" : { "localizations" : { "ko" : { @@ -182548,6 +182581,144 @@ }, "The '%1$@' variant does not run the statement. Leave 'analyze' off, or pass one that does: %2$@." : { + }, + "Hide Result" : { + + }, + "Show Result" : { + + }, + "Inspector Options" : { + + }, + "Assistant Options" : { + + }, + "Result Options" : { + + }, + "Pane" : { + + }, + "Result view" : { + + }, + "No Session Open" : { + + }, + "What a session proposes and runs appears here" : { + + }, + "What the session runs appears once the connection is up" : { + + }, + "Sessions" : { + + }, + "Open Session" : { + + }, + "Close Session" : { + + }, + "Delete Session…" : { + + }, + "Delete Session" : { + + }, + "New Session" : { + + }, + "No Sessions Yet" : { + + }, + "Start one to ask about this connection." : { + + }, + "%@, open in this window" : { + + }, + "Close “%@”?" : { + + }, + "Delete “%@”?" : { + + }, + "The session is still working. Closing it stops the reply, and its conversation stays in the list." : { + + }, + "The session is waiting on your answer about a statement. Closing it cancels the statement, and its conversation stays in the list." : { + + }, + "The session is still working. Deleting it stops the reply and deletes its conversation, which can't be restored." : { + + }, + "The session is waiting on your answer about a statement. Deleting it cancels the statement and deletes its conversation, which can't be restored." : { + + }, + "The session and its conversation are deleted, and can't be restored." : { + + }, + "Read-only database" : { + + }, + "Read-only file copy" : { + + }, + "Required by your organization" : { + + }, + "Writes wait for you" : { + + }, + "No Results Yet" : { + + }, + "Rows the session reads appear here." : { + + }, + "No Rows" : { + + }, + "The query returned no rows." : { + + }, + "Statement Completed" : { + + }, + "The statement returned no rows to show." : { + + }, + "No rows changed." : { + + }, + "%@ row changed." : { + + }, + "%@ rows changed." : { + + }, + "Can't Show This Result" : { + + }, + "The reply is not rows the grid can draw. The conversation has it in full." : { + + }, + "No Statements Yet" : { + + }, + "SQL the session proposes appears here before it runs." : { + + }, + "Session" : { + + }, + "Recent Sessions" : { + + }, + "Clear Recents…" : { + } }, "version" : "1.1" diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index 4d6b3403f..82960082c 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -234,28 +234,14 @@ private struct AIChatBlockView: View, Equatable { } } +/// The system's own progress indicator rather than three bouncing dots, which read as decoration +/// rather than as the app waiting on a reply, and which no other surface in the app uses. struct ChatTypingIndicatorView: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var animating = false - var body: some View { - HStack(spacing: 4) { - ForEach(0..<3, id: \.self) { index in - Circle() - .fill(Color(nsColor: .tertiaryLabelColor)) - .frame(width: 6, height: 6) - .offset(y: animating ? -3 : 0) - .motionAnimation( - .easeInOut(duration: 0.4) - .repeatForever(autoreverses: true) - .delay(Double(index) * 0.15), - value: animating - ) - } - } - .frame(height: 16) - .accessibilityElement(children: .ignore) - .accessibilityLabel(String(localized: "Responding")) - .onAppear { animating = !reduceMotion } + ProgressView() + .controlSize(.small) + .frame(height: 16) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Responding")) } } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 4814910f5..620d94f35 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -17,6 +17,9 @@ struct AIChatPanelView: View { var queryResults: String? @ObservedObject var viewModel: AIChatViewModel + /// Fills its column in the trailing pane, and takes a reading measure in the window's content + /// column, where filling it would run a line the whole width of the window. + var contentWidth: ChatContentWidth = .pane @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var bottomVisibleMessageID: UUID? @State private var pinnedToBottom: Bool = true @@ -123,6 +126,7 @@ struct AIChatPanelView: View { } .controlSize(.small) } + .chatColumn(contentWidth) .padding(8) } } @@ -194,7 +198,7 @@ struct AIChatPanelView: View { } ) } - .frame(maxWidth: .infinity) + .chatColumn(contentWidth) .padding(.horizontal, 8) .padding(.vertical, 8) } @@ -267,6 +271,7 @@ struct AIChatPanelView: View { .buttonStyle(.plain) .accessibilityLabel(String(localized: "Dismiss error")) } + .chatColumn(contentWidth) .padding(.horizontal, 12) .padding(.vertical, 6) .background(.yellow.opacity(Self.warningBackgroundOpacity)) @@ -323,9 +328,13 @@ struct AIChatPanelView: View { slashCommandMenu modeMenu modelPicker + Spacer(minLength: 0) sendOrStopButton } } + /// Capped before the padding, the way the transcript above it is, so the composer's + /// leading edge lines up with the first character of the conversation. + .chatColumn(contentWidth) .padding(8) } } @@ -404,6 +413,14 @@ struct AIChatPanelView: View { } } + /// Sized to the model's name, and able to compress below it. + /// + /// Its label used to carry `maxWidth: .infinity`, which spread the button across the window as + /// soon as the conversation had one to spread across. `.fixedSize()` is the other end of the same + /// mistake: measured on macOS 27, a 44-character model name holds the button at 339pt, which + /// overflows the trailing pane's composer row by 85pt at 240pt wide. Unframed it takes the name's + /// width where there is room and truncates where there is not, and the spacer after it is what + /// keeps Send at the trailing edge in both widths. @ViewBuilder private var modelPicker: some View { let providers = settingsManager.ai.providers @@ -435,7 +452,6 @@ struct AIChatPanelView: View { } .font(.caption) .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) .accessibilityLabel(String(localized: "Choose AI provider and model")) } .menuStyle(.button) @@ -596,7 +612,7 @@ struct AIChatPanelView: View { } /// Hide system turns and user turns that exist only to carry tool-result - /// blocks back to the model — those are protocol plumbing, not user input. + /// blocks back to the model: those are protocol plumbing, not user input. private func isVisibleInMessageList(_ message: ChatTurn) -> Bool { guard message.role != .system else { return false } if message.role == .user { diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index 8c1fc9b7e..5e26a807d 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -58,9 +58,14 @@ struct AIChatWalkthroughBlockView: View { .padding(10) } .onAppear { autoExpandFirstStep(walkthrough.envelope.steps) } + /// The highlight goes with the task that would have cleared it. A reparent, which a mode + /// toggle and a connection switch both are, fires this and then `onAppear` on the same + /// view with its state intact, and nothing re-arms the timer there, so cancelling it alone + /// left the anchored lines highlighted for good. .onDisappear { highlightClearTask?.cancel() highlightClearTask = nil + activeAnchor = nil } } diff --git a/TablePro/Views/AIChat/ChatContentWidth.swift b/TablePro/Views/AIChat/ChatContentWidth.swift new file mode 100644 index 000000000..35f7a43e1 --- /dev/null +++ b/TablePro/Views/AIChat/ChatContentWidth.swift @@ -0,0 +1,38 @@ +// +// ChatContentWidth.swift +// TablePro +// + +import SwiftUI + +/// How wide a conversation lays itself out. +/// +/// Every view in the chat fills what it is given, which is right for the trailing pane it was +/// measured in at 270pt and wrong for Agent mode, where the same view is the window's content column +/// and a line ran the whole width of the window. The two are one view with two widths rather than two +/// views: the transcript, the composer draft and the provider picker belong to the session, and a +/// second chat surface would be a second place for each of them to drift. +internal enum ChatContentWidth: Equatable { + /// Fills its column, which is the trailing assistant's shape. + case pane + /// A centred column of a comfortable reading measure, with the rest of the width left as margin. + case reading + + /// 720pt, which holds about 90 characters at the app's body size: the measure typographers put a + /// column at, and the one Mail, Notes and Xcode's documentation settle on. + internal var maxWidth: CGFloat? { + switch self { + case .pane: nil + case .reading: 720 + } + } +} + +internal extension View { + /// Caps the view at the width's measure and centres it in what is left. A `.pane` conversation is + /// unchanged by it, so the two widths take the same path through every view. + func chatColumn(_ width: ChatContentWidth) -> some View { + frame(maxWidth: width.maxWidth ?? .infinity) + .frame(maxWidth: .infinity) + } +} diff --git a/TablePro/Views/Agent/AgentConversationView.swift b/TablePro/Views/Agent/AgentConversationView.swift index 4ab86bdcf..f5cd91df5 100644 --- a/TablePro/Views/Agent/AgentConversationView.swift +++ b/TablePro/Views/Agent/AgentConversationView.swift @@ -5,16 +5,21 @@ import SwiftUI -/// The session's conversation, at the width of the window's content area. +/// The session's conversation, in the window's content area. /// /// It is `AIChatPanelView`, the same view the trailing pane uses, rather than a second chat surface. /// That is what "one session, two presentations" means in practice: the transcript, the composer /// draft and the provider picker are the session's, so switching mode changes how the conversation -/// is presented and nothing about the conversation. +/// is presented and nothing about the conversation. What it does change is the width: the pane fills +/// its 270pt column, and here the transcript and the composer take a reading measure and leave the +/// rest of the window as margin. internal struct AgentConversationView: View { internal let connection: DatabaseConnection internal let session: AgentSession? internal let isConnecting: Bool + /// What is holding Safe Mode above the level the connection is set to, which in this mode is + /// always something: the mode raises one of its own. + internal let safeModeFloor: SafeModeFloor? internal let onStartSession: () -> Void var body: some View { @@ -24,9 +29,16 @@ internal struct AgentConversationView: View { Divider() } if let session { + AgentConversationContextStrip( + connectionName: connection.name, + session: session, + safeModeFloor: safeModeFloor + ) + Divider() AIChatPanelView( connection: connection, - viewModel: session.viewModel + viewModel: session.viewModel, + contentWidth: .reading ) /// A prompt typed before the connection landed is sent once, here, when the session /// can take it. It is cleared before it is dispatched, so a second flush site cannot @@ -58,8 +70,7 @@ internal struct AgentConversationView: View { } private func sendPendingPromptIfReady(_ session: AgentSession) { - guard !isConnecting, let prompt = session.pendingPrompt else { return } - session.pendingPrompt = nil + guard let prompt = session.takePendingPrompt(isConnecting: isConnecting) else { return } session.viewModel.inputText = prompt session.viewModel.sendMessage() } @@ -81,7 +92,7 @@ internal struct AgentConversationView: View { private var emptyState: some View { UnavailableStateView { - Label(String(localized: "No session open"), systemImage: "sparkles") + Label(String(localized: "No Session Open"), systemImage: "sparkles") } description: { Text(String(localized: "Start one to ask about this connection.")) } actions: { @@ -90,3 +101,50 @@ internal struct AgentConversationView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + +/// The line above the transcript that says what this conversation is about. +/// +/// The connection and the session each on a label of their own, secondary for the session, rather +/// than joined by a separator: a middle dot between them reads as generated and gives a screen reader +/// nothing to pause on. The rest of the line is the Safe Mode floor, as the level's own symbol and +/// the reason in a few words, with the sentence behind it as the tooltip and as what VoiceOver reads. +/// Agent mode raises that floor on every connection it is on and used to say so nowhere. +private struct AgentConversationContextStrip: View { + let connectionName: String + @ObservedObject var session: AgentSession + let safeModeFloor: SafeModeFloor? + + var body: some View { + HStack(spacing: 8) { + Text(connectionName) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.tail) + Text(session.displayTitle) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 12) + if let safeModeFloor { + floorLabel(safeModeFloor) + } + } + .font(.callout) + /// Capped before the padding, so the strip's leading edge is the transcript's own rather than + /// a padding's width to the right of it. + .chatColumn(.reading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func floorLabel(_ floor: SafeModeFloor) -> some View { + Label(floor.summary, systemImage: floor.level.iconName) + .font(.caption) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .lineLimit(1) + .layoutPriority(1) + .help(floor.explanation) + .accessibilityLabel(floor.explanation) + } +} diff --git a/TablePro/Views/Agent/AgentProposedStatementsView.swift b/TablePro/Views/Agent/AgentProposedStatementsView.swift index 905e10c1c..3896056b5 100644 --- a/TablePro/Views/Agent/AgentProposedStatementsView.swift +++ b/TablePro/Views/Agent/AgentProposedStatementsView.swift @@ -6,18 +6,16 @@ import SwiftUI /// Every statement the session proposed, in order, with what became of it. +/// +/// Handed the statements rather than the session, because projecting them out of the transcript is +/// what `AgentArtifactCache` does once per change instead of once per redraw. internal struct AgentProposedStatementsView: View { - @ObservedObject internal var session: AgentSession - - private var statements: [ProposedStatement] { - AgentArtifactProjection.build(from: session.viewModel.messages).statements - } + internal let statements: [ProposedStatement] var body: some View { - let statements = statements if statements.isEmpty { UnavailableStateView( - String(localized: "No statements yet"), + String(localized: "No Statements Yet"), systemImage: "curlybraces", description: Text(String(localized: "SQL the session proposes appears here before it runs.")) ) diff --git a/TablePro/Views/Agent/AgentResultPaneView.swift b/TablePro/Views/Agent/AgentResultPaneView.swift index 3585db43a..ae97b5c05 100644 --- a/TablePro/Views/Agent/AgentResultPaneView.swift +++ b/TablePro/Views/Agent/AgentResultPaneView.swift @@ -17,82 +17,61 @@ import SwiftUI internal struct AgentResultPaneView: View { @ObservedObject internal var session: AgentSession internal let connection: DatabaseConnection? + internal let contentMode: ConnectionWorkspaceContentMode - @State private var segment: AgentResultSegment = .sql + /// One per window, not per session: the column is a hosting controller that outlives every + /// session switch, which is why what the cache holds is named by the session it came from. + @StateObject private var artifacts = AgentArtifactCache() var body: some View { + let artifact = artifacts.artifact(for: session) VStack(spacing: 0) { - picker - Divider() - content + TrailingPaneHeaderView( + surface: .agentResult, + contentMode: contentMode, + paneState: nil + ) { section in + menuSection(section) + } + content(artifact) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - /// Icon-only segments with a name each. Four localized titles truncate in German and French at - /// the trailing pane's 270pt minimum, which is exactly the width this sits at. - private var picker: some View { - Picker(String(localized: "Result view"), selection: $segment) { + @ViewBuilder + private func menuSection(_ section: TrailingPaneMenuSection) -> some View { + switch section { + case .resultView: + segmentPicker + case .inspectorRendering, .jsonReading, .conversations, .clearRecents: + EmptyView() + } + } + + /// In the pane header's menu, where every surface keeps its commands, so the column's top edge + /// lines up with the inspector's and the assistant's. It used to be an icon-only segmented control + /// that was the whole top of the pane, the one surface of three with no title. + /// + /// The choice is the session's, so a switch between two sessions no longer hands one of them the + /// other's view. + private var segmentPicker: some View { + Picker(String(localized: "Result view"), selection: $session.resultSegment) { ForEach(AgentResultSegment.allCases, id: \.self) { item in - Image(systemName: item.symbolName) - .help(item.title) - .accessibilityLabel(item.title) + Label(item.title, systemImage: item.symbolName) .tag(item) } } - .pickerStyle(.segmented) + .pickerStyle(.inline) .labelsHidden() - .padding(.horizontal, 8) - .padding(.vertical, 6) } @ViewBuilder - private var content: some View { - switch segment { + private func content(_ artifact: AgentArtifact) -> some View { + switch session.resultSegment { case .sql: - AgentProposedStatementsView(session: session) - case .plan: - emptyState( - title: String(localized: "No steps yet"), - description: String(localized: "What the session does will be listed here.") - ) + AgentProposedStatementsView(statements: artifact.statements) case .results: - AgentResultRowsView(session: session, connection: connection) - case .schema: - emptyState( - title: String(localized: "No schema changes"), - description: String(localized: "Columns, indexes and constraints a statement would add or remove appear here.") - ) - } - } - - private func emptyState(title: String, description: String) -> some View { - UnavailableStateView(title, systemImage: segment.symbolName, description: Text(description)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -internal enum AgentResultSegment: String, CaseIterable, Hashable { - case sql - case plan - case results - case schema - - internal var title: String { - switch self { - case .sql: String(localized: "SQL") - case .plan: String(localized: "Plan") - case .results: String(localized: "Results") - case .schema: String(localized: "Schema") - } - } - - internal var symbolName: String { - switch self { - case .sql: "curlybraces" - case .plan: "list.number" - case .results: "tablecells" - case .schema: "square.stack.3d.up" + AgentResultRowsView(runs: artifact.runs, artifacts: artifacts, connection: connection) } } } diff --git a/TablePro/Views/Agent/AgentResultRowsView.swift b/TablePro/Views/Agent/AgentResultRowsView.swift index b9a1dd06a..e46c6210e 100644 --- a/TablePro/Views/Agent/AgentResultRowsView.swift +++ b/TablePro/Views/Agent/AgentResultRowsView.swift @@ -23,7 +23,9 @@ import TableProPluginKit /// which is the two-font-domain defect), column separators that scale, an accessibility cell tree, /// selection and copy. None of that is worth reimplementing beside the real one. internal struct AgentResultRowsView: View { - @ObservedObject internal var session: AgentSession + internal let runs: [AgentQueryRun] + /// Decodes each run once. The pane owns it, so the answer survives a redraw and a sort. + internal let artifacts: AgentArtifactCache internal let connection: DatabaseConnection? @State private var changeManager = AnyChangeManager(DataChangeManager()) @@ -33,15 +35,10 @@ internal struct AgentResultRowsView: View { @State private var sortState = SortState() @StateObject private var gridDelegate = AgentResultGridDelegate() - private var runs: [AgentQueryRun] { - AgentArtifactProjection.build(from: session.viewModel.messages).runs - } - var body: some View { - let runs = runs if runs.isEmpty { UnavailableStateView( - String(localized: "No results yet"), + String(localized: "No Results Yet"), systemImage: "tablecells", description: Text(String(localized: "Rows the session reads appear here.")) ) @@ -51,7 +48,7 @@ internal struct AgentResultRowsView: View { VStack(spacing: 0) { runPicker(runs: runs, current: run) Divider() - grid(for: run) + result(for: run) } .onChange(of: run.id) { _ in selectedRows = [] @@ -78,36 +75,76 @@ internal struct AgentResultRowsView: View { ) } + /// Four answers where there used to be two, because "nothing to draw" was four different things + /// and the pane said the same sentence for each: a write that changed rows read as a query that + /// had matched none. @ViewBuilder - private func grid(for run: AgentQueryRun) -> some View { - if let decoded = AgentResultDecoder.tableRows(fromResultJSON: run.resultJSON) { - let rows = TableRowsSorting.sorted(decoded, by: sortState) - DataGridView( - tableRowsProvider: { rows }, - changeManager: changeManager, - isEditable: false, - configuration: DataGridConfiguration( - databaseType: connection?.type, - showRowNumbers: true, - supportsColumnCommands: false - ), - delegate: gridDelegate, - selectedRowIndices: $selectedRows, - sortState: $sortState, - columnLayout: $columnLayout, - contentRevision: contentRevision(for: run) - ) - .onAppear { - gridDelegate.onSortStateChanged = { sortState = $0 } - } - } else { - UnavailableStateView( - String(localized: "Nothing to show"), + private func result(for run: AgentQueryRun) -> some View { + switch artifacts.payload(for: run) { + case .rows(let decoded): + grid(decoded, for: run) + case .noRows: + state( + title: String(localized: "No Rows"), systemImage: "tablecells", - description: Text(String(localized: "This query returned no rows.")) + description: String(localized: "The query returned no rows.") + ) + case .completed(let rowsAffected): + state( + title: String(localized: "Statement Completed"), + systemImage: "checkmark.circle", + description: Self.changeSummary(rowsAffected) ) + case .unreadable: + state( + title: String(localized: "Can't Show This Result"), + systemImage: "text.bubble", + description: String(localized: "The reply is not rows the grid can draw. The conversation has it in full.") + ) + } + } + + private func grid(_ decoded: TableRows, for run: AgentQueryRun) -> some View { + let rows = TableRowsSorting.sorted(decoded, by: sortState) + return DataGridView( + tableRowsProvider: { rows }, + changeManager: changeManager, + isEditable: false, + configuration: DataGridConfiguration( + databaseType: connection?.type, + showRowNumbers: true, + supportsColumnCommands: false + ), + delegate: gridDelegate, + selectedRowIndices: $selectedRows, + sortState: $sortState, + columnLayout: $columnLayout, + contentRevision: contentRevision(for: run) + ) + .onAppear { + gridDelegate.onSortStateChanged = { sortState = $0 } + } + } + + private func state(title: String, systemImage: String, description: String) -> some View { + UnavailableStateView(title, systemImage: systemImage, description: Text(description)) .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + /// Grouped, because a count is read at a glance and "1,204" is legible where "1204" has to be + /// counted. A statement that reports no count at all is one from a tool that answers in the + /// bridge's shape without sending one. + private static func changeSummary(_ rowsAffected: Int?) -> String { + guard let rowsAffected else { + return String(localized: "The statement returned no rows to show.") + } + guard rowsAffected > 0 else { + return String(localized: "No rows changed.") } + let template = rowsAffected == 1 + ? String(localized: "%@ row changed.") + : String(localized: "%@ rows changed.") + return String(format: template, rowsAffected.formatted(.number.grouping(.automatic))) } /// Moves whenever the rows the grid should be drawing move, which a sort does without changing diff --git a/TablePro/Views/Agent/AgentSessionRailView.swift b/TablePro/Views/Agent/AgentSessionRailView.swift index 00b68fe40..6709cb3e3 100644 --- a/TablePro/Views/Agent/AgentSessionRailView.swift +++ b/TablePro/Views/Agent/AgentSessionRailView.swift @@ -9,60 +9,107 @@ import SwiftUI /// /// Selection selects and nothing else. A `List` moves its selection on a single click and on every /// arrow key, so acting on the change means the highlight cannot be moved without opening a session -/// and focus cannot pass through the list at all. Opening is its own command, on a double click and -/// in the context menu. +/// and focus cannot pass through the list at all. Opening is its own command, and it has four routes: +/// a double-click, Return on the highlighted row, the row's own Open Session action, and the context +/// menu. The first two are the list's primary action, which is `NSTableView`'s `doubleAction` +/// underneath and costs a single click nothing; the third is what makes the command reachable by +/// VoiceOver, Switch Control and Voice Control, which a double-click alone never was. +/// +/// Never a `TapGesture(count: 2)` on the row: SwiftUI arbitrates that against the single tap by +/// holding every selection for the whole double-click interval, measured at 371ms. internal struct AgentSessionRailView: View { internal let connectionId: UUID @ObservedObject internal var registry: AgentSessionRegistry - internal let selectedSessionId: UUID? - internal let onSelect: (UUID) -> Void + @ObservedObject internal var railState: AgentSessionRailState + /// The session the window is drawing, which the rail marks and follows with its highlight. + internal let openSessionId: UUID? + internal let onOpen: (UUID) -> Void internal let onNewSession: () -> Void - internal let onCloseSession: (UUID) -> Void - - @State private var listSelection: UUID? + internal let onClose: (UUID) -> Void + internal let onDelete: (UUID) -> Void private var sessions: [AgentSession] { registry.sessions(for: connectionId) } + private var highlightedSession: AgentSession? { + guard let id = railState.highlightedSessionId else { return nil } + return sessions.first { $0.id == id } + } + var body: some View { - VStack(spacing: 0) { - if sessions.isEmpty { - emptyState - } else { - list + ScrollViewReader { proxy in + VStack(spacing: 0) { + if sessions.isEmpty { + emptyState + } else { + list + } + Divider() + bottomBar + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { railState.highlightedSessionId = openSessionId } + /// Opening a session, starting one and closing the one on screen all move which session + /// the window draws, and the rail follows it: the new row is highlighted and scrolled to, + /// which for a session just started is the top of the list. + .onChange(of: openSessionId) { id in + railState.highlightedSessionId = id + guard let id else { return } + proxy.scrollTo(id) } - Divider() - bottomBar } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { listSelection = selectedSessionId } - .onChange(of: selectedSessionId) { listSelection = $0 } } private var list: some View { - List(selection: $listSelection) { + List(selection: $railState.highlightedSessionId) { Section(String(localized: "Sessions")) { ForEach(sessions) { session in - AgentSessionRow(session: session) + AgentSessionRow(session: session, isOpen: session.id == openSessionId) .tag(session.id) - .contentShape(Rectangle()) - .onTapGesture(count: 2) { onSelect(session.id) } - .contextMenu { - Button(String(localized: "Open Session")) { onSelect(session.id) } - Divider() - Button(String(localized: "Close Session")) { onCloseSession(session.id) } + .id(session.id) + .accessibilityAction(named: Text(String(localized: "Open Session"))) { + onOpen(session.id) } } } } .listStyle(.sidebar) + .contextMenu(forSelectionType: UUID.self) { ids in + rowMenu(for: ids) + } primaryAction: { ids in + guard let id = ids.first else { return } + onOpen(id) + } + /// The keyboard's half of the bottom bar's minus button, and the same confirmation. + .onDeleteCommand { + guard let session = highlightedSession else { return } + onDelete(session.id) + } + } + + /// A contextual menu leaves out what does not apply rather than dimming it, which is the reverse + /// of the menu bar's rule: Close Session is absent on a session that has already ended. + /// + /// The ids are the row under the pointer rather than the highlighted one. Measured on macOS 27: + /// right-clicking the fourth row with the first one highlighted calls this with the fourth row's + /// id alone, and leaves the highlight where it was. + @ViewBuilder + private func rowMenu(for ids: Set) -> some View { + if let id = ids.first, let session = sessions.first(where: { $0.id == id }) { + Button(String(localized: "Open Session")) { onOpen(id) } + if !session.status.isEnded { + Button(String(localized: "Close Session")) { onClose(id) } + } + Divider() + Button(String(localized: "Delete Session…")) { onDelete(id) } + } } /// The empty state offers the command rather than describing where its button is. private var emptyState: some View { UnavailableStateView { - Label(String(localized: "No sessions yet"), systemImage: "sparkles") + Label(String(localized: "No Sessions Yet"), systemImage: "sparkles") } description: { Text(String(localized: "Start one to ask about this connection.")) } actions: { @@ -71,26 +118,42 @@ internal struct AgentSessionRailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - /// A bordered button in a bottom bar, which is the shape a source list uses for adding to itself. + /// Add and remove at the foot of the list, which is the shape a source list keeps its own + /// commands in. Removing is destructive and asks first, which the window does rather than the + /// rail, so the menu bar's Delete Session asks the same question. private var bottomBar: some View { HStack(spacing: 0) { Button(action: onNewSession) { Image(systemName: "plus") .frame(width: 20, height: 20) } - .buttonStyle(.borderless) .help(String(localized: "New Session")) .accessibilityLabel(String(localized: "New Session")) + .accessibilityIdentifier("agent-session-add") + Button { + guard let session = highlightedSession else { return } + onDelete(session.id) + } label: { + Image(systemName: "minus") + .frame(width: 20, height: 20) + } + .disabled(highlightedSession == nil) + .help(String(localized: "Delete Session")) + .accessibilityLabel(String(localized: "Delete Session")) + .accessibilityIdentifier("agent-session-remove") Spacer() } + .buttonStyle(.borderless) .padding(.horizontal, 8) .padding(.vertical, 4) } } -/// One session, as a source-list row: what it is about, and what it is doing. +/// One session, as a source-list row: what it is about, what it is doing, and whether it is the one +/// the window is drawing. private struct AgentSessionRow: View { @ObservedObject var session: AgentSession + let isOpen: Bool var body: some View { HStack(spacing: 8) { @@ -99,7 +162,7 @@ private struct AgentSessionRow: View { .foregroundStyle(.secondary) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(title) + Text(session.displayTitle) .lineLimit(1) .truncationMode(.middle) /// The status on its own line rather than joined to the title by a separator, which @@ -110,14 +173,24 @@ private struct AgentSessionRow: View { .lineLimit(1) } Spacer(minLength: 0) + /// The session on screen is marked the way a menu marks the item in force, since the + /// highlight cannot say it: the highlight moves with every arrow key and opening is a + /// command of its own. + if isOpen { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } } .padding(.vertical, 2) .accessibilityElement(children: .combine) - .accessibilityLabel(title) - .accessibilityValue(session.status.title) + .accessibilityLabel(session.displayTitle) + .accessibilityValue(accessibilityValue) } - private var title: String { - session.title.isEmpty ? String(localized: "New Session") : session.title + private var accessibilityValue: String { + guard isOpen else { return session.status.title } + return String(format: String(localized: "%@, open in this window"), session.status.title) } } diff --git a/TablePro/Views/Compare/CompareEndpointToolbarController.swift b/TablePro/Views/Compare/CompareEndpointToolbarController.swift index d7a7a04e0..70083bc53 100644 --- a/TablePro/Views/Compare/CompareEndpointToolbarController.swift +++ b/TablePro/Views/Compare/CompareEndpointToolbarController.swift @@ -101,8 +101,10 @@ internal final class CompareEndpointToolbarController: NSObject { dismiss() return } + /// Nil, because the Compare window's toolbar has no context resolver and hides nothing. guard let identifier = identifiers[side], - let anchor = ToolbarSwitcherPresenter.anchor(in: windowProvider(), identifier) else { return } + let anchor = ToolbarSwitcherPresenter.anchor(in: windowProvider(), identifier, hiddenBy: nil) + else { return } let shown = PopoverPresenter.show( relativeTo: anchor, diff --git a/TablePro/Views/Components/PopoverPresenter.swift b/TablePro/Views/Components/PopoverPresenter.swift index a74f1a356..8b2d8d5df 100644 --- a/TablePro/Views/Components/PopoverPresenter.swift +++ b/TablePro/Views/Components/PopoverPresenter.swift @@ -34,12 +34,14 @@ enum PopoverPresenter { /// present at all once the item is clipped, and a clipped item survives only as its /// `menuFormRepresentation`. /// - /// The caller must have resolved `toolbarItem` out of a visible toolbar. AppKit throws - /// `NSInvalidArgumentException` when it cannot locate the item, which Swift cannot catch, so - /// the check belongs at the call site as a precondition rather than here as error handling. - /// `show(relativeTo: NSToolbarItem)` is macOS 14, and there is no stand-in: an item whose - /// view AppKit generates reports `view` as nil, so there is nothing to anchor on below it. - /// Callers resolve their anchor through `ToolbarSwitcherPresenter.anchor`, which answers nil + /// The caller must have resolved `toolbarItem` out of `NSToolbar.items` as a top-level item. + /// AppKit throws `NSInvalidArgumentException` when it cannot locate the item, which Swift cannot + /// catch, so the check belongs at the call site as a precondition rather than here as error + /// handling. Measured on macOS 27, the one case that raised was a subitem of a group that was + /// off screen; a clipped top-level item is supported, and anchors on the clipped-items + /// indicator. `show(relativeTo: NSToolbarItem)` is macOS 14, and there is no stand-in: an item + /// whose view AppKit generates reports `view` as nil, so there is nothing to anchor on below + /// it. Callers resolve their anchor through `ToolbarSwitcherPresenter.anchor`, which answers nil /// on macOS 13 so they take their own fallback instead. @available(macOS 14.0, *) @discardableResult diff --git a/TablePro/Views/Connection/TrailingPaneUnavailableView.swift b/TablePro/Views/Connection/TrailingPaneUnavailableView.swift index f13063fc1..145aa55b6 100644 --- a/TablePro/Views/Connection/TrailingPaneUnavailableView.swift +++ b/TablePro/Views/Connection/TrailingPaneUnavailableView.swift @@ -5,7 +5,7 @@ import SwiftUI -/// What the inspector and the assistant show for a connection that has no session behind them. +/// What a trailing surface shows when there is nothing behind it to draw. /// /// The pane used to be force-collapsed for exactly this state, as part of the chrome the window /// took down whenever a connection was not up. The window's shape is the user's now and stays put, @@ -15,26 +15,92 @@ import SwiftUI /// `ContentUnavailableView` is the right view here and the wrong one for the connecting surface /// beside it, which is the distinction Apple draws: this is content that cannot be shown, not work /// in flight. +/// +/// It draws the pane's header like every surface does, so the pane's top edge does not jump when a +/// connection drops, and it names why the surface is empty rather than the pane it is in. Every +/// surface used to say "Not Connected" beside `sidebar.right`, including the result column of an Agent +/// mode window whose connection was up and which had no session to show. internal struct TrailingPaneUnavailableView: View { - internal let surface: TrailingPaneSurface + internal enum Reason: Equatable { + case notConnected + case noSession + + /// Why the result column cannot draw a session, or nil when it can. + /// + /// The connection is asked first, the way the inspector and the assistant ask it. A dropped + /// connection with a session used to keep its SQL and Results in the column, statements + /// and rows that could no longer run or be refreshed, beside a detail column that had + /// already moved to the unavailable screen. Only a live connection with nothing started is + /// an empty session list. + internal static func agentResult(pane: ConnectionWindowPane, hasSession: Bool) -> Reason? { + guard pane.hasContent else { return .notConnected } + return hasSession ? nil : .noSession + } + } + + private let surface: TrailingPaneSurface + private let reason: Reason + private let contentMode: ConnectionWorkspaceContentMode + private let paneState: TrailingPaneState? + + internal init( + surface: TrailingPaneSurface, + reason: Reason, + contentMode: ConnectionWorkspaceContentMode, + paneState: TrailingPaneState? + ) { + self.surface = surface + self.reason = reason + self.contentMode = contentMode + self.paneState = paneState + } internal var body: some View { - UnavailableStateView( - String(localized: "Not Connected"), - systemImage: "sidebar.right", - description: Text(description) - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + VStack(spacing: 0) { + TrailingPaneHeaderView( + surface: surface, + contentMode: contentMode, + paneState: paneState, + hasContent: false + ) { _ in + EmptyView() + } + UnavailableStateView( + title, + systemImage: systemImage, + description: Text(description) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var title: String { + switch reason { + case .notConnected: String(localized: "Not Connected") + case .noSession: String(localized: "No Session Open") + } + } + + /// `bolt.horizontal.circle` is the glyph the connection's own unavailable screen draws for a + /// dropped connection, and it exists on macOS 13, the app's minimum. `cable.connector.slash` + /// arrived in macOS 14 and draws nothing on 13. + private var systemImage: String { + switch reason { + case .notConnected: "bolt.horizontal.circle" + case .noSession: surface.symbolName + } } private var description: String { - switch surface { - case .inspector: - return String(localized: "Row fields appear once the connection is up") - case .assistant: - return String(localized: "The assistant answers once the connection is up") - case .agentResult: - return String(localized: "What the session runs appears once the connection is up") + switch (surface, reason) { + case (.inspector, _): + String(localized: "Row fields appear once the connection is up") + case (.assistant, _): + String(localized: "The assistant answers once the connection is up") + case (.agentResult, .notConnected): + String(localized: "What the session runs appears once the connection is up") + case (.agentResult, .noSession): + String(localized: "What a session proposes and runs appears here") } } } diff --git a/TablePro/Views/Editor/History/HistoryRowView.swift b/TablePro/Views/Editor/History/HistoryRowView.swift index 37151159a..a8cbd04a1 100644 --- a/TablePro/Views/Editor/History/HistoryRowView.swift +++ b/TablePro/Views/Editor/History/HistoryRowView.swift @@ -19,22 +19,23 @@ struct HistoryRowView: View { .lineLimit(1) .truncationMode(.tail) - HStack(spacing: 6) { + /// Spacing alone separates the three facts, the way the inspector's status bar + /// separates its counts. A middle dot between them is punctuation the rest of the + /// app's chrome no longer uses, and each fact already reads as its own phrase. + HStack(spacing: 12) { if let connectionLabel { Label { Text(connectionLabel.name) } icon: { - connectionDot(connectionLabel.color?.color) + connectionGlyph(connectionLabel.color?.color) } .labelStyle(.titleAndIcon) - Text(verbatim: "·") } Text(entry.databaseDisplayName) .truncationMode(.middle) if entry.source != .editor { - Text(verbatim: "·") Label(entry.source.displayName, systemImage: entry.source.symbolName) .labelStyle(.titleAndIcon) } @@ -75,15 +76,23 @@ struct HistoryRowView: View { } } - /// A connection's own colour is a fixed value, so it disappears into the accent fill unless it - /// switches with the background. The unnamed case is secondary content and adapts by itself. + /// The glyph the toolbar's own connection control falls back to, tinted with the connection's + /// colour. It replaces a filled dot, which carried the colour and nothing else, so a connection + /// with no colour of its own drew a grey dot that named nothing. + /// + /// Not the engine's own icon, which is the obvious candidate and does not survive this size: + /// half of them are asset-catalog line art, and measured at 12pt against the emphasized + /// selection fill the PostgreSQL elephant kept no pixel of the tint at all. + /// + /// A connection's colour is a fixed value, so it disappears into that fill unless it switches + /// with the background. The uncoloured case is secondary content and adapts by itself. @ViewBuilder - private func connectionDot(_ color: Color?) -> some View { - let dot = Image(systemName: "circle.fill").font(.system(size: 6)) + private func connectionGlyph(_ color: Color?) -> some View { + let glyph = Image(systemName: "network") if let color { - dot.selectionAwareTint(color) + glyph.selectionAwareTint(color) } else { - dot.foregroundStyle(Color.secondary) + glyph.foregroundStyle(Color.secondary) } } } diff --git a/TablePro/Views/Inspector/InspectorStatusBar.swift b/TablePro/Views/Inspector/InspectorStatusBar.swift index c3bc6b2e9..9fa95102a 100644 --- a/TablePro/Views/Inspector/InspectorStatusBar.swift +++ b/TablePro/Views/Inspector/InspectorStatusBar.swift @@ -10,21 +10,22 @@ struct InspectorStatusBar: View { let onPreviousPage: () -> Void let onNextPage: () -> Void + /// Spacing alone separates the counts. A middle dot between them is punctuation the rest of the + /// app's chrome no longer uses, and each count already reads as its own phrase. var body: some View { - HStack(spacing: 8) { + HStack(spacing: 14) { rowSummary - separator Text("\(state.columnNames.count) ^[columns](inflect: true)") if !state.selectedRowIndices.isEmpty { - separator Text("\(state.selectedRowIndices.count) selected") } if state.isComputing { - separator - ProgressView() - .controlSize(.small) - .accessibilityHidden(true) - Text("Updating…") + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + Text("Updating…") + } } Spacer(minLength: 8) if state.pageCount > 1 { @@ -66,11 +67,4 @@ struct InspectorStatusBar: View { private var currentPage: Int { state.pageSize > 0 ? (state.pageOffset / state.pageSize) + 1 : 1 } - - /// Punctuation, so VoiceOver must not read it as an element of its own. - private var separator: some View { - Text(verbatim: "·") - .foregroundStyle(.tertiary) - .accessibilityHidden(true) - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCommandActions+Switchers.swift b/TablePro/Views/Main/Extensions/MainContentCommandActions+Switchers.swift index 9045b1b93..1bae5e457 100644 --- a/TablePro/Views/Main/Extensions/MainContentCommandActions+Switchers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCommandActions+Switchers.swift @@ -57,13 +57,15 @@ internal extension MainContentCommandActions { coordinator?.switcherPresenter?.dismiss() } - /// Anchored to the Database subitem, which is the capsule the user pressed. The group is two - /// capsules wide, so anchoring to it points the chooser at the seam between them; the presenter - /// falls back to the group by itself once AppKit clips it into the overflow menu. + /// Anchored to the Database item, which is the capsule the user pressed. It is a top-level + /// centred item, so it anchors on its own capsule, measured within 2pt of where the old subitem + /// anchor landed at a 1200pt window. Clipped, AppKit anchors it on the clipped-items indicator + /// itself; hidden by the context, the presenter takes the floating panel. private func presentDatabaseSwitcher(on coordinator: MainContentCoordinator, target: ContainerSwitchTarget?) { coordinator.switcherPresenter?.present( from: coordinator.contentWindow, anchoredTo: MainWindowToolbar.database, + hiddenBy: coordinator.splitViewController?.toolbarOwner?.visibility, subject: .container(target), contentSize: DatabaseSwitcherPopover.contentSize ) { dismiss in diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 8e2305b87..97701140a 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -39,7 +39,7 @@ extension MainContentView { } } - /// What the inspector's header names. + /// What the inspector names above its fields. /// /// A schema grid's selection is a column definition, not a row of a result: it has no position /// and no identity, so it gets its own case rather than being rendered as "Row 0 of 0". @@ -258,4 +258,8 @@ struct PendingChangeTrigger: Equatable { let hasStructureChanges: Bool let isFileDirty: Bool let hasCreateTablePending: Bool + /// The fifth source. Without it staging a principal edit changes nothing this value can see, so + /// `updateToolbarPendingState()` is never re-run and the commit control stays dim however many + /// changes the Users & Roles tab holds. + let hasPrincipalChanges: Bool } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift index 8d2c61aec..8c80a72b1 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift @@ -20,6 +20,7 @@ import SwiftUI payload: nil, windowTitle: .constant("SQL Query"), windowSubtitle: .constant(""), + windowRepresentedURL: .constant(nil), sidebarState: SharedSidebarState(), pendingTruncates: .constant([]), pendingDeletes: .constant([]), diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 20142a668..7054c2118 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -201,27 +201,39 @@ extension MainContentView { // MARK: - Command Actions Setup + /// One resolution of what is staged, so the commit control, its verb and Preview SQL's gate + /// cannot disagree. The arm this replaces never read `hasPrincipalChanges`, so a Users & Roles + /// tab with staged principals left both the toolbar's commit button and Cmd+S dim over work + /// `saveChanges()` already knew how to apply. func updateToolbarPendingState() { - if tabManager.selectedTab?.tabType == .createTable { - toolbarState.hasDataPendingChanges = false - toolbarState.hasPendingChanges = toolbarState.hasCreateTablePending - return - } - let hasDataChanges = - changeManager.hasChanges - || !pendingTruncates.isEmpty - || !pendingDeletes.isEmpty - || toolbarState.hasStructureChanges - let hasFileChanges = tabManager.selectedTab?.content.isFileDirty ?? false - toolbarState.hasDataPendingChanges = hasDataChanges - toolbarState.hasPendingChanges = hasDataChanges || hasFileChanges + let kind = PendingChangeKind.resolve( + tabType: tabManager.selectedTab?.tabType, + hasDataChanges: changeManager.hasChanges || !pendingTruncates.isEmpty || !pendingDeletes.isEmpty, + hasStructureChanges: toolbarState.hasStructureChanges, + hasCreateTablePending: toolbarState.hasCreateTablePending, + hasPrincipalChanges: toolbarState.hasPrincipalChanges, + isFileDirty: tabManager.selectedTab?.content.isFileDirty ?? false + ) + toolbarState.pendingChange = kind + toolbarState.hasPendingChanges = kind != nil + /// Preview SQL asks a narrower question than the commit control: a dirty query file and + /// staged principals both raise the commit and neither has grid SQL to show. + toolbarState.hasDataPendingChanges = kind == .data || kind == .structure } /// Update window title, proxy icon, and dirty dot based on the selected tab. + /// + /// This tree is the browse content, so it names the window as the browse content: `.content` + /// and `.browse` are what it is, not guesses. Whether it is the tree on screen is the window's + /// question, and its bindings drop a name or a file written from behind an agent conversation. + /// The edited dot is still written directly, because the unsaved work it reports is still in + /// the window while the conversation is drawn over it. func updateWindowTitleAndFileState() { let selectedTab = tabManager.selectedTab let resolved = WindowTitleResolver.resolveWindow( pane: .content, + contentMode: .browse, + agentSessionTitle: nil, connection: connection, tab: selectedTab, hasTabs: !tabManager.tabs.isEmpty, @@ -229,11 +241,11 @@ extension MainContentView { ) windowTitle = resolved.title windowSubtitle = resolved.subtitle + windowRepresentedURL = resolved.representedURL coordinator.splitViewController?.updateDetailMinimumThickness( for: selectedTab?.tabType, connectionId: connection.id ) - viewWindow?.representedURL = selectedTab?.content.sourceFileURL viewWindow?.isDocumentEdited = selectedTab.map(coordinator.showsUnsavedIndicator) ?? false } @@ -258,7 +270,7 @@ extension MainContentView { coordinator.isKeyWindow = window.isKeyWindow // Native proxy icon (Cmd+click shows path in Finder) and dirty dot - window.representedURL = tabManager.selectedTab?.content.sourceFileURL + windowRepresentedURL = tabManager.selectedTab?.content.sourceFileURL window.isDocumentEdited = tabManager.selectedTab.map(coordinator.showsUnsavedIndicator) ?? false commandActions?.window = window diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 9ff1d2040..82b17c971 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -936,7 +936,7 @@ final class MainContentCommandActions: ObservableObject { var supportsServerDashboard: Bool { guard let type = coordinator?.connection.type else { return false } - return ServerDashboardQueryProviderFactory.provider(for: type) != nil + return ServerDashboardQueryProviderFactory.supportsDashboard(for: type) } func showUsersAndRoles() { @@ -1318,10 +1318,6 @@ final class MainContentCommandActions: ObservableObject { state.isVisible.toggle() } - func toggleRightSidebar() { - coordinator?.trailingPaneProxy?.toggleInspector() - } - func goToPreviousPage() { coordinator?.goToPreviousPage() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 4ffb5a2d5..2fd7a8625 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -146,6 +146,11 @@ final class MainContentCoordinator: ObservableObject { services.databaseManager.browseDatabaseName(for: connection) } var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } + /// The level the toolbar shows, and the floor under it with Agent mode's included, which is what + /// the Safe Mode list offers from and what the toolbar's tooltip explains. + var safeModeStatus: SafeModeStatus { + SafeModeStatus(level: safeModeLevel, floor: AgentModeSafeModeFloor.effectiveFloor(for: connection)) + } func setSafeModeLevel(_ level: SafeModeLevel) { services.databaseManager.chooseSafeModeLevel(level, for: connectionId) toolbarState.safeModeLevel = services.databaseManager.session(for: connectionId)?.safeModeLevel ?? level diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 94b7a3b17..8e08645cd 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -31,6 +31,7 @@ struct MainContentView: View { // Shared state from parent @Binding var windowTitle: String @Binding var windowSubtitle: String + @Binding var windowRepresentedURL: URL? @ObservedObject var schemaService = SchemaService.shared @ObservedObject var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @@ -71,6 +72,7 @@ struct MainContentView: View { payload: EditorTabPayload?, windowTitle: Binding, windowSubtitle: Binding, + windowRepresentedURL: Binding, sidebarState: SharedSidebarState, pendingTruncates: Binding>, pendingDeletes: Binding>, @@ -85,6 +87,7 @@ struct MainContentView: View { self.payload = payload self._windowTitle = windowTitle self._windowSubtitle = windowSubtitle + self._windowRepresentedURL = windowRepresentedURL self.sidebarState = sidebarState self._pendingTruncates = pendingTruncates self._pendingDeletes = pendingDeletes @@ -308,7 +311,8 @@ struct MainContentView: View { pendingDeletes: pendingDeletes, hasStructureChanges: toolbarState.hasStructureChanges, isFileDirty: tabManager.selectedTab?.content.isFileDirty ?? false, - hasCreateTablePending: toolbarState.hasCreateTablePending + hasCreateTablePending: toolbarState.hasCreateTablePending, + hasPrincipalChanges: toolbarState.hasPrincipalChanges ) } diff --git a/TablePro/Views/RowInspector/AssistantPaneView.swift b/TablePro/Views/RowInspector/AssistantPaneView.swift index 0a6c15310..06f3fdf15 100644 --- a/TablePro/Views/RowInspector/AssistantPaneView.swift +++ b/TablePro/Views/RowInspector/AssistantPaneView.swift @@ -7,22 +7,40 @@ import SwiftUI /// The assistant, in the window's trailing pane. /// -/// It is its own surface, with its own title, its own conversation controls and its own command, -/// because a chat is not one of the views of a selected row. +/// It is its own surface, with its own conversation commands and its own command in the menu bar, +/// because a chat is not one of the views of a selected row. Its commands live in the pane header's +/// menu, the same header the inspector draws, so the pane's top edge stays put when the surface +/// changes. /// /// What it draws is the connection's session, which the registry owns rather than this view. The /// same session is what Agent mode puts in the middle column, so the two are one conversation shown /// two ways rather than two conversations. internal struct AssistantPaneView: View { - internal let connection: DatabaseConnection - @ObservedObject internal var state: AssistantState + private let connection: DatabaseConnection + @ObservedObject private var state: AssistantState + private let paneState: TrailingPaneState + private let contentMode: ConnectionWorkspaceContentMode - @State private var showsClearConfirmation = false + internal init( + connection: DatabaseConnection, + paneState: TrailingPaneState, + contentMode: ConnectionWorkspaceContentMode + ) { + self.connection = connection + _state = ObservedObject(wrappedValue: paneState.assistant) + self.paneState = paneState + self.contentMode = contentMode + } var body: some View { VStack(spacing: 0) { - header - Divider() + TrailingPaneHeaderView( + surface: .assistant, + contentMode: contentMode, + paneState: paneState + ) { section in + menuSection(section) + } /// Activation happens in `.task`, never in `body`. Reading it here used to mutate the /// observed object mid-update, which SwiftUI reports as "Publishing changes from within /// view updates" and answers with a second layout pass across this pane and the detail @@ -42,91 +60,72 @@ internal struct AssistantPaneView: View { .task(id: connection.id) { state.activate(connection: connection) } - .alert( - String(localized: "Clear All Conversations?"), - isPresented: $showsClearConfirmation - ) { - Button(String(localized: "Clear"), role: .destructive) { - state.viewModelIfActivated?.clearConversation() - } - Button(String(localized: "Cancel"), role: .cancel) {} - } message: { - Text(String(localized: "This will permanently delete all conversation history.")) - } } - private var header: some View { - HStack(spacing: 4) { - Text("Assistant") - .font(.headline) - .lineLimit(1) - Spacer(minLength: 8) - historyMenu - newConversationButton - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - } - - private var newConversationButton: some View { - Button { - state.viewModelIfActivated?.startNewConversation() - } label: { - icon("square.and.pencil") + /// Every command here is sent to the window through the responder chain, exactly as File > + /// Session sends it, rather than reaching into the view model this pane is holding. One place + /// decides what New Conversation does and one place asks before Clear Recents throws anything + /// away; the alert used to live in this view, so the menu bar had no way to carry the command at + /// all without asking the question a second time in its own words. + @ViewBuilder + private func menuSection(_ section: TrailingPaneMenuSection) -> some View { + switch section { + case .conversations: + Button { + NSApp.sendAction(#selector(MainSplitViewController.newAIConversation(_:)), to: nil, from: nil) + } label: { + Label(String(localized: "New Conversation"), systemImage: "square.and.pencil") + } + .disabled(state.viewModelIfActivated == nil) + conversationHistory + case .clearRecents: + Button(role: .destructive) { + NSApp.sendAction(#selector(MainSplitViewController.clearAIConversations(_:)), to: nil, from: nil) + } label: { + Label(String(localized: "Clear Recents"), systemImage: "trash") + } + .disabled(conversations.isEmpty) + case .inspectorRendering, .jsonReading, .resultView: + EmptyView() } - .buttonStyle(.plain) - .frame(width: 24, height: 22) - .contentShape(Rectangle()) - .help(String(localized: "New Conversation")) - .accessibilityLabel(String(localized: "New Conversation")) } - private var historyMenu: some View { + /// A submenu, because the list grows with every conversation. The current one carries the + /// menu's own checkmark, which VoiceOver reads as selected; it used to be a bare checkmark image + /// beside the title that announced nothing. `text.bubble` rather than `clock`, which is Query + /// History's glyph in the same window. + private var conversationHistory: some View { Menu { - if let viewModel = state.viewModelIfActivated { - if !viewModel.conversations.isEmpty { - Section(String(localized: "Recent Conversations")) { - ForEach(viewModel.conversations) { conversation in - Button { - viewModel.switchConversation(to: conversation.id) - } label: { - HStack { - Text(conversation.title.isEmpty - ? String(localized: "Untitled") - : conversation.title) - if conversation.id == viewModel.activeConversationID { - Image(systemName: "checkmark") - } - } - } - } - } - Divider() - } - Button(role: .destructive) { - showsClearConfirmation = true - } label: { - Label(String(localized: "Clear Recents"), systemImage: "trash") + Picker(String(localized: "Recent Conversations"), selection: activeConversation) { + ForEach(conversations) { conversation in + Text(conversation.title.isEmpty ? String(localized: "Untitled") : conversation.title) + .tag(Optional(conversation.id)) } - .disabled(viewModel.conversations.isEmpty) } + .pickerStyle(.inline) + .labelsHidden() } label: { - icon("clock") - .accessibilityLabel(String(localized: "Conversation history")) + Label(String(localized: "Conversation History"), systemImage: "text.bubble") } - .menuStyle(.button) - .buttonStyle(.borderless) - .menuIndicator(.hidden) - .frame(width: 24, height: 22) - .contentShape(Rectangle()) - .help(String(localized: "Conversation history")) + .disabled(conversations.isEmpty) } - private func icon(_ systemName: String) -> some View { - Image(systemName: systemName) - .font(.subheadline) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) + private var conversations: [AIConversation] { + state.viewModelIfActivated?.conversations ?? [] + } + + /// The chosen conversation travels on an `NSMenuItem` because that is how the command names one: + /// `switchAIConversation(_:)` reads `representedObject`, and the item is built by the same class + /// that builds the menu bar's rows, so there is one answer to how a conversation is named to the + /// window rather than one per surface. + private var activeConversation: Binding { + Binding( + get: { state.viewModelIfActivated?.activeConversationID }, + set: { id in + guard let id, let conversation = conversations.first(where: { $0.id == id }) else { return } + let sender = ConversationHistoryMenuDelegate.item(for: conversation, isActive: false) + NSApp.sendAction(ConversationHistoryMenuDelegate.action, to: nil, from: sender) + } + ) } } diff --git a/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift index a24b299cd..0fac5c11e 100644 --- a/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift @@ -26,6 +26,7 @@ internal struct TypePickerFieldView: View { } .buttonStyle(.borderless) .disabled(context.isReadOnly) + .help(String(localized: "Choose Type")) .accessibilityLabel(String(localized: "Choose Type")) .popover(isPresented: $isPickerPresented) { UserDefinedTypeAwarePicker(scope: context.userDefinedTypeScope) { userDefinedTypes in diff --git a/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift b/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift index 2e8e492ff..e75f110ec 100644 --- a/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift +++ b/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift @@ -33,6 +33,9 @@ internal struct ValuePickerFieldView: View { .menuIndicator(.visible) .fixedSize() .disabled(context.isReadOnly) + /// The tooltip only. The name already comes from the label's title, and an accessibility + /// label modifier on a `Menu` replaces that name with nothing rather than adding one. + .help(String(localized: "Choose Value")) /// A SwiftUI `Menu` is an `NSPopUpButton` on macOS, so it publishes `menuButton` or /// `popUpButton` and never `button`, and no suite has ever resolved one by its label. /// The identifier is the hook `InspectorFieldRow`'s own value menu already carries, and diff --git a/TablePro/Views/RowInspector/InspectorFieldRow.swift b/TablePro/Views/RowInspector/InspectorFieldRow.swift index 9b6259946..af27eda1b 100644 --- a/TablePro/Views/RowInspector/InspectorFieldRow.swift +++ b/TablePro/Views/RowInspector/InspectorFieldRow.swift @@ -134,13 +134,14 @@ internal struct InspectorFieldRow: View { } /// The unsaved-edit marker sits at the trailing end rather than in front of the name, so - /// recording an edit cannot shift the name it belongs to. + /// recording an edit cannot shift the name it belongs to. It is the glyph the filter bar's + /// Show edited fields only toggle draws, so the two read as one idea, and not a coloured dot. @ViewBuilder private var modifiedGlyph: some View { if isModified { - Circle() - .fill(Color.accentColor) - .frame(width: 5, height: 5) + Image(systemName: "pencil.line") + .font(.caption2) + .foregroundStyle(Color.accentColor) .accessibilityHidden(true) } } diff --git a/TablePro/Views/RowInspector/InspectorHeaderView.swift b/TablePro/Views/RowInspector/InspectorHeaderView.swift deleted file mode 100644 index e5bc14b8b..000000000 --- a/TablePro/Views/RowInspector/InspectorHeaderView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// InspectorHeaderView.swift -// TablePro -// - -import SwiftUI - -/// The inspector's title bar: what is being inspected, and which rendering of it is showing. -/// -/// The pane carried no title at all before, because it multiplexed three unrelated surfaces and -/// there was nothing one title could name. `NSSplitViewItemAccessoryViewController` is the -/// sanctioned host for a pane header and is macOS 26 only, so at a macOS 14 target this is drawn -/// by hand above the content. -internal struct InspectorHeaderView: View { - internal let subject: InspectorSubject - @Binding internal var viewMode: InspectorViewMode - internal let showsViewModePicker: Bool - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - VStack(alignment: .leading, spacing: 1) { - if let title = subject.title { - Text(title) - .font(.headline) - .lineLimit(1) - .truncationMode(.middle) - .help(title) - } - if let subtitle = subject.subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .accessibilityIdentifier("inspector-subject-subtitle") - } - } - Spacer(minLength: 6) - if showsViewModePicker { - viewModePicker - } - } - .padding(.horizontal, InspectorMetrics.horizontalInset) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - } - - /// Both segments are renderings of the same selection, which is the case Apple's inspector - /// guidance covers. The assistant used to be a third segment here, which is exactly what this - /// control must not be: a chat is not a view of the selected row. - private var viewModePicker: some View { - Picker("", selection: $viewMode) { - ForEach(InspectorViewMode.allCases, id: \.self) { mode in - Text(mode.localizedTitle).tag(mode) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - .controlSize(.small) - .accessibilityLabel(String(localized: "Inspector View")) - } -} diff --git a/TablePro/Views/RowInspector/InspectorSubjectView.swift b/TablePro/Views/RowInspector/InspectorSubjectView.swift new file mode 100644 index 000000000..b48ccc016 --- /dev/null +++ b/TablePro/Views/RowInspector/InspectorSubjectView.swift @@ -0,0 +1,41 @@ +// +// InspectorSubjectView.swift +// TablePro +// + +import SwiftUI + +/// What the inspector is inspecting: the table, and which row of how many. +/// +/// It used to share a row with the Fields / JSON control as the inspector's own header. The pane's +/// header is now one view that every surface draws, and a two-line title in it would give the +/// inspector's header a different height from the assistant's, so the subject is the first thing in +/// the inspector's content instead. An empty subject draws nothing rather than a blank line. +internal struct InspectorSubjectView: View { + internal let subject: InspectorSubject + + internal var body: some View { + if subject.title != nil || subject.subtitle != nil { + VStack(alignment: .leading, spacing: 1) { + if let title = subject.title { + Text(title) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + .help(title) + } + if let subtitle = subject.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .accessibilityIdentifier("inspector-subject-subtitle") + } + } + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.top, 6) + .padding(.bottom, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift index d4e299468..1fea0432f 100644 --- a/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift +++ b/TablePro/Views/RowInspector/JSON/JSONRowInspectorView.swift @@ -44,13 +44,12 @@ struct JSONRowInspectorView: View { // MARK: - Toolbar + /// The filter field alone. Copy Visible, the expansion commands and Always Expand Foreign Keys + /// are in the pane header's menu, which is where every surface keeps its commands. private var toolbar: some View { - HStack(spacing: 6) { - filterField - optionsMenu - } - .padding(.horizontal, 10) - .padding(.vertical, 6) + filterField + .padding(.horizontal, 10) + .padding(.vertical, 6) } /// The same `NSSearchField` the Details tab beside it uses. @@ -75,36 +74,6 @@ struct JSONRowInspectorView: View { : String(localized: "Filter keys and values. Wrap in slashes for a regular expression.")) } - private var optionsMenu: some View { - Menu { - Button(String(localized: "Copy Visible")) { viewModel.copyVisible() } - Divider() - Button(String(localized: "Collapse All")) { viewModel.collapseAll() } - Button(String(localized: "Expand All")) { viewModel.expandAll() } - Divider() - Toggle( - String(localized: "Always Expand Foreign Keys"), - isOn: Binding( - get: { viewModel.alwaysExpandForeignKeys }, - set: { viewModel.setAlwaysExpandForeignKeys($0) } - ) - ) - } label: { - Image(systemName: "ellipsis") - .font(.subheadline) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) - .frame(width: 22, height: 20) - .contentShape(Rectangle()) - .accessibilityLabel(String(localized: "JSON view options")) - } - .menuStyle(.button) - .buttonStyle(.borderless) - .menuIndicator(.hidden) - .fixedSize() - .help(String(localized: "JSON view options")) - } - // MARK: - Tree @ViewBuilder diff --git a/TablePro/Views/RowInspector/RowInspectorView.swift b/TablePro/Views/RowInspector/RowInspectorView.swift index 06b8c4ac0..67c2f4270 100644 --- a/TablePro/Views/RowInspector/RowInspectorView.swift +++ b/TablePro/Views/RowInspector/RowInspectorView.swift @@ -12,26 +12,76 @@ import SwiftUI /// grows in place and the pop-out windows take anything larger, so the fields around it never go /// away. internal struct RowInspectorView: View { - @ObservedObject internal var state: RowInspectorState - internal let connection: DatabaseConnection + @ObservedObject private var state: RowInspectorState + private let paneState: TrailingPaneState + private let contentMode: ConnectionWorkspaceContentMode + private let connection: DatabaseConnection @Environment(\.commandActions) private var commandActions + internal init( + paneState: TrailingPaneState, + contentMode: ConnectionWorkspaceContentMode, + connection: DatabaseConnection + ) { + _state = ObservedObject(wrappedValue: paneState.inspector) + self.paneState = paneState + self.contentMode = contentMode + self.connection = connection + } + private var context: RowInspectorContext { state.context } var body: some View { VStack(spacing: 0) { - InspectorHeaderView( - subject: context.subject, - viewMode: $state.viewMode, - showsViewModePicker: context.hasRow && context.jsonRow != nil - ) - Divider() + TrailingPaneHeaderView( + surface: .inspector, + contentMode: contentMode, + paneState: paneState, + inspectorRendering: offeredRendering + ) { section in + menuSection(section) + } + InspectorSubjectView(subject: context.subject) content } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + @ViewBuilder + private func menuSection(_ section: TrailingPaneMenuSection) -> some View { + switch section { + case .inspectorRendering: + renderingPicker + case .jsonReading: + JSONReadingCommands(viewModel: state.jsonViewModel) + case .conversations, .clearRecents, .resultView: + EmptyView() + } + } + + /// Both renderings are views of the same selection, which is the case Apple's inspector guidance + /// covers. The header offers this only while `offeredRendering` has a value, and there the stored + /// mode is the one drawn, so the item it checks is the one on screen. + private var renderingPicker: some View { + Picker(String(localized: "Inspector View"), selection: $state.viewMode) { + ForEach(InspectorViewMode.allCases, id: \.self) { mode in + Text(mode.localizedTitle).tag(mode) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + + /// The rendering on screen, for a selection that can be drawn both ways. A schema grid's + /// selection is a column definition with no types and no foreign keys to follow, and a pane with + /// no row draws table info or nothing, so neither offers a choice: the stored mode would be + /// checked there over a pane drawing something else. + private var offeredRendering: InspectorViewMode? { + guard context.hasRow, context.jsonRow != nil else { return nil } + return showsFields ? .fields : .json + } + /// The field list stays mounted and is hidden rather than rebuilt. /// /// It owns its search term, its edited-only setting, which field is expanded, which field has @@ -90,10 +140,13 @@ internal struct RowInspectorView: View { ) } + /// The inspector's own glyph rather than `sidebar.right`, which is the pane and which the pane's + /// not-connected state drew too: a row not being selected and a connection being down read the + /// same. private var emptyState: some View { UnavailableStateView( String(localized: "No Row Selected"), - systemImage: "sidebar.right", + systemImage: TrailingPaneSurface.inspector.symbolName, description: Text(String(localized: "Select a row to see its fields")) ) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -135,3 +188,28 @@ internal struct RowInspectorView: View { } } } + +/// The JSON rendering's own commands, in the pane header's menu while that rendering is on screen. +/// +/// They were an ellipsis of their own at the end of the JSON filter field, which put two ellipsis +/// menus one above the other in the same column once the pane's header gained one. Its own view so +/// it observes the reader's model: the inspector does not, and a menu built from it would go on +/// showing Always Expand Foreign Keys in the state it had when the pane last redrew. +private struct JSONReadingCommands: View { + @ObservedObject var viewModel: JSONRowInspectorViewModel + + var body: some View { + Button(String(localized: "Copy Visible")) { viewModel.copyVisible() } + Divider() + Button(String(localized: "Collapse All")) { viewModel.collapseAll() } + Button(String(localized: "Expand All")) { viewModel.expandAll() } + Divider() + Toggle( + String(localized: "Always Expand Foreign Keys"), + isOn: Binding( + get: { viewModel.alwaysExpandForeignKeys }, + set: { viewModel.setAlwaysExpandForeignKeys($0) } + ) + ) + } +} diff --git a/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift b/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift new file mode 100644 index 000000000..a17914e6f --- /dev/null +++ b/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift @@ -0,0 +1,171 @@ +// +// TrailingPaneHeaderView.swift +// TablePro +// + +import SwiftUI + +/// One height for the header on every surface, so switching surface moves nothing beneath it. +/// +/// Fixed rather than grown from padding, because the leading slot is a picker on one surface and a +/// title on another and the two are not the same height: measured on macOS 27, the small segmented +/// control is 20pt and a headline 18pt, beside a menu whose hit target is 22pt. Grown from its +/// content, the header changed height with the surface, which is the jump this view exists to remove. +internal enum TrailingPaneHeaderMetrics { + internal static let height: CGFloat = 30 +} + +/// The trailing pane's header, drawn by each surface at its own top. +/// +/// A picker between the surfaces the user may choose, or the surface's name where there is nothing to +/// choose, and one menu of that surface's commands. `TrailingPaneHeaderModel` decides all of it, so +/// the three surfaces cannot draw it three ways again; this view only draws what the model says, and +/// each surface supplies the commands in the menu sections the model names. +internal struct TrailingPaneHeaderView: View { + private let surface: TrailingPaneSurface + private let contentMode: ConnectionWorkspaceContentMode + private let paneState: TrailingPaneState? + private let inspectorRendering: InspectorViewMode? + private let hasContent: Bool + private let menuSection: (TrailingPaneMenuSection) -> MenuSectionContent + + /// Read live rather than captured when the pane was built. Turning the assistant off changes + /// nothing the pane's render key holds while browsing, so a captured value would go on offering + /// the assistant's segment over a pane that can no longer draw it. + @ObservedObject private var settings = AppSettingsManager.shared + + /// `paneState` is nil for the result column, whose surface the mode chose, and for a connection + /// whose session went and took its pane state with it. Either way there is no stored surface to + /// write, so the header names its surface instead of offering a picker that could not answer. + internal init( + surface: TrailingPaneSurface, + contentMode: ConnectionWorkspaceContentMode, + paneState: TrailingPaneState?, + inspectorRendering: InspectorViewMode? = nil, + hasContent: Bool = true, + @ViewBuilder menuSection: @escaping (TrailingPaneMenuSection) -> MenuSectionContent + ) { + self.surface = surface + self.contentMode = contentMode + self.paneState = paneState + self.inspectorRendering = inspectorRendering + self.hasContent = hasContent + self.menuSection = menuSection + } + + private var model: TrailingPaneHeaderModel { + TrailingPaneHeaderModel( + surface: surface, + contentMode: contentMode, + isAIEnabled: settings.ai.enabled, + inspectorRendering: inspectorRendering, + hasContent: hasContent + ) + } + + internal var body: some View { + let model = self.model + VStack(spacing: 0) { + HStack(spacing: 8) { + leading(model) + Spacer(minLength: 8) + if !model.menuSections.isEmpty { + menu(model) + } + } + .padding(.horizontal, InspectorMetrics.horizontalInset) + .frame(height: TrailingPaneHeaderMetrics.height) + Divider() + } + } + + @ViewBuilder + private func leading(_ model: TrailingPaneHeaderModel) -> some View { + if model.showsPicker, let paneState { + TrailingPaneSurfacePicker(state: paneState, segments: model.segments) + } else { + Text(model.title) + .font(.headline) + .lineLimit(1) + .accessibilityAddTraits(.isHeader) + } + } + + /// The label is a `Label` with its title hidden, not an image with an accessibility label on the + /// menu: `.accessibilityLabel` on a `Menu` replaces the name its label provides with nothing. + private func menu(_ model: TrailingPaneHeaderModel) -> some View { + Menu { + ForEach(Array(model.menuSections.enumerated()), id: \.element) { index, section in + if index > 0 { + Divider() + } + menuSection(section) + } + } label: { + Label(model.menuLabel, systemImage: "ellipsis") + .labelStyle(.iconOnly) + .font(.subheadline) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .buttonStyle(.borderless) + .menuIndicator(.hidden) + .frame(width: 24, height: 22) + .help(model.menuLabel) + .accessibilityIdentifier("trailing-pane-menu") + } +} + +/// The surface picker, observing the connection's pane state itself so that the segment it draws is +/// the surface the controller parents next rather than the one the pane was built with. +private struct TrailingPaneSurfacePicker: View { + @ObservedObject var state: TrailingPaneState + let segments: [TrailingPaneSurface] + + var body: some View { + Picker(String(localized: "Pane"), selection: selection) { + ForEach(segments, id: \.self) { segment in + Self.symbol(for: segment) + .help(segment.localizedTitle) + .tag(segment) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .controlSize(.small) + .accessibilityIdentifier("trailing-pane-surface") + } + + /// The segment's name travels in the symbol image's own description. Measured on macOS 27, a + /// segmented picker names each segment from the image it was handed and never reads + /// `.accessibilityLabel` on the view, so `Image(systemName:)` published "info" and "sparkle" to + /// VoiceOver whatever label it carried. The image is the same template at the same size. + private static func symbol(for segment: TrailingPaneSurface) -> Image { + guard let image = NSImage( + systemSymbolName: segment.symbolName, + accessibilityDescription: segment.localizedTitle + ) else { + return Image(systemName: segment.symbolName) + } + return Image(nsImage: image) + } + + /// Writes the stored surface and nothing else. A segment is a choice the user made, so it is + /// remembered for the connection, which a suggestion such as a grid click never is. The controller + /// watches the state of the connection on screen and parents the new surface on the next turn of + /// the run loop, so the view whose segment was clicked leaves the window after its action has + /// returned rather than from inside it. + private var selection: Binding { + Binding( + get: { state.surface }, + set: { surface in + guard surface.isUserSelectable else { return } + state.surface = surface + } + ) + } +} diff --git a/TablePro/Views/Sidebar/SidebarScopeControl.swift b/TablePro/Views/Sidebar/SidebarScopeControl.swift new file mode 100644 index 000000000..7d2a4fe3b --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarScopeControl.swift @@ -0,0 +1,64 @@ +// +// SidebarScopeControl.swift +// TablePro +// + +import AppKit + +/// Which list the sidebar shows, as its own row above the filter field. +/// +/// It sits over the list it switches, where Xcode keeps its navigator chooser, rather than in the +/// titlebar. In the toolbar it held two permanent hit targets in a row that also has to carry the +/// connection, its container and the content commands, and it answered for a pane the window could +/// have collapsed. +/// +/// Worded segments rather than glyphs. The toolbar version drew `list.bullet` and `star`, and with +/// no description on either image VoiceOver announced them as "List" and "favorite". Measured on +/// macOS 27, a worded control publishes a radio group whose two radio buttons carry the segments' +/// own labels, "Tables" and "Favorites", so it cannot name itself wrongly. +/// +/// The words fit in every language the app ships. Measured at the sidebar's 280pt minimum, which +/// leaves the row 260pt inside its insets, the widest is Turkish at 238pt, English needs 158pt and +/// Simplified Chinese 100pt. +@MainActor +internal final class SidebarScopeControl: NSSegmentedControl { + internal static let tabs: [SidebarTab] = [.tables, .favorites] + + internal init() { + super.init(frame: .zero) + segmentCount = Self.tabs.count + trackingMode = .selectOne + segmentDistribution = .fillEqually + controlSize = .regular + for (index, tab) in Self.tabs.enumerated() { + setLabel(Self.title(for: tab), forSegment: index) + } + setAccessibilityIdentifier("sidebar-scope") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("SidebarScopeControl does not support NSCoder init") + } + + /// Nil draws no segment selected, which is the state a window with no connection shows. + internal var selectedTab: SidebarTab? { + get { + Self.tabs.indices.contains(selectedSegment) ? Self.tabs[selectedSegment] : nil + } + set { + let index = newValue.flatMap { Self.tabs.firstIndex(of: $0) } ?? -1 + guard selectedSegment != index else { return } + selectedSegment = index + } + } + + internal static func title(for tab: SidebarTab) -> String { + switch tab { + case .tables: + String(localized: "Tables") + case .favorites: + String(localized: "Favorites") + } + } +} diff --git a/TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift b/TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift index 31cc3c151..18c35dc06 100644 --- a/TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift +++ b/TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift @@ -20,7 +20,9 @@ import SwiftUI /// Two surfaces, chosen by whether an anchor exists: /// - The item is in a visible toolbar: an `NSPopover` anchored to it, which is the macOS idiom for /// a toolbar control that reveals a chooser. A clipped item still resolves, and AppKit presents it -/// "from another appropriate affordance in the window" itself. +/// "from another appropriate affordance in the window" itself: measured on macOS 27 at a 420pt +/// window, both centred items anchored on the clipped-items indicator, a 36pt square, and neither +/// raised. /// - No anchor: the same content in the floating panel Open Quickly already uses, which belongs to /// the window rather than to the toolbar. @MainActor @@ -60,9 +62,13 @@ internal final class ToolbarSwitcherPresenter { /// `subject` is what makes "the same command" answerable. One presenter serves the connection /// chooser and the container chooser, so an identity check on presentation alone would make /// either command close the other rather than replace it. + /// + /// `hiddenBy` is the toolbar's own record of what it took out of the titlebar, forwarded to + /// `anchor(in:_:hiddenBy:)`. internal func present( from window: NSWindow?, anchoredTo identifier: NSToolbarItem.Identifier, + hiddenBy visibility: ToolbarVisibility?, subject: Subject, contentSize: NSSize, @ViewBuilder content: (_ dismiss: @escaping () -> Void) -> some View @@ -74,7 +80,7 @@ internal final class ToolbarSwitcherPresenter { } presentedSubject = subject - if let item = Self.anchor(in: window, identifier) { + if let item = Self.anchor(in: window, identifier, hiddenBy: visibility) { /// `.transient`, not `PopoverPresenter`'s `.semitransient` default: a semitransient /// popover ignores interaction outside its own window, so moving to another window or /// another app would leave the chooser floating over a window it no longer belongs to. @@ -125,14 +131,36 @@ internal final class ToolbarSwitcherPresenter { popover = nil } + /// The toolbar item a chooser presents from, or nil for the floating panel. + /// + /// It asks AppKit nothing about what is on screen, because nothing AppKit answers is safe to + /// act on. Measured on macOS 27, one visit to Customize Toolbar leaves `NSToolbar.visibleItems` + /// and `NSToolbarItem.isVisible` over-reporting for good, the item's `view` is nil for every + /// native item, and the titlebar's view hierarchy keeps a stale run of item viewers behind. So + /// the question splits in two, and neither half is a visibility reading. + /// + /// Whether the item is reachable is the app's own record: an item the resolver hid answers nil, + /// and the chooser takes the floating panel rather than a popover anchored on nothing, which + /// AppKit would place at the centre of the window. Which instance to anchor on is + /// `NSToolbar.items`, measured correct through every palette visit, down to each item's + /// identity. An item the user removed is absent from it, and answers nil the same way. + /// + /// A clipped item is neither, and needs no answer: every item here is a top-level item, and + /// AppKit anchors a clipped top-level item on the clipped-items indicator by itself. Only a + /// subitem of a group that was off screen ever raised, and there are no subitems left to anchor + /// on. + /// + /// `hiddenBy` has no default. Nil is a real answer, a toolbar with no resolver that hides + /// nothing, and a caller has to say so rather than get it by omission. + /// /// A hidden toolbar is treated as no anchor at all. `toggleToolbarShown` only flips /// `NSToolbar.isVisible` and leaves the items in place, so the item still resolves and AppKit - /// documents nothing about what anchoring to it then does. Since the failure mode of guessing - /// wrong is an `NSInvalidArgumentException` that Swift cannot catch, this takes the branch it - /// can reason about instead of the one it would have to measure. + /// documents nothing about what anchoring to it then does. That property belongs to the toolbar + /// rather than to an item, and it is measured to read correctly after a palette visit. internal static func anchor( in window: NSWindow?, - _ identifier: NSToolbarItem.Identifier + _ identifier: NSToolbarItem.Identifier, + hiddenBy visibility: ToolbarVisibility? ) -> NSToolbarItem? { /// Anchoring a popover on a toolbar item is macOS 14, and an item whose view AppKit /// generates reports `view` as nil, so there is nothing to anchor on below it. Answering @@ -140,37 +168,7 @@ internal final class ToolbarSwitcherPresenter { /// toolbar already takes. guard #available(macOS 14.0, *) else { return nil } guard let toolbar = window?.toolbar, toolbar.isVisible else { return nil } - return anchor(identifier, in: toolbar.items, visible: toolbar.visibleItems ?? []) - } - - /// The anchor for an identifier that may name a subitem of a group rather than an item the - /// toolbar carries directly. - /// - /// The connection and the container are two subitems of one centred native group, and anchoring - /// both choosers to the group put each of them on the seam between the two capsules rather than - /// under the one it belongs to. Measured on a 1200pt window: the group's midpoint is 600.0, the - /// Connection capsule's is 543.2 and the Container capsule's is 671.8, and a popover anchored to - /// the group lands at 600.0 for both. A subitem does resolve as an anchor and lands on its own - /// capsule to within a point, even though `NSToolbar.items` lists groups only and a native - /// group's subitems carry no `view`. - /// - /// It resolves only while the group is on screen. Once AppKit clips the group into the overflow - /// menu its subitems have no view and `NSPopover.show(relativeTo:)` raises - /// `NSInvalidArgumentException` ("view has no window"), which Swift cannot catch; measured, that - /// is exactly the width at which `visibleItems` stops naming the group. The group keeps working - /// there, because AppKit presents a clipped item from another affordance in the window itself, - /// so an overflowed group is the fallback rather than the floating panel. - internal static func anchor( - _ identifier: NSToolbarItem.Identifier, - in items: [NSToolbarItem], - visible: [NSToolbarItem] - ) -> NSToolbarItem? { - if let item = items.first(where: { $0.itemIdentifier == identifier }) { return item } - let groups = items.compactMap { $0 as? NSToolbarItemGroup } - guard let group = groups.first(where: { group in - group.subitems.contains { $0.itemIdentifier == identifier } - }) else { return nil } - guard visible.contains(where: { $0.itemIdentifier == group.itemIdentifier }) else { return group } - return group.subitems.first { $0.itemIdentifier == identifier } + guard visibility?.hides(identifier) != true else { return nil } + return toolbar.items.first { $0.itemIdentifier == identifier } } } diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 785c113b0..7561e7337 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -256,6 +256,52 @@ struct MainMenuShortcutCoverageTests { #expect(item?.keyEquivalentModifierMask == [.command, .shift]) } + /// The eight commands the revamp made rebindable. Each was reachable only by pointer before: + /// two segments of a toolbar control, an Edit menu item with no action identifier at all, and + /// five buttons inside Agent mode's rail and the assistant pane's header menu. + private static let displacedCommands: [(action: ShortcutAction, title: String)] = [ + (.showTablesList, String(localized: "Show Tables")), + (.showFavoritesList, String(localized: "Show Favorites")), + (.restorePreviousValues, String(localized: "Restore Previous Values…")), + (.newAgentSession, String(localized: "New Session")), + (.openAgentSession, String(localized: "Open Session")), + (.closeAgentSession, String(localized: "Close Session")), + (.deleteAgentSession, String(localized: "Delete Session…")), + (.newAIConversation, String(localized: "New Conversation")), + ] + + @Test("Each newly rebindable command is stamped on the menu item that runs it") + func displacedCommandsReachTheirMenuItem() { + let items = flatten(buildMenu()) + for command in Self.displacedCommands { + let matches = items.filter { $0.identifier == MenuItemFactory.identifier(for: command.action) } + #expect(matches.count == 1, "\(command.action.rawValue) is on \(matches.count) items, expected 1") + #expect(matches.first?.title == command.title, "\(command.action.rawValue) is on the wrong item") + } + } + + /// Shipped unbound on purpose. Every combo a reasonable person would reach for is taken, and a + /// default that displaced a shipped one would be a worse trade than an unassigned row in + /// Settings, which is where these are now visible for the first time. + @Test("Each newly rebindable command ships with no key equivalent of its own") + func displacedCommandsShipUnbound() { + let items = flatten(buildMenu()) + for command in Self.displacedCommands { + #expect(KeyboardSettings.defaultShortcuts[command.action] == nil, "\(command.action.rawValue)") + let item = items.first { $0.identifier == MenuItemFactory.identifier(for: command.action) } + #expect(item?.keyEquivalent.isEmpty == true, "\(command.action.rawValue) arrived with a binding") + } + } + + /// Settings lists every action by this name, so two sharing one would offer the user two + /// identical rows and no way to tell which command they were rebinding. + @Test("No two actions share a display name") + func displayNamesAreUnique() { + let names = ShortcutAction.allCases.map(\.displayName) + let duplicates = Dictionary(grouping: names, by: { $0 }).filter { $0.value.count > 1 }.keys + #expect(duplicates.isEmpty, "Two shortcut actions share a name in Settings: \(duplicates)") + } + @Test("Jump to Column… sits in the Edit menu's Find submenu on Cmd+Shift+J") func jumpToColumnLivesUnderFind() { let edit = buildMenu().items.first { $0.title == String(localized: "Edit") }?.submenu @@ -269,6 +315,88 @@ struct MainMenuShortcutCoverageTests { } } +/// Agent mode's sessions and the assistant's conversations had no menu-bar home at all: the rail's +/// buttons and the trailing pane's header menu were the only routes, so none of the seven commands +/// could be found by search, rebound, or reached with the rail collapsed or the pane closed. +@Suite("File > Session") +@MainActor +struct FileSessionMenuTests { + private func sessionMenu() -> NSMenu? { + buildMenu().items.first { $0.title == String(localized: "File") }? + .submenu?.items.first { $0.title == String(localized: "Session") }? + .submenu + } + + @Test("The submenu carries the session lifecycle and the conversation commands, in that order") + func sessionMenuOrder() throws { + let titles = try #require(sessionMenu()).items.map(\.title) + #expect(titles == [ + String(localized: "New Session"), + String(localized: "Open Session"), + String(localized: "Recent Sessions"), + String(localized: "Close Session"), + String(localized: "Delete Session…"), + "", + String(localized: "New Conversation"), + String(localized: "Conversation History"), + String(localized: "Clear Recents…"), + ]) + } + + /// The two list rows are exempt: AppKit points a submenu container at its own `submenuAction:`, + /// and the rows inside are built by the delegate when the list opens. + @Test("Every leaf carries an action and leaves its target nil") + func everyLeafIsACommand() throws { + let leaves = try #require(sessionMenu()).items.filter { !$0.isSeparatorItem && $0.submenu == nil } + #expect(leaves.count == 6) + for leaf in leaves { + #expect(leaf.action != nil, "\(leaf.title) can never enable") + #expect(leaf.target == nil, "\(leaf.title) bypasses responder-chain validation") + } + } + + /// AppKit ignores a key equivalent on an item that owns a submenu, so the command a user can + /// rebind has to be a leaf. Open Session acts on the session the rail has highlighted, and the + /// list beside it is how any other session is reached, exactly as Import Data… and Import Data + /// From are split. + @Test("Open Session is a leaf, so a binding it is given can fire") + func openSessionIsALeaf() throws { + let item = try #require( + sessionMenu()?.items.first { $0.title == String(localized: "Open Session") } + ) + #expect(item.submenu == nil) + #expect(item.action == #selector(MainSplitViewController.openAgentSession(_:))) + #expect(item.identifier == MenuItemFactory.identifier(for: .openAgentSession)) + } + + @Test("Both lists fill themselves when they open", arguments: [ + String(localized: "Recent Sessions"), String(localized: "Conversation History"), + ]) + func listsAreDelegateDriven(title: String) throws { + let submenu = try #require(sessionMenu()?.items.first { $0.title == title }?.submenu) + #expect(submenu.delegate != nil, "The set changes while the menu is closed, so it is built on open") + #expect(submenu.items.isEmpty, "The list is filled when it opens, not at build time") + } + + /// `AIChatViewModel` is a plain `ObservableObject` and `AgentSessionRegistry` is not a responder, + /// so a command named on either would reach nothing and AppKit would draw it dead. Every one of + /// these names a window selector instead, including the two lists' rows. + @Test("Each command reaches the window rather than a view model nothing can resolve") + func everyCommandIsAWindowSelector() throws { + var actions = try #require(sessionMenu()).items + .filter { $0.submenu == nil } + .compactMap(\.action) + #expect(actions.count == 6) + actions.append(contentsOf: [AgentSessionMenuDelegate.action, ConversationHistoryMenuDelegate.action]) + for action in actions { + #expect( + MainSplitViewController.instancesRespond(to: action), + "\(NSStringFromSelector(action)) reaches nothing, so AppKit draws it dead" + ) + } + } +} + @Suite("Main menu validation") @MainActor struct MainMenuValidationTests { diff --git a/TableProTests/Core/Menu/SafeModeMenuDelegateTests.swift b/TableProTests/Core/Menu/SafeModeMenuDelegateTests.swift new file mode 100644 index 000000000..ba1e107b4 --- /dev/null +++ b/TableProTests/Core/Menu/SafeModeMenuDelegateTests.swift @@ -0,0 +1,118 @@ +// +// SafeModeMenuDelegateTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +/// The Safe Mode list, which the Database menu's submenu and the toolbar control both open. +/// +/// It used to list every level whatever held the connection, with nothing saying why a weaker one +/// changed nothing: Agent mode raised the floor to Alert silently, and a pick of Silent was stored +/// while the level on screen stayed put. +@Suite("Safe Mode list") +@MainActor +struct SafeModeMenuDelegateTests { + private static let agentFloor = SafeModeFloor(level: .alert, reason: .agentMode) + + private static func levelItems(in menu: NSMenu) -> [NSMenuItem] { + menu.items.filter { $0.action == #selector(MainSplitViewController.setSafeModeLevel(_:)) } + } + + @Test("Under a floor the list offers only the levels the floor allows") + func listOffersOnlyAllowedLevels() { + let menu = NSMenu() + + SafeModeMenuDelegate.populate(menu, with: SafeModeStatus(level: .alert, floor: Self.agentFloor)) + + let offered = Self.levelItems(in: menu).compactMap { ($0.representedObject as? String).flatMap(SafeModeLevel.init) } + #expect(offered == SafeModeFloor.levels(allowedBy: Self.agentFloor)) + #expect(!offered.contains(.silent)) + } + + @Test("The checkmark is on the level in force") + func checkmarkFollowsTheLevelInForce() { + let menu = NSMenu() + + SafeModeMenuDelegate.populate(menu, with: SafeModeStatus(level: .safeMode, floor: Self.agentFloor)) + + let checked = Self.levelItems(in: menu).filter { $0.state == .on } + #expect(checked.map(\.title) == [SafeModeLevel.safeMode.displayName]) + } + + /// The floor's reason used to be written in exactly one place, the connection form's Options + /// pane, so a window in Agent mode never said why its level would not go down. + @Test("The floor's reason closes the list as a disabled footnote", arguments: [ + SafeModeFloor(level: .alert, reason: .agentMode), + SafeModeFloor(level: .readOnly, reason: .readOnlyEngine), + SafeModeFloor(level: .readOnly, reason: .remoteDatabaseFile), + SafeModeFloor(level: .safeMode, reason: .managedPolicy), + ]) + func floorReasonIsTheFootnote(floor: SafeModeFloor) throws { + let menu = NSMenu() + + SafeModeMenuDelegate.populate(menu, with: SafeModeStatus(level: floor.level, floor: floor)) + + let footnote = try #require(menu.items.last) + #expect(menu.items.dropLast().last?.isSeparatorItem == true) + #expect(!footnote.isEnabled) + #expect(footnote.action == nil) + #expect(Self.unwrapped(footnote) == floor.explanation) + } + + @Test("With no floor every level is listed and nothing is explained") + func noFloorListsEveryLevel() { + let menu = NSMenu() + + SafeModeMenuDelegate.populate(menu, with: SafeModeStatus(level: .silent, floor: nil)) + + #expect(Self.levelItems(in: menu).count == SafeModeLevel.allCases.count) + #expect(menu.items.count == SafeModeLevel.allCases.count) + } + + /// A window with no session behind it has no level to check and no floor to explain, and the + /// window's validation dims the entries. + @Test("With no session every level is listed, unchecked") + func noSessionListsEveryLevelUnchecked() { + let menu = NSMenu() + + SafeModeMenuDelegate.populate(menu, with: nil) + + #expect(Self.levelItems(in: menu).count == SafeModeLevel.allCases.count) + #expect(Self.levelItems(in: menu).allSatisfy { $0.state == .off }) + } + + @Test("Opening the list again rebuilds it rather than appending") + func repopulatingReplacesTheItems() { + let menu = NSMenu() + let status = SafeModeStatus(level: .alert, floor: Self.agentFloor) + + SafeModeMenuDelegate.populate(menu, with: status) + let first = menu.items.count + SafeModeMenuDelegate.populate(menu, with: status) + + #expect(menu.items.count == first) + } + + /// A menu item's plain title is one line however long it is, so the reason is broken into lines + /// the width of the list rather than widening the list to fit one. + @Test("The footnote wraps to the list's width instead of widening it") + func footnoteWraps() throws { + let explanation = SafeModeFloor(level: .readOnly, reason: .remoteDatabaseFile).explanation + let item = MenuFootnote.item(explanation) + let lines = try #require(item.attributedTitle?.string.components(separatedBy: "\n")) + + #expect(lines.count > 1) + for line in lines { + let width = (line as NSString).size(withAttributes: [.font: MenuFootnote.font]).width + #expect(width <= MenuFootnote.wrapWidth + 1, "\(line)") + } + #expect(Self.unwrapped(item) == explanation) + } + + private static func unwrapped(_ item: NSMenuItem) -> String? { + item.attributedTitle?.string.replacingOccurrences(of: "\n", with: " ") + } +} diff --git a/TableProTests/Core/Menu/SessionMenuDelegateTests.swift b/TableProTests/Core/Menu/SessionMenuDelegateTests.swift new file mode 100644 index 000000000..ae90e2239 --- /dev/null +++ b/TableProTests/Core/Menu/SessionMenuDelegateTests.swift @@ -0,0 +1,80 @@ +// +// SessionMenuDelegateTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +/// The two lists under File > Session are filled when they open, so what they put in the menu is +/// never seen by the suites that walk the built menu bar. These ask the delegates directly. +@Suite("File > Session lists") +@MainActor +struct SessionMenuDelegateTests { + private func makeRegistry() -> AgentSessionRegistry { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("SessionMenuDelegateTests-\(UUID().uuidString)", isDirectory: true) + return AgentSessionRegistry(store: AgentSessionStore(directory: directory)) + } + + /// An empty menu opens as a sliver with no text, which reads as a broken command, and the row + /// that opens it cannot be dimmed through the responder chain: AppKit gives a submenu's row its + /// own action. This is the state a launch with no connection window is in. + @Test("An empty list names itself rather than opening with nothing in it") + func emptyListsCarryAPlaceholder() { + for delegate in [AgentSessionMenuDelegate() as NSMenuDelegate, ConversationHistoryMenuDelegate()] { + let menu = NSMenu() + menu.delegate = delegate + delegate.menuNeedsUpdate?(menu) + + #expect(menu.items.count == 1) + #expect(menu.items.first?.title == String(localized: "None Available")) + #expect(menu.items.first?.isEnabled == false) + #expect(menu.items.first?.action == nil) + } + } + + /// The session travels in `representedObject`, which is what `agentSessionTarget(for:)` reads to + /// act on the session the row names rather than on the rail's highlight. A row with a target + /// would skip the window's validation, and a row without the id would act on the wrong session. + @Test("A session row names its session and leaves the responder chain to resolve it") + func sessionRowCarriesItsSession() throws { + let registry = makeRegistry() + let session = try #require(registry.resolveSession(for: UUID(), startingIfNeeded: true)) + + let row = AgentSessionMenuDelegate.item(for: session, isDisplayed: true) + #expect(row.title == session.displayTitle) + #expect(row.action == AgentSessionMenuDelegate.action) + #expect(row.target == nil) + #expect(row.representedObject as? UUID == session.id) + #expect(row.state == .on) + + #expect(AgentSessionMenuDelegate.item(for: session, isDisplayed: false).state == .off) + } + + /// A conversation is titled from its first exchange, so one nothing was sent in has no title and + /// would otherwise draw a blank row. + @Test("A conversation row falls back to a name when the conversation has none") + func conversationRowNamesAnUntitledConversation() { + let untitled = AIConversation(title: "") + let named = AIConversation(title: "Late orders") + + #expect(ConversationHistoryMenuDelegate.item(for: untitled, isActive: false).title + == String(localized: "Untitled")) + #expect(ConversationHistoryMenuDelegate.item(for: named, isActive: true).title == "Late orders") + } + + @Test("A conversation row names its conversation and carries the current one's tick") + func conversationRowCarriesItsConversation() { + let conversation = AIConversation(title: "Late orders") + + let row = ConversationHistoryMenuDelegate.item(for: conversation, isActive: true) + #expect(row.action == ConversationHistoryMenuDelegate.action) + #expect(row.target == nil) + #expect(row.representedObject as? UUID == conversation.id) + #expect(row.state == .on) + + #expect(ConversationHistoryMenuDelegate.item(for: conversation, isActive: false).state == .off) + } +} diff --git a/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift b/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift new file mode 100644 index 000000000..8eca4803b --- /dev/null +++ b/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift @@ -0,0 +1,40 @@ +// +// ServerDashboardQueryProviderFactoryTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +/// The support question is asked on every toolbar and menu validation pass, so it is answered +/// without building a provider. It has to give the answer building one would. +@Suite("Server dashboard provider factory") +@MainActor +struct ServerDashboardQueryProviderFactoryTests { + @Test("Support is answered without a provider, and agrees with building one for every known engine") + func supportAgreesWithTheProvider() { + let types = DatabaseType.allKnownTypes + #expect(types.count > 10, "Only \(types.count) known types; the walk would pass vacuously") + for type in types { + #expect( + ServerDashboardQueryProviderFactory.supportsDashboard(for: type) + == (ServerDashboardQueryProviderFactory.provider(for: type) != nil), + "\(type.rawValue)" + ) + } + } + + @Test("An engine no plugin has heard of has no dashboard") + func unknownEngineHasNone() { + let unknown = DatabaseType(rawValue: "com.example.not-a-database") + #expect(!ServerDashboardQueryProviderFactory.supportsDashboard(for: unknown)) + #expect(ServerDashboardQueryProviderFactory.provider(for: unknown) == nil) + } + + @Test("PostgreSQL, MySQL and SQLite each have one") + func knownEnginesHaveOne() { + for type in [DatabaseType.postgresql, .mysql, .mariadb, .sqlite, .redshift, .cockroachdb] { + #expect(ServerDashboardQueryProviderFactory.supportsDashboard(for: type), "\(type.rawValue)") + } + } +} diff --git a/TableProTests/Core/Services/Infrastructure/AgentModeWindowTests.swift b/TableProTests/Core/Services/Infrastructure/AgentModeWindowTests.swift new file mode 100644 index 000000000..a4e62791b --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/AgentModeWindowTests.swift @@ -0,0 +1,859 @@ +// +// AgentModeWindowTests.swift +// TableProTests +// +// Entering or leaving Agent mode used to rebuild the connection's browse tree. The conversation and +// the browse content were two arms of one `@ViewBuilder` conditional in the detail pane, and the +// session rail and the object browser two arms in the sidebar, so every toggle was an identity +// change that threw away grid scroll, cell selection, the editor's find panel and undo stack and an +// unsaved Create Table definition. The mode now reparents panes of its own, and the window stops +// describing a tree it is not drawing: the tab strip, the title, the detail column's minimum and +// the Safe Mode list all follow the swap. +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +@Suite("Agent mode window", .serialized) +@MainActor +struct AgentModeWindowTests { + // MARK: - The browse tree survives + + /// `WindowAccessorView` is the AppKit view behind `MainContentView`'s window accessor, so it + /// lives exactly as long as the browse tree's identity does. A rebuild makes a new one. + @Test("Toggling Agent mode on and off keeps the browse pane and the tree in it") + func toggleKeepsTheBrowseTree() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let detail = harness.selected.panes.detail + let accessor = try #require( + harness.settle { detail.view.firstDescendant(of: WindowAccessorView.self) }, + "The browse content never mounted, so the case proves nothing" + ) + + harness.controller.setContentMode(.agent) + + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + #expect(harness.controller.shownSidebarPane === harness.selected.panes.agentRail) + #expect(detail.view.superview == nil, "The browse pane is unparented, not rebuilt in place") + harness.drain() + + harness.controller.setContentMode(.browse) + + #expect(harness.controller.detailPaneHost.shown === detail) + #expect(harness.controller.shownSidebarPane === harness.selected.panes.sidebar) + let remounted = harness.settle { detail.view.firstDescendant(of: WindowAccessorView.self) } + #expect(remounted === accessor, "The browse tree was rebuilt, and everything only it held went with it") + } + } + + // MARK: - Agent mode's panes are built once + + /// The rail's list is an AppKit outline view behind SwiftUI's `List`, so it too lives exactly as + /// long as the rail's identity does. + @Test("Agent mode's panes are built once and kept across toggles") + func agentPanesAreReused() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let rail = harness.selected.panes.agentRail + let conversation = harness.selected.panes.agentConversation + + harness.controller.setContentMode(.agent) + #expect(harness.controller.shownSidebarPane === rail) + #expect(harness.controller.detailPaneHost.shown === conversation) + let list = try #require( + harness.settle { rail.view.firstDescendant(of: NSTableView.self) }, + "The session rail never listed its session" + ) + + harness.controller.setContentMode(.browse) + #expect(rail.view.superview == nil) + #expect(conversation.view.superview == nil) + harness.drain() + + harness.controller.setContentMode(.agent) + #expect(harness.controller.shownSidebarPane === rail) + #expect(harness.controller.detailPaneHost.shown === conversation) + #expect(harness.settle { rail.view.firstDescendant(of: NSTableView.self) } === list) + } + } + + /// A session already exists, so a rail built while browsing would list it. The pane is mounted + /// in a window of its own to look, because an unmounted pane shows nothing whatever it holds. + @Test("Browsing builds nothing of Agent mode") + func browsingBuildsNoAgentContent() throws { + try AIFeatureScope.enabled { + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + let rail = harness.selected.panes.agentRail + + let probe = PaneProbeWindow(showing: rail.view) + probe.settle() + #expect(rail.view.firstDescendant(of: NSTableView.self) == nil, "The rail was built for a mode nobody entered") + probe.close() + + harness.controller.setContentMode(.agent) + #expect(harness.settle { rail.view.firstDescendant(of: NSTableView.self) } != nil) + } + } + + // MARK: - A background connection + + /// The #2545 shape: panes are built for the new state at once, whether the connection is on + /// screen or not, and parented only when it is selected. + @Test("A connection put into Agent mode in the background shows its conversation once selected") + func backgroundAgentModeIsParentedOnSelection() throws { + try AIFeatureScope.enabled { + let harness = TwoConnectionHarness() + defer { harness.tearDown() } + harness.controller.transition(to: .connecting, for: harness.background.connectionId) + try #require(harness.background.resolvedPane == .connecting) + + harness.controller.setContentMode(.agent, for: harness.background.connectionId) + + #expect(harness.background.panes.renderedKey?.contentMode == .agent) + #expect(harness.controller.detailPaneHost.shown === harness.foreground.panes.detail) + #expect(harness.background.panes.agentConversation.parent == nil) + + harness.controller.workspaces.select(harness.background.connectionId) + + #expect(harness.controller.detailPaneHost.shown === harness.background.panes.agentConversation) + #expect(harness.controller.shownSidebarPane === harness.background.panes.agentRail) + #expect(harness.controller.inspectorPaneHost.shown === harness.background.panes.agentResult) + } + } + + // MARK: - The window describes what it draws + + @Test("Agent mode takes the tab strip down and names the window after the session") + func agentModeDropsTheTabStripAndRenamesTheWindow() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let tabManager = try #require(harness.selected.sessionState?.tabManager) + tabManager.addTab(initialQuery: "SELECT 1") + tabManager.addTab(initialQuery: "SELECT 2") + harness.controller.applyTabStripVisibility() + harness.controller.applyWindowTitle() + try #require(!harness.controller.tabStripAccessory.isHidden, "Two tabs over browse content show the strip") + let browseTitle = harness.controller.windowTitle + + harness.controller.setContentMode(.agent) + + #expect(harness.controller.tabStripAccessory.isHidden) + #expect(harness.controller.windowTitle == ConnectionWorkspaceContentMode.agent.localizedTitle) + #expect(harness.window.title == harness.controller.windowTitle) + + harness.controller.setContentMode(.browse) + + #expect(!harness.controller.tabStripAccessory.isHidden) + #expect(harness.controller.windowTitle == browseTitle) + } + } + + /// A session names itself from its first question or reply, which lands after the mode came on. + @Test("The window follows the session as it gets a name") + func titleFollowsTheSession() async throws { + try await AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + #expect(harness.controller.windowTitle == ConnectionWorkspaceContentMode.agent.localizedTitle) + + session.viewModel.messages.append(ChatTurn(role: .user, blocks: [.text("Which orders shipped late?")])) + + #expect(await harness.suspend { harness.controller.windowTitle == "Which orders shipped late?" }) + #expect(harness.window.title == "Which orders shipped late?") + } + } + + /// The proxy icon is the rest of what the titlebar says. The browse content wrote it straight to + /// the window, so a query file's icon and its Command-click path menu stayed beside the + /// session's name. + @Test("Agent mode takes a file's proxy icon down and browsing puts it back") + func agentModeDropsTheProxyIcon() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("AgentModeWindowTests-\(UUID().uuidString).sql") + try Data("SELECT 1".utf8).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + let tabManager = try #require(harness.selected.sessionState?.tabManager) + tabManager.addTab(initialQuery: "SELECT 1", sourceFileURL: file) + harness.controller.applyWindowTitle() + try #require( + harness.window.representedURL == file, + "The file's tab never set the icon, so the case proves nothing" + ) + + harness.controller.setContentMode(.agent) + #expect(harness.window.representedURL == nil) + harness.drain() + #expect(harness.window.representedURL == nil, "The browse tree behind the conversation put its file back") + + harness.controller.setContentMode(.browse) + #expect(harness.window.representedURL == file) + } + } + + @Test("A Users & Roles tab behind the conversation does not set the detail column's floor") + func agentModeKeepsTheDefaultDetailFloor() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + let tabManager = try #require(harness.selected.sessionState?.tabManager) + tabManager.adoptTab(QueryTab(id: UUID(), title: "Users & Roles", query: "", tabType: .usersRoles)) + harness.controller.updateDetailMinimumThickness(for: .usersRoles, connectionId: harness.connection.id) + try #require(harness.detailItem.minimumThickness == UsersRolesLayoutMetrics.tabMinimumWidth) + + harness.controller.setContentMode(.agent) + #expect(harness.detailItem.minimumThickness == MainSplitViewController.defaultDetailMinThickness) + + harness.controller.updateDetailMinimumThickness(for: .usersRoles, connectionId: harness.connection.id) + #expect( + harness.detailItem.minimumThickness == MainSplitViewController.defaultDetailMinThickness, + "The browse tree reporting its tab from behind the conversation raised it again" + ) + + harness.controller.setContentMode(.browse) + #expect(harness.detailItem.minimumThickness == UsersRolesLayoutMetrics.tabMinimumWidth) + } + } + + /// The error, Retry and Manage Connections live on the browse side's unavailable screen, and a + /// composer with none of them is a dead end. A reconnect hands the column back. + @Test("A connection that drops in Agent mode shows the unavailable screen, and a reconnect the conversation") + func droppedConnectionHandsTheColumnBack() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + + DatabaseManager.shared.removeSession(for: harness.connection.id) + harness.controller.refreshFromActiveSessions() + + #expect(!harness.controller.currentPane.hasContent) + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.detail) + #expect(harness.controller.windowTitle == harness.connection.name) + + harness.inject(status: .connected) + harness.controller.refreshFromActiveSessions() + + #expect(harness.controller.currentPane == .content) + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + } + } + + /// The conversation stays built, detached, behind the unavailable screen of a connection that + /// dropped. A search of its pane still found the composer there, so Focus Assistant stayed + /// enabled and moved focus off Retry and onto the window. + @Test("Focus Assistant is dimmed once the conversation has left the detail column") + func focusAssistantFollowsTheConversationOffScreen() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + /// Stands in for the composer the conversation draws once a session has a provider to + /// answer it, which a unit test has no way to configure. + let composer = ChatComposerNSTextView.make() + harness.selected.panes.agentConversation.view.addSubview(composer) + let item = Self.menuItem(#selector(MainSplitViewController.focusAssistant(_:))) + try #require( + harness.controller.validateMenuItem(item), + "The composer on screen was not reachable, so the case proves nothing" + ) + + DatabaseManager.shared.removeSession(for: harness.connection.id) + harness.controller.refreshFromActiveSessions() + try #require(harness.controller.detailPaneHost.shown === harness.selected.panes.detail) + try #require(composer.superview != nil, "The conversation was torn down rather than kept") + + #expect(!harness.controller.validateMenuItem(item)) + #expect(!harness.controller.focusAssistantPane()) + #expect(harness.window.firstResponder !== composer) + } + } + + // MARK: - One registry + + /// The assistant in the trailing pane and Agent mode have to draw one set of sessions. The pane + /// state the window builds as a session lands took the app's registry whatever registry the + /// workspace had been handed, so the two could name different sessions for one connection. + @Test("The trailing pane state the window builds shares the workspace's registry") + func builtPaneStateSharesTheWorkspaceRegistry() throws { + try AIFeatureScope.enabled { + let harness = try Harness(prebuildsPaneState: false) + defer { harness.tearDown() } + try harness.requireContent() + let paneState = try #require(harness.selected.trailingPaneState, "The window built no pane state") + + let session = harness.agentSessions.startSession(for: harness.connection.id) + + #expect(paneState.assistant.session === session) + } + } + + /// Every toggle re-runs the conversation's tasks on the same view. A prompt held for the connect + /// is neither sent early nor dropped, and nothing starts a second session or conversation. + /// + /// The flush marks the engine as waiting for the connection each time it runs, which is how the + /// case knows the toggle really re-ran it rather than passing because nothing ran at all. + @Test("A toggle during the connect neither sends the held prompt nor starts a second session") + func toggleDuringTheConnectKeepsTheHeldPrompt() async throws { + try await AIFeatureScope.enabled { + let harness = try Harness(sessionStatus: .connecting) + defer { harness.tearDown() } + try #require(harness.controller.currentPane == .connecting) + let session = harness.agentSessions.startSession(for: harness.connection.id) + session.pendingPrompt = "Which orders shipped late?" + + harness.controller.setContentMode(.agent) + #expect(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + try #require( + await harness.suspend { session.viewModel.isAwaitingConnection }, + "The conversation never ran its flush" + ) + + harness.controller.setContentMode(.browse) + await harness.pause() + session.viewModel.isAwaitingConnection = false + harness.controller.setContentMode(.agent) + try #require( + await harness.suspend { session.viewModel.isAwaitingConnection }, + "Coming back did not run the flush again, so the case proves nothing" + ) + + #expect(session.pendingPrompt == "Which orders shipped late?") + #expect(session.viewModel.messages.isEmpty) + #expect(session.viewModel.activeConversationID == nil) + #expect(harness.agentSessions.sessions(for: harness.connection.id).map(\.id) == [session.id]) + } + } + + // MARK: - Safe Mode + + /// The list, its validation and the write all judge a level by one status. The validation used + /// to ask the connection's own floor while the write asked the one Agent mode raises, so an entry + /// could validate as a choice the write then held at another level. + @Test("Each Safe Mode entry validates against the floor the list is built from") + func safeModeEntriesFollowTheListsFloor() throws { + let harness = try Harness(type: .cloudflareR2SQL) + defer { harness.tearDown() } + try harness.requireContent() + let status = try #require(harness.controller.safeModeStatus) + try #require(status.floor?.reason == .readOnlyEngine) + + for level in SafeModeLevel.allCases { + #expect( + harness.controller.validateMenuItem(Self.safeModeItem(level)) == status.offers(level), + "\(level)" + ) + } + #expect(!harness.controller.validateMenuItem(Self.safeModeItem(.silent))) + } + + /// The welcome window's Open in Agent Mode puts the window in the mode before its connect lands, + /// so the browse content, which is what used to hand the list its coordinator, never mounts. + /// The list had no checkmark and no entry in it did anything. + @Test("The Safe Mode list works in a window opened straight into Agent mode") + func safeModeWorksWithoutTheBrowseContent() throws { + try AIFeatureScope.enabled { + let harness = try Harness(contentMode: .agent) + defer { harness.tearDown() } + try harness.requireContent() + try #require( + harness.controller.commandActions == nil, + "The browse content mounted, so this is not the window the welcome route opens" + ) + let status = try #require(harness.controller.safeModeStatus, "The list had no level to check") + let pick = try #require(status.offeredLevels.first { status.accepts($0) }) + let item = Self.safeModeItem(pick) + #expect(harness.controller.validateMenuItem(item)) + + harness.controller.setSafeModeLevel(item) + + #expect(DatabaseManager.shared.session(for: harness.connection.id)?.safeModeLevel == pick) + #expect(harness.controller.safeModeStatus?.level == pick) + } + } + + // MARK: - Session commands + + /// Close used to go from the rail straight to the registry, which stopped the session and told + /// no pane: the conversation column went on drawing it, with a composer that still took messages. + @Test("Close Session stops the session and takes it off the window") + func closeSessionTakesTheSessionOffTheWindow() async throws { + try await AIFeatureScope.enabled { + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + let asked = ConfirmationRecorder() + harness.controller.confirmAgentSessionCommand = asked.answer(true) + + harness.controller.closeAgentSession(Self.sessionItem(Self.closeSession, session: session.id)) + + #expect(await harness.suspend { harness.selected.displayedAgentSession == nil }) + #expect(session.status == .stopped) + #expect(asked.confirmations.isEmpty, "An idle session loses nothing by stopping, so nothing is asked") + #expect( + harness.agentSessions.sessions(for: harness.connection.id).count == 1, + "Closing keeps the transcript, so the session stays in the rail" + ) + #expect( + harness.selected.panes.renderedKey?.agentSessionId == nil, + "The panes were not rebuilt for a window with no session open" + ) + } + } + + @Test("Delete Session asks first, and discards the session once it is answered") + func deleteSessionAsksAndDiscards() async throws { + try await AIFeatureScope.enabled { + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + let asked = ConfirmationRecorder() + harness.controller.confirmAgentSessionCommand = asked.answer(true) + + harness.controller.deleteAgentSession(Self.sessionItem(Self.deleteSession, session: session.id)) + + #expect(await harness.suspend { harness.agentSessions.session(id: session.id) == nil }) + #expect(asked.confirmations.count == 1) + #expect(asked.confirmations.first?.isDestructive == true) + #expect(asked.confirmations.first?.title.contains(session.displayTitle) == true) + #expect(harness.selected.displayedAgentSession == nil) + } + } + + @Test("A refused question leaves the session where it was") + func deleteSessionRefused() async throws { + try await AIFeatureScope.enabled { + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + let asked = ConfirmationRecorder() + harness.controller.confirmAgentSessionCommand = asked.answer(false) + + harness.controller.deleteAgentSession(Self.sessionItem(Self.deleteSession, session: session.id)) + + #expect(await harness.suspend { asked.confirmations.count == 1 }) + #expect(harness.agentSessions.session(id: session.id) === session) + #expect(harness.selected.displayedAgentSession === session) + } + } + + @Test("New Session starts one and puts it on screen") + func newSessionStartsAndShowsIt() throws { + try AIFeatureScope.enabled { + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let first = try #require(harness.selected.displayedAgentSession) + + harness.controller.newAgentSession(nil) + + let started = try #require(harness.selected.displayedAgentSession) + #expect(started !== first) + #expect(harness.agentSessions.sessions(for: harness.connection.id).count == 2) + #expect(harness.selected.panes.renderedKey?.agentSessionId == started.id) + } + } + + /// A menu that lists sessions names one in each item; every other route acts on the rail's + /// highlight, which is the window's rather than the rail view's for exactly this reason. + @Test("Open Session takes its session from the item, and from the rail when the item names none") + func openSessionResolvesItsTarget() throws { + try AIFeatureScope.enabled { + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.setContentMode(.agent) + let first = try #require(harness.selected.displayedAgentSession) + harness.controller.newAgentSession(nil) + let second = try #require(harness.selected.displayedAgentSession) + + harness.controller.openAgentSession(Self.sessionItem(Self.openSession, session: first.id)) + #expect(harness.selected.displayedAgentSession === first) + + harness.selected.agentRail.highlightedSessionId = second.id + harness.controller.openAgentSession(nil) + #expect(harness.selected.displayedAgentSession === second) + } + } + + @Test("The session commands follow the mode and the session they would act on") + func sessionCommandsValidateAgainstTheirTarget() throws { + try AIFeatureScope.enabled { + let harness = try Harness(startsAgentSession: true) + defer { harness.tearDown() } + try harness.requireContent() + + for selector in [Self.newSession, Self.openSession, Self.closeSession, Self.deleteSession] { + #expect( + !harness.controller.validateMenuItem(Self.menuItem(selector)), + "Browsing draws no rail, so \(selector) has nothing to act on" + ) + } + + harness.controller.setContentMode(.agent) + let session = try #require(harness.selected.displayedAgentSession) + #expect(harness.controller.validateMenuItem(Self.menuItem(Self.newSession))) + + /// The rail highlights the session on screen as it appears, so a rail with nothing + /// highlighted is asked for here rather than waited for. + harness.selected.agentRail.highlightedSessionId = nil + for selector in [Self.openSession, Self.closeSession, Self.deleteSession] { + #expect( + !harness.controller.validateMenuItem(Self.menuItem(selector)), + "With no row highlighted there is no session for \(selector)" + ) + } + + harness.selected.agentRail.highlightedSessionId = session.id + for selector in [Self.openSession, Self.closeSession, Self.deleteSession] { + #expect(harness.controller.validateMenuItem(Self.menuItem(selector)), "\(selector)") + } + #expect( + !harness.controller.validateMenuItem(Self.sessionItem(Self.openSession, session: UUID())), + "An item naming a session that is gone is answered by the item, not by the highlight" + ) + + harness.agentSessions.stopSession(id: session.id) + #expect( + !harness.controller.validateMenuItem(Self.menuItem(Self.closeSession)), + "A session that has already ended cannot be closed again" + ) + #expect( + harness.controller.validateMenuItem(Self.menuItem(Self.deleteSession)), + "A stopped session keeps its transcript, so it is still there to delete" + ) + } + } + + private static let newSession = #selector(MainSplitViewController.newAgentSession(_:)) + private static let openSession = #selector(MainSplitViewController.openAgentSession(_:)) + private static let closeSession = #selector(MainSplitViewController.closeAgentSession(_:)) + private static let deleteSession = #selector(MainSplitViewController.deleteAgentSession(_:)) + + /// Answers the window's question without raising an alert. A modal one in a test holds the whole + /// run: nothing on a test runner dismisses it. + @MainActor + private final class ConfirmationRecorder { + private(set) var confirmations: [AgentSessionConfirmation] = [] + + func answer(_ reply: Bool) -> AgentSessionConfirming { + { [self] confirmation, _ in + confirmations.append(confirmation) + return reply + } + } + } + + private static func safeModeItem(_ level: SafeModeLevel) -> NSMenuItem { + let item = menuItem(#selector(MainSplitViewController.setSafeModeLevel(_:))) + item.title = level.displayName + item.representedObject = level.rawValue + return item + } + + private static func sessionItem(_ action: Selector, session: UUID) -> NSMenuItem { + let item = menuItem(action) + item.representedObject = session + return item + } + + private static func menuItem(_ action: Selector) -> NSMenuItem { + NSMenuItem(title: "", action: action, keyEquivalent: "") + } + + // MARK: - Harnesses + + /// One connection whose session the window adopts from `DatabaseManager`, the way a real connect + /// lands. Its agent sessions live in a registry of its own, in a directory of its own, so + /// entering Agent mode writes nothing into the user's real session store. + @MainActor + private struct Harness { + let controller: MainSplitViewController + let selected: ConnectionWorkspace + let agentSessions: AgentSessionRegistry + let window: NSWindow + let connection: DatabaseConnection + private let defaults: UserDefaults + private let suiteName: String + private let registryDirectory: URL + + var detailItem: NSSplitViewItem { + controller.splitViewItems[1] + } + + /// `contentMode` is set before the window is built, which is how the welcome window's Open in + /// Agent Mode lands: the mode is on before the connect, so the browse content never mounts. + /// + /// `prebuildsPaneState` false leaves the trailing pane state for the window to build as the + /// session lands, which is how every workspace the app opens gets one. + init( + type: DatabaseType = .mysql, + sessionStatus: ConnectionStatus = .connected, + contentMode: ConnectionWorkspaceContentMode = .browse, + startsAgentSession: Bool = false, + prebuildsPaneState: Bool = true + ) throws { + connection = TestFixtures.makeConnection(name: "Agent window", type: type) + suiteName = "AgentModeWindowTests.\(UUID().uuidString)" + defaults = try #require(UserDefaults(suiteName: suiteName)) + registryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("AgentModeWindowTests-\(UUID().uuidString)", isDirectory: true) + agentSessions = AgentSessionRegistry(store: AgentSessionStore(directory: registryDirectory)) + if startsAgentSession { + agentSessions.startSession(for: connection.id) + } + let paneState = prebuildsPaneState + ? TrailingPaneState(connectionId: connection.id, defaults: defaults, sessionRegistry: agentSessions) + : nil + selected = ConnectionWorkspace( + connectionId: connection.id, + payload: nil, + autoConnect: false, + payloadConnection: connection, + session: nil, + sessionState: nil, + trailingPaneState: paneState, + phase: .connecting, + agentSessions: agentSessions + ) + selected.contentMode = contentMode + controller = MainSplitViewController(payload: nil, sessionState: nil, adopting: selected) + /// Before the window appears, so the status pass it runs as it does finds the session. + /// A connect still in flight is otherwise read as a connect nobody owns, and failed. + Self.inject(status: sessionStatus, for: connection) + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_200, height: 700), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.orderFront(nil) + + controller.refreshFromActiveSessions() + resetPaneLayout() + } + + func inject(status: ConnectionStatus) { + Self.inject(status: status, for: connection) + } + + private static func inject(status: ConnectionStatus, for connection: DatabaseConnection) { + var session = ConnectionSession( + connection: connection, + driver: status == .connected ? MockDatabaseDriver(connection: connection) : nil + ) + session.status = status + DatabaseManager.shared.injectSession(session, for: connection.id) + } + + /// Asked after the caller has registered `tearDown`, so a harness that failed to connect + /// still gives its window and its injected session back. + func requireContent() throws { + try #require(controller.currentPane == .content, "The connection has no content behind it") + } + + /// SwiftUI mounts a pane on the next layout pass, so anything read out of one waits for it. + func settle(_ find: () -> Found?) -> Found? { + let deadline = Date(timeIntervalSinceNow: 5) + while Date() < deadline { + window.contentView?.layoutSubtreeIfNeeded() + if let found = find() { return found } + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + } + return find() + } + + /// A session derives its name on a main-actor task of its own, and a synchronous spin of the + /// run loop from inside this test's own main-actor job never lets that task run: measured, + /// a `Task { @MainActor in }` did not run across a hundred 10ms turns. So this suspends + /// instead, in a bounded count of short steps, and a missing change fails rather than hangs. + func suspend(until condition: () -> Bool) async -> Bool { + for _ in 0 ..< 200 { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() + } + + func pause() async { + for _ in 0 ..< 10 { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + func drain() { + for _ in 0 ..< 10 { + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + } + } + + /// `NSSplitView`'s autosave record is shared by every case in the target, and entering the + /// mode reveals both side columns, so each case starts and ends on the shipping default: + /// sidebar open, inspector closed. + func resetPaneLayout() { + if controller.isSidebarCollapsed { controller.toggleSidebar(nil) } + if controller.isTrailingPaneOpen { controller.hideTrailingPane() } + } + + /// Sessions are removed rather than left for the workspace's teardown to stop, because + /// stopping one writes its transcript to the app's real conversation store. + func tearDown() { + if controller.contentMode == .agent { controller.setContentMode(.browse) } + selected.contentMode = .browse + resetPaneLayout() + for session in agentSessions.sessions { + agentSessions.removeSession(id: session.id) + } + window.orderOut(nil) + window.contentViewController = nil + selected.teardown() + DatabaseManager.shared.removeSession(for: connection.id) + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: registryDirectory) + } + } + + /// One window hosting two connections, the second one in the background and still dialing. It + /// owns its attempt, so a status reconcile leaves it dialing rather than failing it. + @MainActor + private struct TwoConnectionHarness { + let controller: MainSplitViewController + let foreground: ConnectionWorkspace + let background: ConnectionWorkspace + let agentSessions: AgentSessionRegistry + private let window: NSWindow + private let registryDirectory: URL + + init() { + registryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("AgentModeWindowTests-\(UUID().uuidString)", isDirectory: true) + agentSessions = AgentSessionRegistry(store: AgentSessionStore(directory: registryDirectory)) + foreground = Self.makeWorkspace( + connection: TestFixtures.makeConnection(name: "Foreground"), + phase: .idle, + agentSessions: agentSessions + ) + background = Self.makeWorkspace( + connection: TestFixtures.makeConnection(name: "Background"), + phase: .connecting, + agentSessions: agentSessions + ) + background.attemptToken = UUID() + + controller = MainSplitViewController(payload: nil, sessionState: nil, adopting: foreground) + controller.workspaces.insert(background, select: false) + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_200, height: 700), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.orderFront(nil) + } + + func tearDown() { + for workspace in [foreground, background] where workspace.contentMode == .agent { + controller.setContentMode(.browse, for: workspace.connectionId) + } + if controller.isSidebarCollapsed { controller.toggleSidebar(nil) } + if controller.isTrailingPaneOpen { controller.hideTrailingPane() } + for session in agentSessions.sessions { + agentSessions.removeSession(id: session.id) + } + window.orderOut(nil) + window.contentViewController = nil + background.teardown() + foreground.teardown() + try? FileManager.default.removeItem(at: registryDirectory) + } + + private static func makeWorkspace( + connection: DatabaseConnection, + phase: ConnectionWindowPhase, + agentSessions: AgentSessionRegistry + ) -> ConnectionWorkspace { + ConnectionWorkspace( + connectionId: connection.id, + payload: nil, + autoConnect: false, + payloadConnection: connection, + session: nil, + sessionState: nil, + trailingPaneState: nil, + phase: phase, + agentSessions: agentSessions + ) + } + } + + /// A window of its own for a pane the connection window is not showing, so what the pane holds + /// is laid out and can be looked at. + @MainActor + private struct PaneProbeWindow { + private let window: NSWindow + private let paneView: NSView + + init(showing paneView: NSView) { + self.paneView = paneView + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let container = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 400)) + window.contentView = container + paneView.frame = container.bounds + container.addSubview(paneView) + window.orderFront(nil) + } + + func settle() { + for _ in 0 ..< 20 { + paneView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + } + } + + func close() { + paneView.removeFromSuperview() + window.orderOut(nil) + } + } +} diff --git a/TableProTests/Core/Services/Infrastructure/AgentSessionRegistryTests.swift b/TableProTests/Core/Services/Infrastructure/AgentSessionRegistryTests.swift index 9155f0ceb..d7e515ead 100644 --- a/TableProTests/Core/Services/Infrastructure/AgentSessionRegistryTests.swift +++ b/TableProTests/Core/Services/Infrastructure/AgentSessionRegistryTests.swift @@ -49,6 +49,23 @@ struct AgentSessionRegistryTests { #expect(session.viewModel.sessionId == session.id) } + /// The conversation flushes a held prompt from a `task`, and a reparent re-runs that task on the + /// same view: a mode toggle and a connection switch are both one. Taking the prompt rather than + /// reading it is what keeps each re-run from sending it again. + @Test("A held prompt is handed over once, and only once the connection is up") + func heldPromptIsTakenOnce() throws { + let registry = AgentSessionRegistry(store: makeStore()) + let session = try #require(registry.resolveSession(for: UUID(), startingIfNeeded: true)) + session.pendingPrompt = "Which orders shipped late?" + + #expect(session.takePendingPrompt(isConnecting: true) == nil) + #expect(session.pendingPrompt == "Which orders shipped late?") + + #expect(session.takePendingPrompt(isConnecting: false) == "Which orders shipped late?") + #expect(session.takePendingPrompt(isConnecting: false) == nil) + #expect(session.pendingPrompt == nil) + } + /// Stopping keeps the transcript. Window close, disconnect and a lost session all reach it, and /// none of them is the user throwing a conversation away. @Test("Stopping a session keeps it and its transcript") @@ -161,6 +178,121 @@ struct AgentSessionRegistryTests { #expect(registry.session(id: session.id) == nil) } + // MARK: - Order + + /// The rail lists the latest first, and going to work is what makes a session the latest. Opening + /// one is not work: ordered by that, the row someone double-clicked in the middle of the list + /// would jump to the top from under the pointer that opened it. + @Test("The rail lists the session that last went to work first") + func sessionsAreOrderedByActivity() throws { + let registry = AgentSessionRegistry(store: makeStore()) + let connectionId = UUID() + let first = registry.startSession(for: connectionId) + let second = registry.startSession(for: connectionId) + #expect(registry.sessions(for: connectionId).map(\.id) == [second.id, first.id]) + + first.markActive() + + #expect(registry.sessions(for: connectionId).map(\.id) == [first.id, second.id]) + } + + @Test("Opening a session leaves the order alone") + func openingDoesNotReorder() { + let registry = AgentSessionRegistry(store: makeStore()) + let connectionId = UUID() + let first = registry.startSession(for: connectionId) + let second = registry.startSession(for: connectionId) + + registry.setDisplayedSession(first.id, for: connectionId) + + #expect(registry.sessions(for: connectionId).map(\.id) == [second.id, first.id]) + #expect(registry.currentSession(for: connectionId) === first) + } + + /// With nothing named, the latest live session is the one two panes share, not the oldest. + @Test("The shared session is the latest live one") + func currentSessionPrefersTheLatestLiveOne() { + let registry = AgentSessionRegistry(store: makeStore()) + let connectionId = UUID() + let first = registry.startSession(for: connectionId) + let second = registry.startSession(for: connectionId) + registry.stopSession(id: second.id) + + #expect(registry.currentSession(for: connectionId) === first) + } + + // MARK: - Closing and deleting the session on screen + + /// Close used to stop the session and tell nothing, so the conversation column went on drawing it + /// with a composer that still took messages. + @Test("Closing the session on screen hands the window the next live one") + func closingHandsOverToTheNextLiveSession() { + let registry = AgentSessionRegistry(store: makeStore()) + let connectionId = UUID() + let first = registry.startSession(for: connectionId) + let second = registry.startSession(for: connectionId) + #expect(registry.currentSession(for: connectionId) === second) + + registry.stopSession(id: second.id) + + #expect(second.status == .stopped) + #expect(registry.currentSession(for: connectionId) === first) + #expect(registry.sessions(for: connectionId).count == 2, "A closed session stays in the rail") + } + + @Test("Closing the last live session leaves the window with none") + func closingTheLastLiveSessionShowsNothing() { + let registry = AgentSessionRegistry(store: makeStore()) + let connectionId = UUID() + let session = registry.startSession(for: connectionId) + + registry.stopSession(id: session.id) + + #expect(registry.currentSession(for: connectionId) == nil) + #expect(registry.sessions(for: connectionId).count == 1) + } + + /// Nothing on screen is a state the user asked for, and entering the mode again is them asking + /// for a session; resolving is what mints it. + @Test("A window with no session open starts one when it is asked to") + func resolvingAfterClosingStartsAnother() throws { + let registry = AgentSessionRegistry(store: makeStore()) + let connectionId = UUID() + let closed = registry.startSession(for: connectionId) + registry.stopSession(id: closed.id) + + #expect(registry.resolveSession(for: connectionId, startingIfNeeded: false) == nil) + let started = try #require(registry.resolveSession(for: connectionId, startingIfNeeded: true)) + + #expect(started !== closed) + #expect(registry.currentSession(for: connectionId) === started) + } + + @Test("Deleting the session on screen hands the window the next live one") + func deletingHandsOverToTheNextLiveSession() { + let registry = AgentSessionRegistry(store: makeStore()) + let connectionId = UUID() + let first = registry.startSession(for: connectionId) + let second = registry.startSession(for: connectionId) + + registry.removeSession(id: second.id) + + #expect(registry.currentSession(for: connectionId) === first) + #expect(registry.sessions(for: connectionId).map(\.id) == [first.id]) + } + + @Test("Deleting a session nobody is looking at leaves the open one alone") + func deletingAnotherSessionKeepsTheOpenOne() { + let registry = AgentSessionRegistry(store: makeStore()) + let connectionId = UUID() + let other = registry.startSession(for: connectionId) + let open = registry.startSession(for: connectionId) + + registry.removeSession(id: other.id) + + #expect(registry.currentSession(for: connectionId) === open) + } + /// A reply that was still arriving when the app went away did not finish, and saying so is more /// honest than leaving it reading as busy for ever. @Test("A session still working at terminate is recorded as failed") diff --git a/TableProTests/Core/Services/Infrastructure/BrowseCollapseStateOwnershipTests.swift b/TableProTests/Core/Services/Infrastructure/BrowseCollapseStateOwnershipTests.swift new file mode 100644 index 000000000..83c8273b2 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/BrowseCollapseStateOwnershipTests.swift @@ -0,0 +1,85 @@ +// +// BrowseCollapseStateOwnershipTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +/// What Browse had collapsed belongs to the workspace, not to the connection. +/// +/// A tab torn into its own window leaves one connection hosted by two workspaces, and each window +/// has its own collapsed sidebar and inspector. Keyed by connection id in a static, the second +/// window to enter Agent mode overwrote what the first had recorded, and the first then came out of +/// the mode with the second window's layout. +@Suite("Browse collapse state ownership") +@MainActor +struct BrowseCollapseStateOwnershipTests { + private static let connectionId = UUID(uuidString: "00000000-0000-0000-0000-0000000000D4") + + private func makeWorkspace(_ connectionId: UUID) -> ConnectionWorkspace { + ConnectionWorkspace( + connectionId: connectionId, + payload: nil, + autoConnect: false, + payloadConnection: nil, + session: nil, + sessionState: nil, + trailingPaneState: nil, + phase: .idle + ) + } + + @Test("A fresh workspace has recorded nothing") + func startsEmpty() throws { + let connectionId = try #require(Self.connectionId) + #expect(makeWorkspace(connectionId).browseCollapseState == nil) + } + + @Test("Two workspaces for one connection keep separate records") + func twoWorkspacesDoNotShareARecord() throws { + let connectionId = try #require(Self.connectionId) + let first = makeWorkspace(connectionId) + let second = makeWorkspace(connectionId) + + first.browseCollapseState = (sidebar: false, inspector: true) + second.browseCollapseState = (sidebar: true, inspector: false) + + #expect(first.browseCollapseState?.sidebar == false) + #expect(first.browseCollapseState?.inspector == true) + #expect(second.browseCollapseState?.sidebar == true) + #expect(second.browseCollapseState?.inspector == false) + } + + /// Leaving the mode hands the layout back and forgets it, so a second entry records what Browse + /// has at that moment rather than replaying what it had the first time. + @Test("Clearing one workspace's record leaves the other's standing") + func clearingIsScopedToItsWorkspace() throws { + let connectionId = try #require(Self.connectionId) + let first = makeWorkspace(connectionId) + let second = makeWorkspace(connectionId) + first.browseCollapseState = (sidebar: true, inspector: true) + second.browseCollapseState = (sidebar: false, inspector: false) + + first.browseCollapseState = nil + + #expect(first.browseCollapseState == nil) + #expect(second.browseCollapseState?.sidebar == false) + #expect(second.browseCollapseState?.inspector == false) + } + + /// The mode is per connection too, so one workspace can sit in Agent mode with its columns + /// revealed while another in the same window stays on a table with its inspector closed. + @Test("The content mode is per workspace as well") + func contentModeIsPerWorkspace() throws { + let connectionId = try #require(Self.connectionId) + let browsing = makeWorkspace(connectionId) + let agent = makeWorkspace(connectionId) + + agent.contentMode = .agent + + #expect(browsing.contentMode == .browse) + #expect(agent.contentMode == .agent) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionActionsMenuDelegateTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionActionsMenuDelegateTests.swift new file mode 100644 index 000000000..b831a4ca3 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ConnectionActionsMenuDelegateTests.swift @@ -0,0 +1,204 @@ +// +// ConnectionActionsMenuDelegateTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +/// Stands in for the toolbar the delegate asks, so a test can move the context between two opens. +@MainActor +private final class ContextSource { + var context: ToolbarContext + + init(_ context: ToolbarContext) { + self.context = context + } +} + +/// What the Actions pull-down draws for a context. The resolver decides the entries and is pinned by +/// its own suite; this pins how they become menu items, which is where a target, a missing +/// `representedObject` or a lost chord would break the menu without the resolver noticing. +@Suite("Connection actions menu delegate") +@MainActor +struct ConnectionActionsMenuDelegateTests { + private static func context( + tabKind: TabType? = .table, + contentMode: ConnectionWorkspaceContentMode = .browse, + isConnected: Bool = true + ) -> ToolbarContext { + ToolbarContext( + tabKind: tabKind, + resultsMode: .data, + contentMode: contentMode, + pane: isConnected ? .content : .unavailable(.notConnected), + isConnected: isConnected, + hasSelectedWorkspace: true, + supportsImport: true, + supportsServerDashboard: true, + isAIEnabled: true + ) + } + + private static func makeDelegate(for context: ToolbarContext) -> ConnectionActionsMenuDelegate { + ConnectionActionsMenuDelegate(importFormats: ImportFormatMenuDelegate(), context: { context }) + } + + /// The submenus' delegates are held by the Actions delegate and `NSMenu.delegate` is weak, so a + /// case that reads them keeps the Actions delegate alive for as long as it reads, the way the + /// toolbar does for the life of the window. + private static func withItems( + for context: ToolbarContext, + keyboard: KeyboardSettings = KeyboardSettings(), + _ body: ([NSMenuItem]) throws -> Void + ) rethrows { + let delegate = makeDelegate(for: context) + try withExtendedLifetime(delegate) { + try body(delegate.items(for: context, keyboard: keyboard)) + } + } + + /// With a target, a command is validated by that object instead of the responder chain, and the + /// toolbar's own `validateMenuItem` answers true for every action it did not build. A submenu's + /// own row is AppKit's to wire, and it targets the submenu. + @Test("No command carries a target, so the window validates every one") + func entriesCarryNoTarget() { + for contentMode in ConnectionWorkspaceContentMode.allCases { + Self.withItems(for: Self.context(contentMode: contentMode)) { items in + for item in items where !item.isSeparatorItem { + if let submenu = item.submenu { + #expect(item.target === submenu, "\(item.title)") + } else { + #expect(item.target == nil, "\(item.title)") + } + } + } + } + } + + @Test("Sections are divided by separators, and nothing else is") + func sectionsAreDividedBySeparators() { + let context = Self.context() + let sections = ConnectionActionsMenuResolver.sections(context) + let items = Self.makeDelegate(for: context).items(for: context, keyboard: KeyboardSettings()) + + #expect(items.filter(\.isSeparatorItem).count == sections.count - 1) + #expect(items.count == sections.reduce(0) { $0 + $1.entries.count } + sections.count - 1) + #expect(items.first?.isSeparatorItem == false) + #expect(items.last?.isSeparatorItem == false) + } + + /// The chord comes from the user's own binding, the way the menu bar's does, so a rebind in + /// Settings reaches this menu on its next open. + @Test("An entry with a shortcut shows the user's binding") + func shortcutsFollowTheBinding() throws { + var keyboard = KeyboardSettings() + keyboard.setShortcut(.character("j", command: true, control: true), for: .addRow) + let context = Self.context() + let items = Self.makeDelegate(for: context).items(for: context, keyboard: keyboard) + let addRow = try #require(items.first { $0.action == NSSelectorFromString("addRow:") }) + + #expect(addRow.keyEquivalent == "j") + #expect(addRow.keyEquivalentModifierMask == [.command, .control]) + } + + /// AppKit ignores a key equivalent on an item that owns a submenu, so the root carries none, + /// and the leaves are filled when it opens. + @Test("The import formats and the modes open submenus their delegates fill") + func submenusHaveDelegates() throws { + try Self.withItems(for: Self.context()) { items in + let importRoot = try #require(items.first { $0.title == String(localized: "Import Data From") }) + let modeRoot = try #require(items.first { $0.title == String(localized: "Mode") }) + + for root in [importRoot, modeRoot] { + #expect(root.keyEquivalent.isEmpty) + #expect(root.submenu?.delegate != nil) + } + #expect(importRoot.submenu?.delegate is ImportFormatMenuDelegate) + #expect(modeRoot.submenu?.delegate is ContentModeMenuDelegate) + } + } + + /// The command ⇧⌘I runs is drawn as a leaf the responder chain validates, so the window dims it + /// when there is nothing to import, and it shows the user's own chord for it. As a submenu's row + /// it could show neither. + @Test("Import Data… is a leaf that reaches the window and shows its binding") + func importDataIsAPlainLeaf() throws { + var keyboard = KeyboardSettings() + keyboard.setShortcut(.character("u", command: true, control: true), for: .importData) + try Self.withItems(for: Self.context(), keyboard: keyboard) { items in + let leaf = try #require(items.first { $0.title == String(localized: "Import Data…") }) + + #expect(leaf.submenu == nil) + #expect(leaf.action == #selector(MainSplitViewController.importData(_:))) + #expect(leaf.target == nil) + #expect(leaf.keyEquivalent == "u") + #expect(leaf.keyEquivalentModifierMask == [.command, .control]) + } + } + + /// The format list's row cannot be dimmed through the responder chain, so an empty list says why + /// it is empty rather than opening as a blank sliver. + @Test("An import list with nothing to offer says so") + func emptyImportListSaysSo() throws { + let menu = NSMenu() + menu.addItem(ImportFormatMenuDelegate.item(for: ImportFormatOption(id: "stale", name: "Stale"))) + + ImportFormatMenuDelegate().menuNeedsUpdate(menu) + + let placeholder = try #require(menu.items.first) + #expect(menu.items.count == 1) + #expect(placeholder.title == String(localized: "None Available")) + #expect(placeholder.action == nil) + #expect(placeholder.isEnabled == false) + } + + /// `setContentModeFromMenu(_:)` reads the mode out of `representedObject` and does nothing + /// without it, and the window's validation reads the same value to tick the current mode. + @Test("Each mode entry names its mode, for the action and for the checkmark") + func modeEntriesNameTheirMode() { + let menu = NSMenu() + ContentModeMenuDelegate().menuNeedsUpdate(menu) + + #expect(menu.items.count == ConnectionWorkspaceContentMode.allCases.count) + for (item, mode) in zip(menu.items, ConnectionWorkspaceContentMode.allCases) { + #expect(item.title == mode.localizedTitle) + #expect(item.representedObject as? String == mode.rawValue) + #expect(item.action == #selector(MainSplitViewController.setContentModeFromMenu(_:))) + #expect(item.target == nil) + } + } + + /// The parent row already says Import Data From, so the leaf is the format alone. Under that + /// parent the sidebar's "From CSV…" would read twice. + @Test("An import format entry names its format and reaches the window") + func importFormatEntryNamesItsFormat() { + let item = ImportFormatMenuDelegate.item(for: ImportFormatOption(id: "csv", name: "CSV")) + + #expect(item.title == "CSV\u{2026}") + #expect(item.representedObject as? String == "csv") + #expect(item.action == #selector(MainSplitViewController.importDataFormat(_:))) + #expect(item.target == nil) + } + + /// Filled on open from the context the toolbar is pointed at when it opens, so a menu opened + /// once cannot go on describing a tab the window has left. + @Test("The menu is rebuilt from the current context each time it opens") + func menuFollowsTheContextOnEachOpen() { + let source = ContextSource(Self.context(tabKind: .table)) + let delegate = ConnectionActionsMenuDelegate( + importFormats: ImportFormatMenuDelegate(), + context: { source.context } + ) + let menu = NSMenu() + + delegate.menuNeedsUpdate(menu) + #expect(menu.items.contains { $0.action == NSSelectorFromString("addRow:") }) + + source.context = Self.context(tabKind: .query) + delegate.menuNeedsUpdate(menu) + #expect(!menu.items.contains { $0.action == NSSelectorFromString("addRow:") }) + #expect(menu.items.contains { $0.action == NSSelectorFromString("toggleResults:") }) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionActionsMenuResolverTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionActionsMenuResolverTests.swift new file mode 100644 index 000000000..026f7a4e9 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ConnectionActionsMenuResolverTests.swift @@ -0,0 +1,336 @@ +// +// ConnectionActionsMenuResolverTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@Suite("Connection actions menu resolver") +struct ConnectionActionsMenuResolverTests { + private static let tabKinds: [TabType] = [ + .query, .table, .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, + ] + + /// Every title the pull-down may use, and every one of them is a title the menu bar already + /// carries. The rule this pins is that the pull-down is a second route to commands that exist, + /// never a place for commands that exist nowhere else: a menu nobody can find from the menu bar + /// is a menu that cannot be searched, rebound or discovered. + private static let menuBarTitles: Set = [ + String(localized: "New Session"), + String(localized: "Open Session"), + String(localized: "Close Session"), + String(localized: "Delete Session…"), + String(localized: "New Conversation"), + String(localized: "Add Row"), + String(localized: "Restore Previous Values…"), + String(localized: "Back"), + String(localized: "Forward"), + String(localized: "Preview SQL"), + String(localized: "Show Results"), + String(localized: "Export Results…"), + String(localized: "Export Tables…"), + String(localized: "Import Data…"), + String(localized: "Import Data From"), + String(localized: "Show DDL"), + String(localized: "Copy DDL"), + String(localized: "Show Query History"), + String(localized: "Users & Roles"), + String(localized: "Query Insights"), + String(localized: "Server Dashboard"), + String(localized: "New Tab"), + String(localized: "Open Quickly…"), + String(localized: "Mode"), + String(localized: "Switch Connection…"), + String(localized: "Reconnect"), + String(localized: "Close Connection"), + ] + + private static func context( + tabKind: TabType? = .table, + resultsMode: ResultsViewMode? = .data, + contentMode: ConnectionWorkspaceContentMode = .browse, + isConnected: Bool = true, + supportsImport: Bool = true, + supportsServerDashboard: Bool = true, + isAIEnabled: Bool = true + ) -> ToolbarContext { + ToolbarContext( + tabKind: tabKind, + resultsMode: resultsMode, + contentMode: contentMode, + pane: isConnected ? .content : .unavailable(.notConnected), + isConnected: isConnected, + hasSelectedWorkspace: true, + supportsImport: supportsImport, + supportsServerDashboard: supportsServerDashboard, + isAIEnabled: isAIEnabled + ) + } + + private static func entries(_ context: ToolbarContext) -> [ActionsMenuEntry] { + ConnectionActionsMenuResolver.sections(context).flatMap(\.entries) + } + + private static func titles(_ context: ToolbarContext) -> [String] { + entries(context).map(\.title) + } + + // MARK: - House rules + + @Test("Every entry the pull-down can emit has a menu-bar twin with the same title") + func everyEntryHasAMenuBarTwin() { + for tabKind in Self.tabKinds + [nil] { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for isConnected in [true, false] { + for title in Self.titles( + Self.context(tabKind: tabKind, contentMode: contentMode, isConnected: isConnected) + ) { + #expect(Self.menuBarTitles.contains(title), "\(title) has no menu-bar twin") + } + } + } + } + } + + /// The list above is only a claim until the menu bar is asked. Every title in it has to be one the + /// built menu bar draws, a submenu's own row included, or a pull-down entry could name a twin + /// that does not exist and the rule would pass on a typo. + @Test("Every twin the rule names is in the built menu bar") + @MainActor + func menuBarTitlesAreInTheMenuBar() { + var drawn: Set = [] + collectTitles(from: MainMenuBuilder.build(keyboard: KeyboardSettings()), into: &drawn) + + #expect(drawn.count > 50, "Only \(drawn.count) titles collected; the walk missed the menus") + for title in Self.menuBarTitles { + #expect(drawn.contains(title), "\(title) is named as a twin but the menu bar has no such item") + } + } + + @MainActor + private func collectTitles(from menu: NSMenu, into titles: inout Set) { + for item in menu.items where !item.isSeparatorItem { + titles.insert(item.title) + if let submenu = item.submenu { collectTitles(from: submenu, into: &titles) } + } + } + + /// A section is a run drawn between two separators. Past about six entries a run stops reading + /// as a group and becomes a list, which is what the pull-down exists to avoid. + @Test("No section runs longer than six entries") + func sectionsStayShort() { + for tabKind in Self.tabKinds + [nil] { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for isConnected in [true, false] { + let sections = ConnectionActionsMenuResolver.sections( + Self.context(tabKind: tabKind, contentMode: contentMode, isConnected: isConnected) + ) + for section in sections { + #expect(section.entries.count <= 6) + #expect(section.entries.isEmpty == false) + } + } + } + } + } + + @Test("A command never appears twice in one menu") + func titlesAreUniqueWithinAMenu() { + for tabKind in Self.tabKinds + [nil] { + for contentMode in ConnectionWorkspaceContentMode.allCases { + let titles = Self.titles(Self.context(tabKind: tabKind, contentMode: contentMode)) + #expect(Set(titles).count == titles.count) + } + } + } + + /// The window always has a way out of itself, whatever it is showing and whether or not the + /// connection is up. This is what lets the pull-down stay enabled in every phase. + @Test("Every context offers a route to another connection") + func everyContextOffersSwitchConnection() { + for tabKind in Self.tabKinds + [nil] { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for isConnected in [true, false] { + let titles = Self.titles( + Self.context(tabKind: tabKind, contentMode: contentMode, isConnected: isConnected) + ) + #expect(titles.contains(String(localized: "Switch Connection…"))) + #expect(titles.contains(String(localized: "Close Connection"))) + } + } + } + } + + // MARK: - Capability gates + + @Test("Import is offered only by an engine that has it") + func importFollowsTheDriver() { + let withImport = Self.entries(Self.context(supportsImport: true)) + let without = Self.entries(Self.context(supportsImport: false)) + + #expect(withImport.contains { $0.submenu == .importFormats }) + #expect(withImport.contains { $0.selector == NSSelectorFromString("importData:") }) + #expect(without.contains { $0.submenu == .importFormats } == false) + #expect(without.contains { $0.selector == NSSelectorFromString("importData:") } == false) + } + + /// The command ⇧⌘I runs is a leaf that says so, and the format list is a row of its own. A row + /// that owns a submenu can carry neither an action nor a chord, so folding the two into one row + /// drew a submenu with no command and no shortcut, and File > Import draws the same two rows. + @Test("Import Data… is a plain leaf beside a list of formats, the way File > Import draws them") + func importIsALeafBesideTheFormatList() throws { + let entries = Self.entries(Self.context(supportsImport: true)) + let leaf = try #require(entries.first { $0.title == String(localized: "Import Data…") }) + let list = try #require(entries.first { $0.submenu == .importFormats }) + + #expect(leaf.selector == NSSelectorFromString("importData:")) + #expect(leaf.shortcut == .importData) + #expect(leaf.submenu == nil) + #expect(list.title == String(localized: "Import Data From")) + #expect(list.title.hasSuffix("…") == false, "A submenu's row opens a menu, not a dialog, so it takes no ellipsis") + let leafIndex = try #require(entries.firstIndex(of: leaf)) + #expect(entries.indices.contains(leafIndex + 1)) + #expect(entries[leafIndex + 1] == list, "The format list sits right under the command it refines") + } + + /// A submenu's row is wired by AppKit to its submenu the moment one is assigned, and AppKit + /// ignores a key equivalent on it, so a selector or a chord declared there is a promise the menu + /// never keeps. + @Test("No submenu row declares a selector or a shortcut") + func submenuRowsDeclareNoCommand() { + for tabKind in Self.tabKinds + [nil] { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for isConnected in [true, false] { + let context = Self.context(tabKind: tabKind, contentMode: contentMode, isConnected: isConnected) + for entry in Self.entries(context) where entry.submenu != nil { + #expect(entry.selector == nil, "\(entry.title)") + #expect(entry.shortcut == nil, "\(entry.title)") + } + } + } + } + } + + @Test("Server Dashboard is offered only by an engine that has one") + func dashboardFollowsTheDriver() { + #expect( + Self.titles(Self.context(supportsServerDashboard: true)) + .contains(String(localized: "Server Dashboard")) + ) + #expect( + Self.titles(Self.context(supportsServerDashboard: false)) + .contains(String(localized: "Server Dashboard")) == false + ) + } + + /// Agent mode is the AI feature, so with the setting off there is no mode to choose between. + @Test("The Mode submenu is offered only while AI is on") + func modeSubmenuFollowsTheSetting() { + #expect(Self.entries(Self.context(isAIEnabled: true)).contains { $0.submenu == .mode }) + #expect(Self.entries(Self.context(isAIEnabled: false)).contains { $0.submenu == .mode } == false) + } + + @Test("Reconnect is offered only when there is nothing connected") + func reconnectFollowsTheSession() { + #expect( + Self.titles(Self.context(isConnected: false)).contains(String(localized: "Reconnect")) + ) + #expect( + Self.titles(Self.context(isConnected: true)).contains(String(localized: "Reconnect")) == false + ) + } + + // MARK: - Per-context content + + /// Add Row asks the same question the grid does, so it follows the results mode rather than the + /// tab kind alone. + @Test("Add Row is offered on a table tab showing data and nowhere else", arguments: ResultsViewMode.allCases) + func addRowFollowsTheResultsMode(mode: ResultsViewMode) { + let onTable = Self.titles(Self.context(tabKind: .table, resultsMode: mode)) + #expect(onTable.contains(String(localized: "Add Row")) == (mode == .data)) + } + + @Test("Add Row is never offered outside a table tab", arguments: tabKinds.filter { $0 != .table }) + func addRowIsTableOnly(tabKind: TabType) { + #expect( + Self.titles(Self.context(tabKind: tabKind)).contains(String(localized: "Add Row")) == false + ) + } + + @Test("Show Results is offered on a query tab and nowhere else", arguments: tabKinds) + func showResultsIsQueryOnly(tabKind: TabType) { + let offered = Self.titles(Self.context(tabKind: tabKind)).contains(String(localized: "Show Results")) + #expect(offered == (tabKind == .query)) + } + + /// Back and Forward walk a table's own browse history, which only a table tab has. They were + /// two permanent hit targets in the titlebar and dim on the other seven kinds. + @Test("Back and Forward are offered on a table tab and nowhere else", arguments: tabKinds) + func navigationIsTableOnly(tabKind: TabType) { + let titles = Self.titles(Self.context(tabKind: tabKind)) + #expect(titles.contains(String(localized: "Back")) == (tabKind == .table)) + #expect(titles.contains(String(localized: "Forward")) == (tabKind == .table)) + } + + @Test("An unsaved definition offers its preview but nothing that reads rows") + func createTableOffersPreviewOnly() { + let titles = Self.titles(Self.context(tabKind: .createTable)) + #expect(titles.contains(String(localized: "Preview SQL"))) + #expect(titles.contains(String(localized: "Add Row")) == false) + #expect(titles.contains(String(localized: "Export Results…")) == false) + } + + /// Agent mode has no grid, no object browser and no tab to act on, so the pull-down carries the + /// window's own commands and stops. + @Test("Agent mode offers only the mode and the connection") + func agentModeIsMinimal() { + let titles = Self.titles(Self.context(contentMode: .agent)) + #expect(titles.contains(String(localized: "Mode"))) + #expect(titles.contains(String(localized: "Switch Connection…"))) + #expect(titles.contains(String(localized: "Close Connection"))) + #expect(titles.contains(String(localized: "Add Row")) == false) + #expect(titles.contains(String(localized: "Show Query History")) == false) + #expect(titles.contains(String(localized: "New Tab")) == false) + } + + /// A window whose connection went away offers the three commands that can do something about + /// it, and nothing that needs a session. + @Test("A window with no session offers only what can be done without one") + func disconnectedIsMinimal() { + let titles = Self.titles(Self.context(isConnected: false)) + #expect(titles.contains(String(localized: "Switch Connection…"))) + #expect(titles.contains(String(localized: "Reconnect"))) + #expect(titles.contains(String(localized: "Close Connection"))) + #expect(titles.contains(String(localized: "Export Tables…")) == false) + #expect(titles.contains(String(localized: "Show Query History")) == false) + } + + // MARK: - Selectors + + /// Every selector is spelled exactly as the class declares it. A selector with a colon the + /// implementation does not have reaches nothing and AppKit draws the entry disabled, which + /// fails quietly. + @Test("Reconnect takes no sender, as the menu bar spells it") + func reconnectTakesNoSender() { + let reconnect = Self.entries(Self.context(isConnected: false)) + .first { $0.title == String(localized: "Reconnect") } + #expect(reconnect?.selector == NSSelectorFromString("retryConnection")) + } + + @Test("Every entry is a titled command with a selector, or a titled submenu row") + func everyEntryIsComplete() { + for tabKind in Self.tabKinds + [nil] { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for entry in Self.entries(Self.context(tabKind: tabKind, contentMode: contentMode)) { + #expect(entry.title.isEmpty == false) + #expect((entry.selector == nil) == (entry.submenu != nil), "\(entry.title)") + if let selector = entry.selector { + #expect(NSStringFromSelector(selector).isEmpty == false) + } + } + } + } + } +} diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift index 72615a379..d2bb3a799 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWindowChromeTests.swift @@ -91,10 +91,10 @@ struct ConnectionWindowChromeTests { harness.controller.transition(to: .connecting, for: harness.selected.connectionId) let identifiers = try #require(harness.window.toolbar).items.map(\.itemIdentifier) - /// `connection` itself is a subitem of the centred group, so the group is the identifier a - /// toolbar reports. Both of these are the window's own commands and answer with no subject. - #expect(identifiers.contains(MainWindowToolbar.connectionGroup)) - #expect(identifiers.contains(MainWindowToolbar.sidebarToggle)) + /// Both of these are the window's own commands and answer with no subject: Switch + /// Connection, and AppKit's sidebar toggle, which the window validates in every phase. + #expect(identifiers.contains(MainWindowToolbar.connection)) + #expect(identifiers.contains(.toggleSidebar)) #expect(harness.controller.commandActions == nil) } @@ -116,13 +116,49 @@ struct ConnectionWindowChromeTests { #expect(!before.isEmpty) } - /// Switch Connection reaches the window itself, so it needs no subject. The toolbar's sidebar - /// item is the Tables/Favorites segmented control and does need one, however window-owned the - /// sidebar is: the tab it selects is per-connection state. - @Test("Switch Connection answers with no coordinator and the sidebar segment does not") + /// Switch Connection reaches the window itself, so it needs no subject, and the toolbar's own + /// validation says so for a window whose connection has not come up. + @Test("Switch Connection answers with no coordinator behind the toolbar") func windowScopedToolbarItemsAnswerWithoutASubject() throws { - #expect(MainWindowToolbar.isWindowScoped(MainWindowToolbar.connection)) - #expect(!MainWindowToolbar.isWindowScoped(MainWindowToolbar.sidebarToggle)) + let harness = try Harness() + defer { harness.tearDown() } + + harness.controller.transition(to: .unavailable(.notConnected), for: harness.selected.connectionId) + #expect(harness.controller.commandActions == nil) + + let owner = try #require(harness.controller.toolbarOwner) + #expect(owner.validateToolbarItem(NSToolbarItem(itemIdentifier: MainWindowToolbar.connection))) + #expect(!owner.validateToolbarItem(NSToolbarItem(itemIdentifier: MainWindowToolbar.refresh))) + } + + /// The toolbar's shape is the selected workspace's, and a workspace with no session has no + /// coordinator. A switch between two of them is a repoint from nothing to nothing, which returns + /// before it reaches the toolbar, so the outgoing connection's hidden set stayed on screen: a + /// down SQLite connection took the container capsule away from the down PostgreSQL one selected + /// after it, until that one came up. + @available(macOS 15.0, *) + @Test("The toolbar's shape follows a switch between two connections with no session") + func toolbarShapeFollowsACoordinatorlessSwitch() throws { + let harness = try Harness(selectedType: .sqlite, siblingType: .postgresql) + defer { harness.tearDown() } + + let owner = try #require(harness.controller.toolbarOwner) + #expect(harness.controller.commandActions == nil) + #expect(owner.coordinator == nil) + #expect( + owner.visibility.hides(MainWindowToolbar.database), + "A file-based engine has no container to switch, so the capsule starts hidden" + ) + + harness.controller.workspaces.select(harness.sibling.connectionId) + #expect(owner.coordinator == nil, "Neither workspace has a coordinator, which is the case under test") + #expect( + !owner.visibility.hides(MainWindowToolbar.database), + "PostgreSQL switches databases, so its capsule has to come back" + ) + + harness.controller.workspaces.select(harness.selected.connectionId) + #expect(owner.visibility.hides(MainWindowToolbar.database), "And go again on the way back") } /// The sidebar is the window's and stands in every phase, so its command answers in every @@ -162,6 +198,30 @@ struct ConnectionWindowChromeTests { #expect(harness.controller.validateMenuItem(item)) } + /// The same rule for the assistant, read through the toolbar's own context rather than one built + /// by hand, since that context is where the toolbar's button learns what the menu command knows. + /// It used to check for a live session instead, which dimmed the button beside a Hide Assistant + /// the menu still offered. + @available(macOS 14.0, *) + @Test("An assistant the user left open can still be closed from the toolbar with the session gone") + func openAssistantStaysClosableFromTheToolbar() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + + harness.attachRenderableSession() + harness.controller.transition(to: .connected, for: harness.selected.connectionId) + harness.controller.showAssistant() + #expect(harness.controller.isAssistantVisible) + + harness.controller.transition(to: .unavailable(.disconnected(nil)), for: harness.selected.connectionId) + + #expect(harness.controller.validateMenuItem( + Self.item(for: #selector(MainSplitViewController.toggleAssistant(_:))) + )) + } + } + /// The other half of the same rule: a pane the user never opened offers nothing to open. @available(macOS 14.0, *) @Test("A closed trailing pane stays unavailable without a session") @@ -245,8 +305,10 @@ struct ConnectionWindowChromeTests { let harness = try Harness() defer { harness.tearDown() } - #expect(harness.controller.switcherPresenter === harness.controller.switcherPresenter) - #expect(harness.controller.quickSwitcherPanel === harness.controller.quickSwitcherPanel) + let presenter = harness.controller.switcherPresenter + let panel = harness.controller.quickSwitcherPanel + #expect(harness.controller.switcherPresenter === presenter) + #expect(harness.controller.quickSwitcherPanel === panel) } /// The connections strip and the View menu reach a window's other connections without asking a @@ -281,14 +343,17 @@ struct ConnectionWindowChromeTests { private struct Harness { let controller: MainSplitViewController let selected: ConnectionWorkspace + let sibling: ConnectionWorkspace let window: NSWindow - private let sibling: ConnectionWorkspace private let connection: DatabaseConnection - init() throws { - connection = TestFixtures.makeConnection(name: "Selected") + init(selectedType: DatabaseType = .mysql, siblingType: DatabaseType = .mysql) throws { + connection = TestFixtures.makeConnection(name: "Selected", type: selectedType) selected = Self.makeWorkspace(connection: connection, phase: .connected) - sibling = Self.makeWorkspace(connection: TestFixtures.makeConnection(name: "Sibling"), phase: .connected) + sibling = Self.makeWorkspace( + connection: TestFixtures.makeConnection(name: "Sibling", type: siblingType), + phase: .connected + ) controller = MainSplitViewController(payload: nil, sessionState: nil, adopting: selected) controller.workspaces.insert(sibling, select: false) @@ -335,12 +400,16 @@ struct ConnectionWindowChromeTests { selected.sessionState = SessionStateFactory.create(connection: connection, payload: nil) } + /// The pane state is built on the app's own defaults, which a unit test does not redirect, so + /// a case that reveals a surface writes a key under this run's random connection id. It is + /// removed here rather than left to pile up in the domain of whoever ran the suite. func tearDown() { resetPaneLayout() window.orderOut(nil) window.contentViewController = nil sibling.teardown() selected.teardown() + ConnectionLocalState.purgeTrailingPaneKeys([connection.id, sibling.connectionId]) } private static func makeWorkspace( diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWindowPaneResolverTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWindowPaneResolverTests.swift index f2aa6b4fe..320f11d67 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWindowPaneResolverTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWindowPaneResolverTests.swift @@ -344,13 +344,13 @@ struct ConnectionWindowPaneResolverTests { @Test("The tab strip band appears only for content with more than one tab") func tabStripBandFollowsContentAndTabCount() { - #expect(ConnectionWindowPaneResolver.showsTabStrip(for: .content, tabCount: 2)) - #expect(ConnectionWindowPaneResolver.showsTabStrip(for: .content, tabCount: 9)) + #expect(ConnectionWindowPaneResolver.showsTabStrip(for: .content, tabCount: 2, contentMode: .browse)) + #expect(ConnectionWindowPaneResolver.showsTabStrip(for: .content, tabCount: 9, contentMode: .browse)) /// A single tab is the window every connection opens with, and it gained no chrome /// before this band existed. - #expect(!ConnectionWindowPaneResolver.showsTabStrip(for: .content, tabCount: 1)) - #expect(!ConnectionWindowPaneResolver.showsTabStrip(for: .content, tabCount: 0)) + #expect(!ConnectionWindowPaneResolver.showsTabStrip(for: .content, tabCount: 1, contentMode: .browse)) + #expect(!ConnectionWindowPaneResolver.showsTabStrip(for: .content, tabCount: 0, contentMode: .browse)) } @Test("A pane with no session behind it shows no tab strip, whatever the stale tab count says") @@ -361,7 +361,46 @@ struct ConnectionWindowPaneResolverTests { .unavailable(.failed(Self.failure)), .empty, ] { - #expect(!ConnectionWindowPaneResolver.showsTabStrip(for: pane, tabCount: 5)) + #expect(!ConnectionWindowPaneResolver.showsTabStrip(for: pane, tabCount: 5, contentMode: .browse)) + } + } + + /// The tabs belong to the browse content, and Agent mode puts the conversation in its place. A + /// band left over the conversation offered tabs a click selected with nothing on screen moving. + @Test("Agent mode shows no tab strip, whatever the pane or the tab count") + func tabStripBandHiddenInAgentMode() { + for pane in Self.everyPane { + for tabCount in [0, 1, 2, 9] { + #expect( + !ConnectionWindowPaneResolver.showsTabStrip(for: pane, tabCount: tabCount, contentMode: .agent), + "\(pane) with \(tabCount) tabs" + ) + } + } + } + + @Test("Browsing always draws the browse tree in the detail column") + func browsingDrawsTheBrowseTree() { + for pane in Self.everyPane { + #expect(ConnectionWindowPaneResolver.detailMode(for: pane, contentMode: .browse) == .browse, "\(pane)") + } + } + + /// The conversation is drawn while connecting on purpose: the prompt the user typed is what + /// they are waiting with. + @Test("Agent mode draws the conversation over a connection that is up or coming up") + func agentModeDrawsTheConversation() { + #expect(ConnectionWindowPaneResolver.detailMode(for: .content, contentMode: .agent) == .agent) + #expect(ConnectionWindowPaneResolver.detailMode(for: .connecting, contentMode: .agent) == .agent) + } + + /// The error, Retry, sign-in and Manage Connections live on the browse side's unavailable + /// screen, and a composer with none of them is a dead end. + @Test("Agent mode hands the detail column back over a connection that cannot be reached") + func agentModeYieldsToTheUnavailableScreen() { + let panes: [ConnectionWindowPane] = [.empty] + Self.everyUnavailableReason.map { .unavailable($0) } + for pane in panes { + #expect(ConnectionWindowPaneResolver.detailMode(for: pane, contentMode: .agent) == .browse, "\(pane)") } } } diff --git a/TableProTests/Core/Services/Infrastructure/ContentModeTests.swift b/TableProTests/Core/Services/Infrastructure/ContentModeTests.swift index 931c077c0..7867fc649 100644 --- a/TableProTests/Core/Services/Infrastructure/ContentModeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ContentModeTests.swift @@ -69,58 +69,122 @@ struct ContentModeTests { #expect(TrailingPaneSurface.resolved(.assistant, isAIEnabled: true) == .assistant) } - // MARK: - The toolbar control - - /// Measured on macOS 27: an expanded `selectOne` group publishes a radio group whose buttons - /// take their name from each image's `accessibilityDescription`, never from `labels:`. With nil - /// the sidebar control announced its SF Symbol names, "List" and "favorite". - @Test("Every toolbar segment names itself for assistive clients") - func segmentsAreNamed() { - let mode = MainWindowToolbar.makeContentModeGroup(target: nil, action: #selector(NSResponder.selectAll(_:))) - let sidebar = MainWindowToolbar.makeSidebarSegmentGroup(target: nil, action: #selector(NSResponder.selectAll(_:))) - - for group in [mode, sidebar] { - for subitem in group.subitems { - #expect(subitem.image?.accessibilityDescription?.isEmpty == false) - } + /// A mode switch is one of the three moments the titlebar may change shape, so it has to reach + /// the key the toolbar compares before it writes anything. + @Test("A mode switch changes the titlebar's visibility key") + func modeSwitchChangesTheVisibilityKey() { + let browse = ToolbarContext(tabKind: .table, contentMode: .browse, isAIEnabled: true) + let agent = ToolbarContext(tabKind: .table, contentMode: .agent, isAIEnabled: true) + #expect(browse.visibilityKey != agent.visibilityKey) + } + + /// Nothing on screen draws a session while browsing, so nothing may be rebuilt after one: a + /// session started or switched then would otherwise repaint panes nobody is looking at. + @Test("The displayed agent session, and the render key, name a session only in Agent mode") + func agentSessionIsNamedOnlyInAgentMode() { + AIFeatureScope.enabled { + let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let workspace = Self.makeWorkspace(phase: .idle, agentSessions: registry) + let session = registry.startSession(for: workspace.connectionId) + + #expect(workspace.displayedAgentSession == nil) + #expect(workspace.paneRenderKey.agentSessionId == nil) + + workspace.contentMode = .agent + + #expect(workspace.displayedAgentSession === session) + #expect(workspace.paneRenderKey.agentSessionId == session.id) } } - /// The overflow menu sends an `NSMenuItem`, and reading `selectedIndex` off whatever arrived - /// meant choosing a mode from the overflow did nothing at all. - @Test("A segment action resolves its index from either sender") - func segmentIndexAcceptsBothSenders() { - let group = MainWindowToolbar.makeContentModeGroup(target: nil, action: #selector(NSResponder.selectAll(_:))) - group.selectedIndex = 1 + @Test("A workspace reads its sessions from the registry it was given") + func workspaceReadsItsOwnRegistry() { + AIFeatureScope.enabled { + let mine = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let other = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let workspace = Self.makeWorkspace(phase: .idle, agentSessions: mine) + workspace.contentMode = .agent + other.startSession(for: workspace.connectionId) - let fromGroup = MainWindowToolbar.segmentIndex(from: group, group: group) - #expect(fromGroup == 1) + #expect(workspace.displayedAgentSession == nil) - let menuItem = NSMenuItem() - menuItem.tag = 0 - #expect(MainWindowToolbar.segmentIndex(from: menuItem, group: group) == 0) + let session = mine.startSession(for: workspace.connectionId) + #expect(workspace.displayedAgentSession === session) + } + } - #expect(MainWindowToolbar.segmentIndex(from: nil, group: group) == 1) + /// The conversation is what Agent mode draws while the connection is up or coming up, and the + /// unavailable screen, with its Retry, is what it draws over one that cannot be reached. + @Test("The detail column follows the mode, except over a connection that cannot be reached") + func detailModeFollowsTheModeAndThePane() { + AIFeatureScope.enabled { + let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let connecting = Self.makeWorkspace(phase: .connecting, agentSessions: registry) + let failed = Self.makeWorkspace( + phase: .unavailable(.failed(ConnectionFailureInfo(message: "refused"))), + agentSessions: registry + ) + + #expect(connecting.detailMode == .browse) + connecting.contentMode = .agent + failed.contentMode = .agent + #expect(connecting.detailMode == .agent) + #expect(failed.detailMode == .browse) + } } - @Test("The mode control owns an overflow menu with one item per mode") - func menuFormHasEveryMode() throws { - let group = MainWindowToolbar.makeContentModeGroup(target: nil, action: #selector(NSResponder.selectAll(_:))) - let submenu = try #require(group.menuFormRepresentation?.submenu) + /// The result column is one hosting controller per window, drawing whichever session is open, so + /// a view holding the choice handed one session's view to the next. Nothing stores it: a session + /// is opened on its statements. + @Test("Which result view is showing belongs to the session") + func resultSegmentBelongsToTheSession() { + AIFeatureScope.enabled { + let registry = AgentSessionRegistry(store: AgentSessionStore(directory: Self.temporaryDirectory())) + let connectionId = UUID() + let first = registry.startSession(for: connectionId) + let second = registry.startSession(for: connectionId) + + first.resultSegment = .results - #expect(submenu.items.count == ConnectionWorkspaceContentMode.allCases.count) - for (index, item) in submenu.items.enumerated() { - #expect(item.tag == index) - #expect(item.title == ConnectionWorkspaceContentMode.allCases[index].localizedTitle) + #expect(second.resultSegment == .sql) } } - /// `isNavigational` lets AppKit lift an item out of its declared slot and pin it to the leading - /// edge, which is what put the sidebar control past the sidebar divider. - @Test("The mode control stays in the slot it was given") - func modeControlIsNotNavigational() { - let group = MainWindowToolbar.makeContentModeGroup(target: nil, action: #selector(NSResponder.selectAll(_:))) - #expect(group.isNavigational == false) - #expect(group.selectionMode == NSToolbarItemGroup.SelectionMode.selectOne) + /// Nothing stores the choice, so a session comes back from disk on its statements. + @Test("The result view is not carried across a relaunch") + func resultSegmentIsNotPersisted() throws { + let store = AgentSessionStore(directory: Self.temporaryDirectory()) + let registry = AgentSessionRegistry(store: store) + let session = registry.startSession(for: UUID()) + session.resultSegment = .results + registry.persistNow() + + let reopened = AgentSessionRegistry(store: store) + let restored = try #require(reopened.session(id: session.id)) + + #expect(restored.resultSegment == .sql) + } + + private static func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("ContentModeTests-\(UUID().uuidString)", isDirectory: true) + } + + private static func makeWorkspace( + phase: ConnectionWindowPhase, + agentSessions: AgentSessionRegistry + ) -> ConnectionWorkspace { + let connection = TestFixtures.makeConnection(type: .mysql) + return ConnectionWorkspace( + connectionId: connection.id, + payload: nil, + autoConnect: false, + payloadConnection: connection, + session: nil, + sessionState: nil, + trailingPaneState: nil, + phase: phase, + agentSessions: agentSessions + ) } } diff --git a/TableProTests/Core/Services/Infrastructure/MenuContentModeParityTests.swift b/TableProTests/Core/Services/Infrastructure/MenuContentModeParityTests.swift new file mode 100644 index 000000000..548446cba --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/MenuContentModeParityTests.swift @@ -0,0 +1,249 @@ +// +// MenuContentModeParityTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +/// The menu bar and the titlebar answer for one command, so they have to answer the same. +/// +/// The revamp moved most of these commands out of the titlebar, which makes the menu bar the place +/// they now live: a command the toolbar's resolver refuses in Agent mode and the menu validator +/// still lights is more wrongly enabled than it was before the move, not less. Refresh over a grid +/// that is not mounted, Save over a commit gate frozen at the moment the mode changed, Command Y +/// flipping a persisted flag for a drawer that is not there and that then springs open on the way +/// back to browsing. +/// +/// The pair table is the contract, and `everyBrowseOnlyItemHasAMenuTwin` derives it back out of the +/// toolbar so the table cannot be the only thing that knows: an item made browse-only there without +/// an entry here fails rather than ships enabled on the menu bar. +@Suite("Menu and toolbar agree about the content mode") +@MainActor +struct MenuContentModeParityTests { + /// One command, spelled for each surface. + private struct CommandPair { + let selector: Selector + let identifier: NSToolbarItem.Identifier + var name: String { NSStringFromSelector(selector) } + } + + private static let pairs: [CommandPair] = [ + CommandPair( + selector: #selector(MainSplitViewController.refreshDatabase(_:)), + identifier: MainWindowToolbar.refresh + ), + CommandPair( + selector: #selector(MainSplitViewController.saveDocument(_:)), + identifier: MainWindowToolbar.saveChanges + ), + CommandPair( + selector: #selector(MainSplitViewController.addRow(_:)), + identifier: MainWindowToolbar.addRow + ), + CommandPair( + selector: #selector(MainSplitViewController.restorePreviousValues(_:)), + identifier: MainWindowToolbar.restorePreviousValues + ), + CommandPair( + selector: #selector(MainSplitViewController.previewSQL(_:)), + identifier: MainWindowToolbar.previewSQL + ), + CommandPair( + selector: #selector(MainSplitViewController.toggleResults(_:)), + identifier: MainWindowToolbar.results + ), + CommandPair( + selector: #selector(MainSplitViewController.toggleQueryHistory(_:)), + identifier: MainWindowToolbar.history + ), + CommandPair( + selector: #selector(MainSplitViewController.newEditorTab(_:)), + identifier: MainWindowToolbar.newTab + ), + CommandPair( + selector: #selector(MainSplitViewController.openQuickSwitcher(_:)), + identifier: MainWindowToolbar.quickSwitcher + ), + CommandPair( + selector: #selector(MainSplitViewController.exportTables(_:)), + identifier: MainWindowToolbar.exportTables + ), + CommandPair( + selector: #selector(MainSplitViewController.importData(_:)), + identifier: MainWindowToolbar.importTables + ), + CommandPair( + selector: #selector(MainSplitViewController.showServerDashboard(_:)), + identifier: MainWindowToolbar.dashboard + ), + CommandPair( + selector: #selector(MainSplitViewController.navigateBack(_:)), + identifier: MainWindowToolbar.navigateBack + ), + CommandPair( + selector: #selector(MainSplitViewController.navigateForward(_:)), + identifier: MainWindowToolbar.navigateForward + ), + ] + + /// Everything either surface could ask about is true, so the content mode is the only thing left + /// that can answer no. A query tab, because it is the one kind with a results pane, and the one + /// the two Show Results rules are written against. + private static func toolbarContext(_ contentMode: ConnectionWorkspaceContentMode) -> ToolbarContext { + ToolbarContext( + tabKind: .query, + resultsMode: .data, + contentMode: contentMode, + pane: .content, + isConnected: true, + hasSelectedWorkspace: true, + canToggleTrailingPane: true, + pendingChange: .data, + hasDataPendingChanges: true, + canAddRow: true, + canRestorePreviousValues: true, + canNavigateBack: true, + canNavigateForward: true, + supportsContainerSwitching: true, + supportsImport: true, + supportsServerDashboard: true, + isAIEnabled: true + ) + } + + /// The same facts in the menu bar's vocabulary. `supportsImport` is the driver's capability and + /// `hasImportFormats` the list it produced, which is the one place the two surfaces read a + /// different fact about the same command; both are true here so the rule underneath is what the + /// comparison sees. + private static func menuContext(_ contentMode: ConnectionWorkspaceContentMode) -> MenuValidationContext { + var context = MenuValidationContext() + context.hasSelectedWorkspace = true + context.isConnected = true + context.isAgentMode = contentMode == .agent + context.isQueryTab = true + context.hasPendingChanges = true + context.hasDataPendingChanges = true + context.isCurrentTabEditable = true + context.isCurrentTabSchemaResolved = true + context.canRestorePreviousValues = true + context.canNavigateBack = true + context.canNavigateForward = true + context.hasImportFormats = true + context.supportsServerDashboard = true + context.hasAssistantConversation = true + context.hasStoredConversations = true + return context + } + + private static func menuAnswer(_ pair: CommandPair, _ contentMode: ConnectionWorkspaceContentMode) -> Bool { + MainSplitViewController.isEnabled(pair.selector, context: menuContext(contentMode)) + } + + private static func toolbarAnswer(_ pair: CommandPair, _ contentMode: ConnectionWorkspaceContentMode) -> Bool { + ToolbarContextResolver.isEnabled(pair.identifier, context: toolbarContext(contentMode)) + } + + @Test("Both surfaces give one answer per command in both modes") + func surfacesAgree() { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for pair in Self.pairs { + #expect( + Self.menuAnswer(pair, contentMode) == Self.toolbarAnswer(pair, contentMode), + "\(pair.name) and \(pair.identifier.rawValue) disagree in \(contentMode.rawValue)" + ) + } + } + } + + /// Without this the suite would pass over a table of commands that are disabled everywhere. + @Test("Every command in the table answers while browsing") + func browsingEnablesEveryPair() { + for pair in Self.pairs { + #expect(Self.menuAnswer(pair, .browse), "\(pair.name) is dim on the menu bar while browsing") + #expect(Self.toolbarAnswer(pair, .browse), "\(pair.identifier.rawValue) is dim in the titlebar") + } + } + + @Test("Agent mode dims every command in the table") + func agentModeDisablesEveryPair() { + for pair in Self.pairs { + #expect(!Self.menuAnswer(pair, .agent), "\(pair.name) stayed lit on the menu bar in Agent mode") + #expect(!Self.toolbarAnswer(pair, .agent), "\(pair.identifier.rawValue) stayed lit in Agent mode") + } + } + + /// The table read back out of the toolbar. Every item the resolver makes browse-only has to have + /// a menu twin here, or the command keeps a live menu-bar route into content the window is not + /// drawing. The trailing-pane and assistant toggles are not derived: they carry their own mode + /// answer in `canToggleTrailingPane` and `canToggleAssistant`, which the window computes. + @Test("Every browse-only toolbar item has a menu twin in the table") + func everyBrowseOnlyItemHasAMenuTwin() { + let candidates = Set( + MainWindowToolbar.allowedItemIdentifiers + + [MainWindowToolbar.navigateBack, MainWindowToolbar.navigateForward] + ) + let browseOnly = candidates.filter { identifier in + ToolbarContextResolver.isEnabled(identifier, context: Self.toolbarContext(.browse)) + && !ToolbarContextResolver.isEnabled(identifier, context: Self.toolbarContext(.agent)) + } + let covered = Set(Self.pairs.map(\.identifier)) + + #expect(!browseOnly.isEmpty, "Nothing is browse-only, so this guard would pass vacuously") + #expect( + browseOnly.subtracting(covered).isEmpty, + "Browse-only in the titlebar with no menu twin: \(browseOnly.subtracting(covered).map(\.rawValue))" + ) + #expect( + covered.subtracting(browseOnly).isEmpty, + "In the table and not browse-only: \(covered.subtracting(browseOnly).map(\.rawValue))" + ) + } + + /// The other half of the rule, and the part that keeps it honest. A command that still acts in + /// Agent mode must answer the same in both modes: the window, the session and the conversation + /// are all still there, and dimming them would take away the route out of a mode the user is in. + @Test("The commands Agent mode still runs are untouched by it") + func agentModeLeavesItsOwnCommandsAlone() { + let unaffected: [Selector] = [ + #selector(MainSplitViewController.switchConnection(_:)), + #selector(MainSplitViewController.closeConnection(_:)), + #selector(MainSplitViewController.setSafeModeLevel(_:)), + #selector(MainSplitViewController.setContentModeFromMenu(_:)), + #selector(MainSplitViewController.toggleContentModeFromMenu(_:)), + #selector(MainSplitViewController.newAIConversation(_:)), + #selector(MainSplitViewController.switchAIConversation(_:)), + #selector(MainSplitViewController.clearAIConversations(_:)), + #selector(MainSplitViewController.focusAssistant(_:)), + #selector(MainSplitViewController.toggleWorkspaceRail(_:)), + ] + + for selector in unaffected { + #expect( + MainSplitViewController.isEnabled(selector, context: Self.menuContext(.agent)) + == MainSplitViewController.isEnabled(selector, context: Self.menuContext(.browse)), + "\(NSStringFromSelector(selector)) changed answer with the content mode" + ) + } + } + + /// Named rather than left to the equality above, which two disabled answers would also satisfy. + @Test("The window's own commands still answer in Agent mode") + func theWindowsCommandsAnswerInAgentMode() { + let context = Self.menuContext(.agent) + let live: [Selector] = [ + #selector(MainSplitViewController.switchConnection(_:)), + #selector(MainSplitViewController.closeConnection(_:)), + #selector(MainSplitViewController.setSafeModeLevel(_:)), + #selector(MainSplitViewController.newAIConversation(_:)), + #selector(MainSplitViewController.clearAIConversations(_:)), + ] + for selector in live { + #expect( + MainSplitViewController.isEnabled(selector, context: context), + "\(NSStringFromSelector(selector)) is dim in Agent mode" + ) + } + } +} diff --git a/TableProTests/Core/Services/Infrastructure/MenuValidationCoverageTests.swift b/TableProTests/Core/Services/Infrastructure/MenuValidationCoverageTests.swift index e88bcbe4e..c35aa8784 100644 --- a/TableProTests/Core/Services/Infrastructure/MenuValidationCoverageTests.swift +++ b/TableProTests/Core/Services/Infrastructure/MenuValidationCoverageTests.swift @@ -88,6 +88,154 @@ struct MenuValidationCoverageTests { } } + /// The toolbar's Actions pull-down carries no target, so each entry reaches the window's + /// controller through the responder chain and is validated there, the way the menu bar's own + /// commands are. An entry the controller does not implement reaches nothing and AppKit draws it + /// disabled, and one it implements with no arm stays lit over a window that cannot run it. + /// Every context the resolver can be asked about is walked, and so are the leaves its two + /// submenus fill when they open. A submenu's own row carries no selector, so it adds none. + @Test("Every Actions entry reaches the window and is decided there") + func everyActionsEntryIsAnsweredAndDecided() { + let tabKinds: [TabType?] = [ + .query, .table, .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, nil, + ] + var selectors: Set = [ImportFormatMenuDelegate.action, ContentModeMenuDelegate.action] + for tabKind in tabKinds { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for isConnected in [true, false] { + let context = ToolbarContext( + tabKind: tabKind, + resultsMode: .data, + contentMode: contentMode, + pane: isConnected ? .content : .unavailable(.notConnected), + isConnected: isConnected, + hasSelectedWorkspace: true, + supportsImport: true, + supportsServerDashboard: true, + isAIEnabled: true + ) + for section in ConnectionActionsMenuResolver.sections(context) { + selectors.formUnion(section.entries.compactMap(\.selector)) + } + } + } + } + + let unanswered = selectors + .filter { !MainSplitViewController.instancesRespond(to: $0) } + .map(NSStringFromSelector) + let undecided = selectors + .filter { !liveValidatedSelectors.contains($0) } + .filter { MainSplitViewController.resolvedEnablement($0, context: MenuValidationContext()) == nil } + .map(NSStringFromSelector) + + #expect(selectors.count > 20, "Only \(selectors.count) selectors collected; the walk missed contexts") + #expect(unanswered.isEmpty, "The window does not implement these, so AppKit draws them dead: \(unanswered)") + #expect(undecided.isEmpty, "No arm in resolvedEnablement, so these stay lit: \(undecided)") + } + + /// The session commands reach the window from the rail today and from the menu bar next, and a + /// selector with no arm here is enabled over a window that cannot run it. Each of them needs the + /// rail on screen, and all but New Session need a session to act on. + @Test("Each session command is decided by the mode and by the session it would act on") + func sessionCommandsAreDecided() { + let commands: [(selector: Selector, needsTarget: Bool)] = [ + (#selector(MainSplitViewController.newAgentSession(_:)), false), + (#selector(MainSplitViewController.openAgentSession(_:)), true), + (#selector(MainSplitViewController.closeAgentSession(_:)), true), + (#selector(MainSplitViewController.deleteAgentSession(_:)), true), + ] + + for command in commands { + let name = NSStringFromSelector(command.selector) + var browsing = MenuValidationContext() + browsing.agentSessionTarget = .ready + #expect( + MainSplitViewController.resolvedEnablement(command.selector, context: browsing) == false, + "\(name) is a command of the rail, which browsing does not draw" + ) + + var agent = MenuValidationContext() + agent.isAgentMode = true + #expect( + MainSplitViewController.resolvedEnablement(command.selector, context: agent) == !command.needsTarget, + "\(name) with no session highlighted" + ) + + agent.agentSessionTarget = .ready + #expect(MainSplitViewController.resolvedEnablement(command.selector, context: agent) == true, "\(name)") + } + } + + /// Closing is the one that cares what the session is doing: a session that has already ended + /// cannot be closed again, and a stopped one is still there to open or delete. + @Test("Close Session dims over a session that has already ended") + func closeSessionFollowsTheSessionsState() { + var context = MenuValidationContext() + context.isAgentMode = true + context.agentSessionTarget = .stopped + + #expect(MainSplitViewController.resolvedEnablement(Self.closeSession, context: context) == false) + #expect(MainSplitViewController.resolvedEnablement(Self.openSession, context: context) == true) + #expect(MainSplitViewController.resolvedEnablement(Self.deleteSession, context: context) == true) + } + + private static let openSession = #selector(MainSplitViewController.openAgentSession(_:)) + private static let closeSession = #selector(MainSplitViewController.closeAgentSession(_:)) + private static let deleteSession = #selector(MainSplitViewController.deleteAgentSession(_:)) + + /// The two delegate-filled lists under File > Session build their rows when they open, so the + /// menu walk above never sees them. Each row's selector still has to reach the window and be + /// decided there, which is the whole reason they carry no target. + @Test("Every delegate-filled row reaches the window and is decided there") + func delegateFilledRowsAreAnsweredAndDecided() { + let selectors: [Selector] = [ + AgentSessionMenuDelegate.action, + ConversationHistoryMenuDelegate.action, + ImportFormatMenuDelegate.action, + ContentModeMenuDelegate.action, + ] + for selector in selectors { + let name = NSStringFromSelector(selector) + #expect(MainSplitViewController.instancesRespond(to: selector), "\(name) reaches nothing") + guard !liveValidatedSelectors.contains(selector) else { continue } + #expect( + MainSplitViewController.resolvedEnablement(selector, context: MenuValidationContext()) != nil, + "\(name) has no arm, so it stays lit over a window that cannot run it" + ) + } + } + + /// The assistant's conversation commands, which had no selector at all before: the pane header's + /// buttons reached `AIChatViewModel` from inside SwiftUI, so the menu bar could not carry them. + /// They answer in both content modes, because the conversation is one thing shown two ways. + @Test("The conversation commands follow the assistant rather than the mode") + func conversationCommandsFollowTheAssistant() { + let newConversation = #selector(MainSplitViewController.newAIConversation(_:)) + let switchConversation = #selector(MainSplitViewController.switchAIConversation(_:)) + let clearConversations = #selector(MainSplitViewController.clearAIConversations(_:)) + + var context = MenuValidationContext() + #expect(MainSplitViewController.resolvedEnablement(newConversation, context: context) == false) + #expect(MainSplitViewController.resolvedEnablement(switchConversation, context: context) == false) + #expect(MainSplitViewController.resolvedEnablement(clearConversations, context: context) == false) + + context.hasAssistantConversation = true + #expect(MainSplitViewController.resolvedEnablement(newConversation, context: context) == true) + #expect( + MainSplitViewController.resolvedEnablement(clearConversations, context: context) == false, + "Nothing stored is nothing to clear" + ) + + context.hasStoredConversations = true + #expect(MainSplitViewController.resolvedEnablement(switchConversation, context: context) == true) + #expect(MainSplitViewController.resolvedEnablement(clearConversations, context: context) == true) + + context.isAgentMode = true + #expect(MainSplitViewController.resolvedEnablement(newConversation, context: context) == true) + #expect(MainSplitViewController.resolvedEnablement(clearConversations, context: context) == true) + } + /// The fall-through still has to stand for everything the window does not own, or the system's /// own items would arrive disabled. @Test("A command the window does not own is left alone") diff --git a/TableProTests/Core/Services/Infrastructure/ToolbarContextResolverTests.swift b/TableProTests/Core/Services/Infrastructure/ToolbarContextResolverTests.swift new file mode 100644 index 000000000..8c9556db4 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ToolbarContextResolverTests.swift @@ -0,0 +1,456 @@ +// +// ToolbarContextResolverTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@Suite("Toolbar context resolver") +struct ToolbarContextResolverTests { + /// `TabType` is not `CaseIterable`, so the list is written out. A ninth kind fails the + /// exhaustive switch in the resolver before it can fail here. + private static let tabKinds: [TabType] = [ + .query, .table, .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, + ] + + private static let panes: [ConnectionWindowPane] = [ + .connecting, .unavailable(.notConnected), .content, .empty, + ] + + /// The hit targets in the default set: one sidebar toggle, the centred pair, the three content + /// commands, Safe Mode and the trailing-pane toggle. Spacers and tracking separators take no + /// click and are not counted. + private static let defaultHitTargets: [NSToolbarItem.Identifier] = [ + .toggleSidebar, + MainWindowToolbar.connection, + MainWindowToolbar.database, + MainWindowToolbar.refresh, + MainWindowToolbar.saveChanges, + MainWindowToolbar.actions, + MainWindowToolbar.safeMode, + MainWindowToolbar.inspector, + ] + + private static func context( + tabKind: TabType? = .table, + resultsMode: ResultsViewMode? = .data, + contentMode: ConnectionWorkspaceContentMode = .browse, + pane: ConnectionWindowPane = .content, + isFileBased: Bool = false, + supportsContainerSwitching: Bool = true, + isAIEnabled: Bool = true + ) -> ToolbarContext { + ToolbarContext( + tabKind: tabKind, + resultsMode: resultsMode, + contentMode: contentMode, + pane: pane, + isConnected: pane == .content, + hasSelectedWorkspace: true, + canToggleTrailingPane: pane == .content, + isFileBased: isFileBased, + supportsContainerSwitching: supportsContainerSwitching, + isAIEnabled: isAIEnabled + ) + } + + private static func hidden(_ context: ToolbarContext) -> Set { + ToolbarContextResolver.visibility(for: context.visibilityKey).hidden + } + + /// Every context the window can reach, as far as the item set is concerned. + private static var everyContext: [ToolbarContext] { + var contexts: [ToolbarContext] = [] + for tabKind in tabKinds + [nil] { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for pane in panes { + for isFileBased in [true, false] { + for supportsContainerSwitching in [true, false] { + contexts.append( + context( + tabKind: tabKind, + contentMode: contentMode, + pane: pane, + isFileBased: isFileBased, + supportsContainerSwitching: supportsContainerSwitching + ) + ) + } + } + } + } + } + return contexts + } + + private static func visibleCount(_ context: ToolbarContext) -> Int { + let hiddenSet = Self.hidden(context) + return defaultHitTargets.filter { !hiddenSet.contains($0) }.count + } + + // MARK: - The ceiling + + /// The whole point of the revamp: no context may put more than eight things to click in the + /// titlebar. The guard test this replaces counted identifiers rather than hit targets, which is + /// how a two-segment control was added to a full titlebar and passed. + @Test("No reachable context exceeds eight hit targets") + func hitTargetCeiling() { + for tabKind in Self.tabKinds + [nil] { + for contentMode in ConnectionWorkspaceContentMode.allCases { + for pane in Self.panes { + for isFileBased in [true, false] { + let context = Self.context( + tabKind: tabKind, + contentMode: contentMode, + pane: pane, + isFileBased: isFileBased + ) + #expect(Self.visibleCount(context) <= 8) + } + } + } + } + } + + @Test("Agent mode shows six") + func agentModeShowsSix() { + #expect(Self.visibleCount(Self.context(contentMode: .agent)) == 6) + } + + @Test("A file-based connection drops the container capsule") + func fileBasedShowsSeven() { + #expect(Self.visibleCount(Self.context(isFileBased: true)) == 7) + } + + // MARK: - What may be hidden + + /// An item the user dragged in from the customization palette is opt-in, so it stays where they + /// put it and dims. Only the default set may be taken off screen. + @Test("The hidden set never reaches past the default set") + func hiddenStaysInsideTheDefaultSet() { + for context in Self.everyContext { + #expect(Self.hidden(context).isSubset(of: ToolbarContextResolver.hideableIdentifiers)) + } + } + + /// The other half of the same rule, stated against the list a user actually drags from. With + /// the intersection inside the resolver this holds for any context a later change invents, + /// not only for the three items the contexts name today. + @Test("An item only the palette offers is never hidden") + func paletteOnlyItemsAreNeverHidden() { + let paletteOnly = Set(MainWindowToolbar.allowedItemIdentifiers) + .subtracting(MainWindowToolbar.defaultItemIdentifiers) + #expect(!paletteOnly.isEmpty) + for context in Self.everyContext { + #expect(Self.hidden(context).isDisjoint(with: paletteOnly)) + } + } + + /// Spaces and tracking separators are in the hideable set, because it is the default list and + /// filtering them out by prefix would take the two pane toggles with them. A context has no + /// reason to name one, and hiding a tracking separator would unhook the titlebar from a pane. + @Test("No context hides a space or a tracking separator") + func spacesAreNeverHidden() { + var spaces: Set = [.flexibleSpace, .space, .sidebarTrackingSeparator] + if #available(macOS 14.0, *) { + spaces.insert(.inspectorTrackingSeparator) + } + for context in Self.everyContext { + #expect(Self.hidden(context).isDisjoint(with: spaces)) + } + } + + /// The window's own identity, the pull-down that carries everything displaced, the control that + /// says whether a keystroke can reach a live table, and the two pane toggles. None of these has + /// a context in which it means nothing, and the connection capsule is also what Switch + /// Connection presents from. + @Test("The permanent controls are never hidden") + func permanentControlsAreNeverHidden() { + let permanent: Set = [ + .toggleSidebar, + MainWindowToolbar.connection, + MainWindowToolbar.actions, + MainWindowToolbar.safeMode, + MainWindowToolbar.inspector, + ] + for context in Self.everyContext { + #expect(Self.hidden(context).isDisjoint(with: permanent)) + } + } + + // MARK: - The anti-reflow rule + + /// `isHidden` is written only from the slow-moving subset, so the item set can change on a tab + /// switch, a mode switch or a connection switch and on nothing else. A keystroke in a cell + /// editor builds the key, compares it and moves nothing. + @Test("Visibility ignores everything transient") + func visibilityIgnoresTransientState() { + let quiet = ToolbarContext( + tabKind: .table, + resultsMode: .data, + contentMode: .browse, + pane: .content, + isConnected: true, + hasSelectedWorkspace: true, + canToggleTrailingPane: true, + supportsContainerSwitching: true + ) + let busy = ToolbarContext( + tabKind: .table, + resultsMode: .data, + contentMode: .browse, + pane: .connecting, + isConnected: false, + hasSelectedWorkspace: true, + canToggleTrailingPane: false, + pendingChange: .data, + hasDataPendingChanges: true, + blocksAllWrites: true, + canAddRow: true, + canRestorePreviousValues: true, + canNavigateBack: true, + canNavigateForward: true, + supportsContainerSwitching: true + ) + + #expect(quiet.visibilityKey == busy.visibilityKey) + #expect(Self.hidden(quiet) == Self.hidden(busy)) + } + + /// The whole context and the key the toolbar builds on its own have to carry the same eight + /// facts, or a context built for enablement would disagree with the shape it is drawn over. + @Test("A context built over a key carries that key back") + func contextOverAKeyRoundTrips() { + let key = ToolbarContext.VisibilityKey( + tabKind: .createTable, + resultsMode: .structure, + contentMode: .agent, + isFileBased: true, + supportsContainerSwitching: false, + supportsImport: true, + supportsServerDashboard: true, + isAIEnabled: true + ) + let context = ToolbarContext( + key: key, + pane: .content, + isConnected: true, + hasSelectedWorkspace: true, + canToggleTrailingPane: true, + pendingChange: .createTable, + hasDataPendingChanges: false, + blocksAllWrites: false, + canAddRow: false, + canRestorePreviousValues: false, + canNavigateBack: false, + canNavigateForward: false + ) + + #expect(context.visibilityKey == key) + #expect(context.pendingChange == .createTable) + } + + // MARK: - The commit verb + + /// The verb comes from the tab kind, never from what is staged, so an edit that makes a + /// definition valid or invalid cannot relabel the control and reflow a labelled titlebar. + @Test("The commit verb follows the tab kind", arguments: tabKinds + [nil]) + func commitVerbFollowsTheTabKind(tabKind: TabType?) { + let expected: String + switch tabKind { + case .createTable: + expected = String(localized: "Create Table") + case .usersRoles: + expected = String(localized: "Apply Changes") + default: + expected = String(localized: "Save Changes") + } + #expect(ToolbarContextResolver.commitVerb(for: tabKind) == expected) + } + + /// Three verbs for three different commits. Two kinds sharing one would have the control offer + /// to save a definition that is about to be created. + @Test("The three commit verbs are distinct") + func commitVerbsAreDistinct() { + let verbs = Set([TabType.table, .createTable, .usersRoles].map { ToolbarContextResolver.commitVerb(for: $0) }) + #expect(verbs.count == 3) + } + + // MARK: - Per-kind sets + + @Test("An unsaved definition has nothing to reload") + func createTableHidesRefresh() { + let hidden = Self.hidden(Self.context(tabKind: .createTable)) + #expect(hidden.contains(MainWindowToolbar.refresh)) + #expect(hidden.contains(MainWindowToolbar.saveChanges) == false) + } + + @Test( + "The four kinds that can never stage a change lose the commit control", + arguments: [TabType.erDiagram, .serverDashboard, .insights, .objectSource] + ) + func readOnlyKindsHideSaveChanges(tabKind: TabType) { + let hidden = Self.hidden(Self.context(tabKind: tabKind)) + #expect(hidden.contains(MainWindowToolbar.saveChanges)) + #expect(hidden.contains(MainWindowToolbar.refresh) == false) + } + + @Test("The three kinds that can stage a change keep both content commands", arguments: [ + TabType.query, .table, .usersRoles, + ]) + func editableKindsKeepBoth(tabKind: TabType) { + let hidden = Self.hidden(Self.context(tabKind: tabKind)) + #expect(hidden.contains(MainWindowToolbar.saveChanges) == false) + #expect(hidden.contains(MainWindowToolbar.refresh) == false) + } + + @Test("Agent mode has no grid to reload and nothing mounted to commit") + func agentModeHidesBothContentCommands() { + let hidden = Self.hidden(Self.context(contentMode: .agent)) + #expect(hidden.contains(MainWindowToolbar.refresh)) + #expect(hidden.contains(MainWindowToolbar.saveChanges)) + } + + @Test("A window with no selected tab keeps the full set") + func noSelectedTabKeepsEverything() { + #expect(Self.hidden(Self.context(tabKind: nil)).isEmpty) + } + + @Test("The results mode never moves an item", arguments: ResultsViewMode.allCases) + func resultsModeNeverMovesAnything(mode: ResultsViewMode) { + #expect( + Self.hidden(Self.context(resultsMode: mode)) + == Self.hidden(Self.context(resultsMode: .data)) + ) + } + + @Test("The container capsule goes when the engine has nothing to switch to") + func containerCapsuleVisibility() { + #expect( + Self.hidden(Self.context(isFileBased: false, supportsContainerSwitching: true)) + .contains(MainWindowToolbar.database) == false + ) + #expect( + Self.hidden(Self.context(isFileBased: true, supportsContainerSwitching: true)) + .contains(MainWindowToolbar.database) + ) + #expect( + Self.hidden(Self.context(isFileBased: false, supportsContainerSwitching: false)) + .contains(MainWindowToolbar.database) + ) + } + + // MARK: - Enablement + + /// Switch Connection is the window's command and the route back from a connection that failed, + /// so it answers in every phase including the one with no session at all. + @Test("Only the connection chooser answers over an empty pane") + func emptyPaneDisablesEverythingButTheConnection() { + let context = Self.context(pane: .empty) + for identifier in Self.defaultHitTargets where identifier != MainWindowToolbar.connection { + guard identifier != .toggleSidebar else { continue } + #expect(ToolbarContextResolver.isEnabled(identifier, context: context) == false) + } + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.connection, context: context)) + } + + /// The pull-down offers Switch Connection, Reconnect and Close Connection here, so it is the + /// route out of a window whose connection went away. + @Test("The pull-down answers while connecting and while unavailable", arguments: [ + ConnectionWindowPane.connecting, .unavailable(.notConnected), + ]) + func actionsAnswersWithoutASession(pane: ConnectionWindowPane) { + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.actions, context: Self.context(pane: pane))) + } + + /// The shipped rule was `connected && !isTableTab`, which enabled the command on the five kinds + /// that have no results pane at all and then wrote a collapse flag with no tab-kind guard. + @Test("Show Results answers on a query tab and nowhere else", arguments: tabKinds) + func showResultsIsQueryOnly(tabKind: TabType) { + let enabled = ToolbarContextResolver.isEnabled( + MainWindowToolbar.results, + context: Self.context(tabKind: tabKind) + ) + #expect(enabled == (tabKind == .query)) + } + + /// The drawer is not mounted in Agent mode, and toggling it there flipped a persisted flag that + /// sprang it open on the way back to browsing. + @Test("Query History answers while browsing a live session and nowhere else") + func queryHistoryGating() { + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.history, context: Self.context())) + #expect( + ToolbarContextResolver.isEnabled( + MainWindowToolbar.history, + context: Self.context(contentMode: .agent) + ) == false + ) + #expect( + ToolbarContextResolver.isEnabled( + MainWindowToolbar.history, + context: Self.context(pane: .connecting) + ) == false + ) + } + + /// A connection that drops with the pane open must still be able to close it, which is the + /// state the old `connected` rule disabled on the app's minimum OS. + @Test("The trailing-pane toggle follows whether the pane can be toggled") + func trailingPaneToggleFollowsItsOwnRule() { + var context = Self.context(pane: .unavailable(.notConnected)) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.inspector, context: context) == false) + + context = ToolbarContext( + tabKind: .table, + contentMode: .browse, + pane: .unavailable(.notConnected), + isConnected: false, + hasSelectedWorkspace: true, + canToggleTrailingPane: true + ) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.inspector, context: context)) + } + + /// The old switch ended in `default: return true`, so every identifier nobody had thought about + /// was live, including over a window with no coordinator and no session. + @Test("An identifier the toolbar does not vend does not answer") + func unknownIdentifiersDoNotAnswer() { + #expect( + ToolbarContextResolver.isEnabled( + NSToolbarItem.Identifier("com.TablePro.toolbar.nothingAtAll"), + context: Self.context() + ) == false + ) + } + + @Test("The commit control answers only over staged work a safe mode allows") + func commitControlGating() { + var context = Self.context() + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.saveChanges, context: context) == false) + + context = ToolbarContext( + tabKind: .table, + contentMode: .browse, + pane: .content, + isConnected: true, + hasSelectedWorkspace: true, + pendingChange: .data, + supportsContainerSwitching: true + ) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.saveChanges, context: context)) + + context = ToolbarContext( + tabKind: .table, + contentMode: .browse, + pane: .content, + isConnected: true, + hasSelectedWorkspace: true, + pendingChange: .data, + blocksAllWrites: true, + supportsContainerSwitching: true + ) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.saveChanges, context: context) == false) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/TrailingPaneCommandTitleTests.swift b/TableProTests/Core/Services/Infrastructure/TrailingPaneCommandTitleTests.swift new file mode 100644 index 000000000..fb455eedf --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/TrailingPaneCommandTitleTests.swift @@ -0,0 +1,274 @@ +// +// TrailingPaneCommandTitleTests.swift +// TableProTests +// +// The View menu's two trailing-pane commands take their titles, their effects and their enablement +// from the surface the pane is drawing. They used to read the stored surface with no content-mode +// term, so in Agent mode Show Inspector read Hide Inspector over the result column and closed it +// with nothing able to bring it back, and Show Assistant wrote a browse preference and changed +// nothing on screen. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Trailing pane command titles") +struct TrailingPaneCommandTitleTests { + private struct Row { + let mode: ConnectionWorkspaceContentMode + let stored: TrailingPaneSurface + let isOpen: Bool + let isAIEnabled: Bool + let paneTitle: String + let assistantTitle: String + + var context: TrailingPaneCommandResolver.Context { + TrailingPaneCommandResolver.Context( + contentMode: mode, + storedSurface: stored, + isPaneOpen: isOpen, + isAIEnabled: isAIEnabled, + hasContent: true + ) + } + + var label: String { + "\(mode) stored=\(stored) open=\(isOpen) ai=\(isAIEnabled)" + } + } + + private static let showInspector = String(localized: "Show Inspector") + private static let hideInspector = String(localized: "Hide Inspector") + private static let showAssistant = String(localized: "Show Assistant") + private static let hideAssistant = String(localized: "Hide Assistant") + private static let showResult = String(localized: "Show Result") + private static let hideResult = String(localized: "Hide Result") + + /// Every combination of mode, stored surface, pane state and AI setting, written out rather than + /// derived, so the table is the specification and not a second copy of the resolver. + /// + /// Agent mode with the AI setting off is browsing, and the stored `.agentResult` a browse window + /// can hold in memory resolves to the inspector, so those rows read like the inspector's. + private static let rows: [Row] = [ + Row(mode: .browse, stored: .inspector, isOpen: true, isAIEnabled: true, + paneTitle: hideInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .inspector, isOpen: true, isAIEnabled: false, + paneTitle: hideInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .inspector, isOpen: false, isAIEnabled: true, + paneTitle: showInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .inspector, isOpen: false, isAIEnabled: false, + paneTitle: showInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .assistant, isOpen: true, isAIEnabled: true, + paneTitle: showInspector, assistantTitle: hideAssistant), + Row(mode: .browse, stored: .assistant, isOpen: true, isAIEnabled: false, + paneTitle: hideInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .assistant, isOpen: false, isAIEnabled: true, + paneTitle: showInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .assistant, isOpen: false, isAIEnabled: false, + paneTitle: showInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .agentResult, isOpen: true, isAIEnabled: true, + paneTitle: hideInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .agentResult, isOpen: true, isAIEnabled: false, + paneTitle: hideInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .agentResult, isOpen: false, isAIEnabled: true, + paneTitle: showInspector, assistantTitle: showAssistant), + Row(mode: .browse, stored: .agentResult, isOpen: false, isAIEnabled: false, + paneTitle: showInspector, assistantTitle: showAssistant), + Row(mode: .agent, stored: .inspector, isOpen: true, isAIEnabled: true, + paneTitle: hideResult, assistantTitle: showAssistant), + Row(mode: .agent, stored: .inspector, isOpen: true, isAIEnabled: false, + paneTitle: hideInspector, assistantTitle: showAssistant), + Row(mode: .agent, stored: .inspector, isOpen: false, isAIEnabled: true, + paneTitle: showResult, assistantTitle: showAssistant), + Row(mode: .agent, stored: .inspector, isOpen: false, isAIEnabled: false, + paneTitle: showInspector, assistantTitle: showAssistant), + Row(mode: .agent, stored: .assistant, isOpen: true, isAIEnabled: true, + paneTitle: hideResult, assistantTitle: showAssistant), + Row(mode: .agent, stored: .assistant, isOpen: true, isAIEnabled: false, + paneTitle: hideInspector, assistantTitle: showAssistant), + Row(mode: .agent, stored: .assistant, isOpen: false, isAIEnabled: true, + paneTitle: showResult, assistantTitle: showAssistant), + Row(mode: .agent, stored: .assistant, isOpen: false, isAIEnabled: false, + paneTitle: showInspector, assistantTitle: showAssistant), + Row(mode: .agent, stored: .agentResult, isOpen: true, isAIEnabled: true, + paneTitle: hideResult, assistantTitle: showAssistant), + Row(mode: .agent, stored: .agentResult, isOpen: true, isAIEnabled: false, + paneTitle: hideInspector, assistantTitle: showAssistant), + Row(mode: .agent, stored: .agentResult, isOpen: false, isAIEnabled: true, + paneTitle: showResult, assistantTitle: showAssistant), + Row(mode: .agent, stored: .agentResult, isOpen: false, isAIEnabled: false, + paneTitle: showInspector, assistantTitle: showAssistant), + ] + + @Test("The table covers every combination once") + func tableIsComplete() { + let labels = Set(Self.rows.map(\.label)) + #expect(labels.count == Self.rows.count) + #expect(Self.rows.count == ConnectionWorkspaceContentMode.allCases.count + * TrailingPaneSurface.allCases.count * 2 * 2) + } + + @Test("View > Show Inspector names the column the pane toggle acts on") + func paneToggleTitle() { + for row in Self.rows { + #expect(TrailingPaneCommandResolver.paneToggleTitle(row.context) == row.paneTitle, "\(row.label)") + } + } + + @Test("View > Show Assistant offers to hide only an assistant that is on screen") + func assistantToggleTitle() { + for row in Self.rows { + #expect( + TrailingPaneCommandResolver.assistantToggleTitle(row.context) == row.assistantTitle, + "\(row.label)" + ) + } + } + + /// A title that says Hide has to hide, and one that says Show has to open the pane on the + /// surface it names, or the menu promises one thing and does another. + @Test("Each title matches what the command then does") + func titlesMatchTheirEffects() { + for row in Self.rows { + let effect = TrailingPaneCommandResolver.paneToggle(row.context) + let hides = row.paneTitle == Self.hideInspector || row.paneTitle == Self.hideResult + #expect((effect == .hide) == hides, "\(row.label)") + if row.paneTitle == Self.showResult { + #expect(effect == .reveal(.agentResult), "\(row.label)") + } + if row.paneTitle == Self.showInspector { + #expect(effect == .reveal(.inspector), "\(row.label)") + } + + let assistant = TrailingPaneCommandResolver.assistantToggle(row.context) + if row.assistantTitle == Self.hideAssistant { + #expect(assistant == .hide, "\(row.label)") + } else { + #expect(assistant != .hide, "\(row.label)") + } + } + } + + // MARK: - Agent mode + + /// The pane toggle opens and closes the result column, and nothing it can do lands on the + /// inspector: the mode imposes the result, so a reveal of anything else opens on the result + /// anyway after writing a preference the user did not choose. + @Test("In Agent mode the pane toggle opens and closes the result column") + func agentModePaneToggleIsTheResultColumn() { + for stored in TrailingPaneSurface.allCases { + let closed = Self.context(mode: .agent, stored: stored, isOpen: false) + let open = Self.context(mode: .agent, stored: stored, isOpen: true) + #expect(TrailingPaneCommandResolver.paneToggle(closed) == .reveal(.agentResult), "\(stored)") + #expect(TrailingPaneCommandResolver.paneToggle(open) == .hide, "\(stored)") + #expect(TrailingPaneCommandResolver.canTogglePane(closed), "\(stored)") + } + } + + /// Dimmed rather than repurposed. The conversation is the content column in Agent mode, which no + /// command hides, and the pane holds the result, so there is no assistant to show or hide. + @Test("In Agent mode Show Assistant is dimmed and does nothing") + func agentModeDimsTheAssistantToggle() { + for stored in TrailingPaneSurface.allCases { + for isOpen in [true, false] { + let context = Self.context(mode: .agent, stored: stored, isOpen: isOpen) + #expect(TrailingPaneCommandResolver.assistantToggle(context) == nil, "\(stored) open=\(isOpen)") + #expect(!TrailingPaneCommandResolver.canToggleAssistant(context), "\(stored) open=\(isOpen)") + } + } + } + + /// Focus Assistant is how the keyboard reaches the conversation in Agent mode, which is the + /// other half of dimming Show Assistant there. + @Test("In Agent mode Focus Assistant goes to the conversation and Focus Inspector is dimmed") + func agentModeFocusTargets() { + let context = Self.context(mode: .agent, stored: .assistant, isOpen: true) + #expect(TrailingPaneCommandResolver.assistantFocus(context) == .conversation) + #expect(TrailingPaneCommandResolver.inspectorFocus(context) == nil) + } + + // MARK: - Browsing + + @Test("While browsing, each focus command reveals its own surface") + func browseFocusTargets() { + let context = Self.context(mode: .browse, stored: .inspector, isOpen: false) + #expect(TrailingPaneCommandResolver.inspectorFocus(context) == .trailingPane(.inspector)) + #expect(TrailingPaneCommandResolver.assistantFocus(context) == .trailingPane(.assistant)) + } + + /// Pressing the command for the surface that is not showing swaps to it rather than closing the + /// pane, which is what makes two commands over one pane read like two commands over two panes. + @Test("Show Inspector over an open assistant swaps rather than closes") + func inspectorOverAssistantSwaps() { + let context = Self.context(mode: .browse, stored: .assistant, isOpen: true) + #expect(TrailingPaneCommandResolver.paneToggle(context) == .reveal(.inspector)) + #expect(TrailingPaneCommandResolver.assistantToggle(context) == .hide) + } + + @Test("Show Assistant over an open inspector swaps rather than closes") + func assistantOverInspectorSwaps() { + let context = Self.context(mode: .browse, stored: .inspector, isOpen: true) + #expect(TrailingPaneCommandResolver.assistantToggle(context) == .reveal(.assistant)) + #expect(TrailingPaneCommandResolver.paneToggle(context) == .hide) + } + + /// The assistant is the one surface a setting takes away, so its command goes with it. + @Test("With AI off Show Assistant is dimmed") + func aiOffDimsTheAssistant() { + let context = Self.context(mode: .browse, stored: .assistant, isOpen: false, isAIEnabled: false) + #expect(TrailingPaneCommandResolver.assistantToggle(context) == nil) + #expect(TrailingPaneCommandResolver.assistantFocus(context) == nil) + } + + /// Opening needs a session to put in the pane; closing one the user left open does not, or a + /// dropped connection strands an empty column. + @Test("Without content the pane can be closed but not opened") + func withoutContentThePaneOnlyCloses() { + let open = Self.context(mode: .browse, stored: .inspector, isOpen: true, hasContent: false) + let closed = Self.context(mode: .browse, stored: .inspector, isOpen: false, hasContent: false) + #expect(TrailingPaneCommandResolver.canTogglePane(open)) + #expect(!TrailingPaneCommandResolver.canTogglePane(closed)) + + let assistantOpen = Self.context(mode: .browse, stored: .assistant, isOpen: true, hasContent: false) + let assistantClosed = Self.context(mode: .browse, stored: .assistant, isOpen: false, hasContent: false) + #expect(TrailingPaneCommandResolver.assistantToggle(assistantOpen) == .hide) + #expect(TrailingPaneCommandResolver.assistantToggle(assistantClosed) == nil) + } + + /// A dead fourth name for the pane toggle, beside Inspector in the menu, trailing pane in the + /// proxy and right panel in a legacy defaults key. Nothing called it, and a command surface that + /// keeps a name nobody uses is where the next caller picks the wrong one. + @Test("The command surface has no second name for the pane toggle") + func toggleRightSidebarIsGone() throws { + let url = Self.repositoryRoot.appendingPathComponent("TablePro/Views/Main/MainContentCommandActions.swift") + let source = try String(contentsOf: url, encoding: .utf8) + #expect(!source.contains("toggleRightSidebar")) + } + + // MARK: - Helpers + + private static let repositoryRoot: URL = { + var url = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 5 { + url.deleteLastPathComponent() + } + return url + }() + + private static func context( + mode: ConnectionWorkspaceContentMode, + stored: TrailingPaneSurface, + isOpen: Bool, + isAIEnabled: Bool = true, + hasContent: Bool = true + ) -> TrailingPaneCommandResolver.Context { + TrailingPaneCommandResolver.Context( + contentMode: mode, + storedSurface: stored, + isPaneOpen: isOpen, + isAIEnabled: isAIEnabled, + hasContent: hasContent + ) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/TrailingPaneRevealTests.swift b/TableProTests/Core/Services/Infrastructure/TrailingPaneRevealTests.swift new file mode 100644 index 000000000..e8f5dd67f --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/TrailingPaneRevealTests.swift @@ -0,0 +1,364 @@ +// +// TrailingPaneRevealTests.swift +// TableProTests +// +// What asking for a trailing surface does to the pane and to the connection's stored preference. +// A surface the user picks is remembered; one the app offers, or one a mode imposes, is not. Two +// shipped the other way: Show Assistant in Agent mode wrote the assistant into the browse preference +// and changed nothing on screen, and the first grid click after closing a pane left on the assistant +// opened it on the inspector and stored the inspector over the user's choice. +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +@Suite("Trailing pane reveal", .serialized) +@MainActor +struct TrailingPaneRevealTests { + // MARK: - The decision + + @Test("Asking for any surface in Agent mode stores nothing, and only the result opens") + func agentModeStoresNothing() { + for surface in TrailingPaneSurface.allCases { + let decision = TrailingPaneCommandResolver.reveal(surface, Self.context(mode: .agent)) + #expect(!decision.storesChoice, "\(surface)") + #expect(decision.opensPane == (surface == .agentResult), "\(surface)") + } + } + + @Test("Asking for a surface while browsing stores it as the user's choice") + func browsingStoresAChoice() { + let context = Self.context(mode: .browse) + #expect( + TrailingPaneCommandResolver.reveal(.inspector, context) + == TrailingPaneCommandResolver.Reveal(opensPane: true, storesChoice: true) + ) + #expect( + TrailingPaneCommandResolver.reveal(.assistant, context) + == TrailingPaneCommandResolver.Reveal(opensPane: true, storesChoice: true) + ) + #expect( + TrailingPaneCommandResolver.reveal(.agentResult, context) + == TrailingPaneCommandResolver.Reveal(opensPane: false, storesChoice: false) + ) + } + + /// The pane would open on the inspector instead, over a question about the assistant. + @Test("An assistant the settings took away is neither opened nor stored") + func aiOffAssistantIsRefused() { + let context = Self.context(mode: .browse, isAIEnabled: false) + #expect( + TrailingPaneCommandResolver.reveal(.assistant, context) + == TrailingPaneCommandResolver.Reveal(opensPane: false, storesChoice: false) + ) + } + + @Test("A grid click does not open a pane the user left on the assistant") + func gridClickRespectsTheAssistant() { + let context = Self.context(mode: .browse, stored: .assistant, isOpen: false) + #expect(!TrailingPaneCommandResolver.revealsForSelection(context)) + } + + @Test("A grid click opens a closed pane left on the inspector") + func gridClickOpensTheInspector() { + #expect(TrailingPaneCommandResolver.revealsForSelection(Self.context(mode: .browse, isOpen: false))) + #expect(!TrailingPaneCommandResolver.revealsForSelection(Self.context(mode: .browse, isOpen: true))) + } + + /// With the assistant switched off the pane can only draw the inspector, so the click opens it, + /// and the stored assistant is left for when the setting comes back. + @Test("A grid click opens the inspector over a stored assistant the settings took away") + func gridClickWithAIOff() { + let context = Self.context(mode: .browse, stored: .assistant, isOpen: false, isAIEnabled: false) + #expect(TrailingPaneCommandResolver.revealsForSelection(context)) + } + + @Test("A grid click never opens the pane in Agent mode") + func gridClickInAgentMode() { + for stored in TrailingPaneSurface.allCases { + let context = Self.context(mode: .agent, stored: stored, isOpen: false) + #expect(!TrailingPaneCommandResolver.revealsForSelection(context), "\(stored)") + } + } + + // MARK: - The window + + @Test("In Agent mode the pane toggle opens and closes the result column and stores nothing") + func agentModePaneToggleActsOnTheResult() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.paneState.surface = .assistant + harness.selected.contentMode = .agent + + let item = Harness.menuItem(#selector(MainSplitViewController.toggleInspector(_:))) + #expect(harness.controller.validateMenuItem(item)) + #expect(item.title == String(localized: "Show Result")) + + harness.controller.toggleInspector(nil) + #expect(harness.controller.isTrailingPaneOpen) + #expect(harness.controller.inspectorPaneHost.shown === harness.selected.panes.agentResult) + #expect(harness.paneState.surface == .assistant) + _ = harness.controller.validateMenuItem(item) + #expect(item.title == String(localized: "Hide Result")) + + harness.controller.toggleInspector(nil) + #expect(!harness.controller.isTrailingPaneOpen) + #expect(harness.paneState.surface == .assistant) + } + } + + /// Both routes: the View menu's command, and `showAssistant()`, which Explain with AI and Fix with + /// AI reach from the Query menu whatever the mode. + @Test("In Agent mode Show Assistant is dimmed and neither route stores a browse preference") + func agentModeAssistantStoresNothing() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.paneState.surface = .inspector + harness.selected.contentMode = .agent + + let item = Harness.menuItem(#selector(MainSplitViewController.toggleAssistant(_:))) + #expect(!harness.controller.validateMenuItem(item)) + #expect(item.title == String(localized: "Show Assistant")) + + harness.controller.toggleAssistant(nil) + harness.controller.showAssistant() + + #expect(harness.paneState.surface == .inspector) + #expect(!harness.controller.isTrailingPaneOpen) + } + } + + /// Show Assistant is dimmed in Agent mode on the strength of this command reaching the + /// conversation. The welcome window's Open in Agent Mode puts the window in the mode before its + /// connect lands, so the browse content that sets up the command actions never mounts, and a + /// validation that read them dimmed Focus Assistant too, leaving no command to the composer. + @Test("Focus Assistant reaches the composer in a window that opened in Agent mode") + func focusAssistantInAWindowOpenedInAgentMode() throws { + try AIFeatureScope.enabled { + let harness = try Harness(contentMode: .agent) + defer { harness.tearDown() } + try harness.requireContent() + try #require( + harness.controller.commandActions == nil, + "The browse content mounted, so this is not the window the welcome route opens" + ) + /// Stands in for the composer the conversation draws once a session has a provider to + /// answer it, which a unit test has no way to configure. The conversation is a pane of + /// its own, parented in place of the browse content, so that is where it goes. + let composer = ChatComposerNSTextView.make() + try #require(harness.controller.detailPaneHost.shown === harness.selected.panes.agentConversation) + harness.selected.panes.agentConversation.view.addSubview(composer) + + let item = Harness.menuItem(#selector(MainSplitViewController.focusAssistant(_:))) + #expect(harness.controller.validateMenuItem(item)) + + harness.controller.focusAssistant(nil) + #expect(harness.window.firstResponder === composer) + #expect(!harness.controller.isTrailingPaneOpen) + #expect(harness.paneState.surface == .inspector) + } + } + + @Test("A grid click leaves a pane closed on the assistant closed, and the choice stored") + func gridClickKeepsTheAssistantChoice() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.paneState.surface = .assistant + + harness.controller.revealInspectorForSelection() + + #expect(!harness.controller.isTrailingPaneOpen) + #expect(harness.paneState.surface == .assistant) + } + } + + @Test("A grid click opens a closed pane on the inspector") + func gridClickOpensTheInspectorPane() throws { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.paneState.surface = .inspector + + harness.controller.revealInspectorForSelection() + + #expect(harness.controller.isTrailingPaneOpen) + #expect(harness.controller.inspectorPaneHost.shown === harness.selected.panes.inspector) + #expect(harness.paneState.surface == .inspector) + } + + /// The header's picker writes the stored surface and nothing else. Parenting the new surface from + /// inside that write would take the view whose segment was clicked off the window during its own + /// action, so the window follows on the next turn of the run loop. + @Test("A surface picked in the header is parented after the picker's action returns") + func headerChoiceIsParentedOnTheNextTurn() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.showInspector() + #expect(harness.controller.inspectorPaneHost.shown === harness.selected.panes.inspector) + /// A status event still queued from the injected session reparents the pane as well, as + /// any transition does, and would pass this case with no observer behind the picker. + Self.drainRunLoop() + + harness.paneState.surface = .assistant + + #expect( + harness.controller.inspectorPaneHost.shown === harness.selected.panes.inspector, + "The outgoing surface left the window from inside the write" + ) + #expect(Self.turnRunLoop { harness.controller.inspectorPaneHost.shown === harness.selected.panes.assistant }) + #expect(harness.controller.isAssistantVisible) + } + } + + @Test("Visibility follows the surface the pane draws, not the one stored") + func visibilityFollowsTheDrawnSurface() throws { + try AIFeatureScope.enabled { + let harness = try Harness() + defer { harness.tearDown() } + try harness.requireContent() + harness.controller.showAssistant() + #expect(harness.controller.isAssistantVisible) + #expect(!harness.controller.isInspectorVisible) + + harness.selected.contentMode = .agent + + #expect(!harness.controller.isAssistantVisible) + #expect(!harness.controller.isInspectorVisible) + #expect(harness.paneState.surface == .assistant) + } + } + + // MARK: - Helpers + + private static func context( + mode: ConnectionWorkspaceContentMode, + stored: TrailingPaneSurface = .inspector, + isOpen: Bool = false, + isAIEnabled: Bool = true + ) -> TrailingPaneCommandResolver.Context { + TrailingPaneCommandResolver.Context( + contentMode: mode, + storedSurface: stored, + isPaneOpen: isOpen, + isAIEnabled: isAIEnabled, + hasContent: true + ) + } + + /// A bounded count of short turns rather than a wall-clock limit, keeping the main thread rather + /// than yielding it, so a stored-surface change delivered on the main run loop is seen as soon as + /// it lands and a missing one fails instead of hanging. + private static func turnRunLoop(until condition: () -> Bool) -> Bool { + for _ in 0 ..< 200 { + if condition() { return true } + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + } + return condition() + } + + /// Delivers whatever the main run loop already holds, so the next change a case makes is the + /// only thing left for the window to react to. + private static func drainRunLoop() { + for _ in 0 ..< 20 { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + } + } + + /// One connected workspace whose session the window adopts from `DatabaseManager`, the way a + /// real connect lands. A workspace handed a session the manager does not hold is released by the + /// first status reconcile, which the window runs as it appears and again on any status event the + /// run loop delivers. + /// + /// The pane state is the connection's own, built before adoption so the window keeps it rather + /// than building one on the app's defaults, and it writes to a suite of its own. + @MainActor + private struct Harness { + let controller: MainSplitViewController + let selected: ConnectionWorkspace + let paneState: TrailingPaneState + let window: NSWindow + private let connection: DatabaseConnection + private let defaults: UserDefaults + private let suiteName: String + + /// `contentMode` is set before the window is built, which is how the welcome window's Open in + /// Agent Mode lands: the mode is on before the connect, so the browse content never mounts. + init(contentMode: ConnectionWorkspaceContentMode = .browse) throws { + connection = TestFixtures.makeConnection(name: "Trailing", type: .mysql) + suiteName = "TrailingPaneRevealTests.\(UUID().uuidString)" + defaults = try #require(UserDefaults(suiteName: suiteName)) + let registryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("TrailingPaneRevealTests-\(UUID().uuidString)", isDirectory: true) + paneState = TrailingPaneState( + connectionId: connection.id, + defaults: defaults, + sessionRegistry: AgentSessionRegistry(store: AgentSessionStore(directory: registryDirectory)) + ) + selected = ConnectionWorkspace( + connectionId: connection.id, + payload: nil, + autoConnect: false, + payloadConnection: connection, + session: nil, + sessionState: nil, + trailingPaneState: paneState, + phase: .connecting + ) + selected.contentMode = contentMode + controller = MainSplitViewController(payload: nil, sessionState: nil, adopting: selected) + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_200, height: 700), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.orderFront(nil) + + var session = ConnectionSession(connection: connection, driver: MockDatabaseDriver(connection: connection)) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + controller.refreshFromActiveSessions() + closePane() + } + + /// Asked after the caller has registered `tearDown`, so a harness that failed to connect + /// still gives its window and its injected session back. + func requireContent() throws { + try #require(selected.trailingPaneState === paneState, "The window replaced the connection's pane state") + try #require(controller.currentPane == .content, "The connection has no content behind it") + } + + static func menuItem(_ action: Selector) -> NSMenuItem { + NSMenuItem(title: "", action: action, keyEquivalent: "") + } + + /// `NSSplitView`'s autosave record is shared by every case in the target, so the pane is put + /// back to the shipping default, closed, at both ends of each case. + func closePane() { + if controller.isTrailingPaneOpen { controller.hideTrailingPane() } + } + + func tearDown() { + selected.contentMode = .browse + closePane() + window.orderOut(nil) + window.contentViewController = nil + selected.teardown() + DatabaseManager.shared.removeSession(for: connection.id) + defaults.removePersistentDomain(forName: suiteName) + } + } +} diff --git a/TableProTests/Core/Services/Infrastructure/WorkspacePanesFirewallTests.swift b/TableProTests/Core/Services/Infrastructure/WorkspacePanesFirewallTests.swift new file mode 100644 index 000000000..bfb1e4731 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/WorkspacePanesFirewallTests.swift @@ -0,0 +1,125 @@ +// +// WorkspacePanesFirewallTests.swift +// TableProTests +// +// `WorkspacePanes` applies `sizingOptions = []` and tears its panes down by walking one hand-written +// list. A hosting controller stored beside the others but left off that list publishes its +// content's minimum width to the split view, which pins the window's dividers (#1872), and outlives +// its connection, keeping the coordinator it retains answering the app about tabs nobody can see. +// These read the stored controllers off the instance, so the next pane cannot be forgotten. +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +private final class PaneMountRecorder { + var makeCount = 0 + var dismantleCount = 0 +} + +private struct PaneMountProbe: NSViewRepresentable { + let recorder: PaneMountRecorder + + func makeNSView(context: Context) -> NSView { + recorder.makeCount += 1 + return NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) {} + + static func dismantleNSView(_ nsView: NSView, coordinator: PaneMountRecorder) { + coordinator.dismantleCount += 1 + } + + func makeCoordinator() -> PaneMountRecorder { + recorder + } +} + +@Suite("Workspace panes firewall", .serialized) +@MainActor +struct WorkspacePanesFirewallTests { + private static func storedPanes(of panes: WorkspacePanes) -> [(label: String, pane: NSHostingController)] { + Mirror(reflecting: panes).children.compactMap { child in + guard let label = child.label, let pane = child.value as? NSHostingController else { return nil } + return (label, pane) + } + } + + @Test("Agent mode's rail and conversation are panes of their own, beside the browse ones") + func agentPanesAreStoredBesideTheOthers() { + let panes = WorkspacePanes() + let labels = Set(Self.storedPanes(of: panes).map(\.label)) + + #expect(labels.isSuperset(of: ["agentRail", "agentConversation", "agentResult", "sidebar", "detail"])) + #expect(panes.agentRail !== panes.sidebar) + #expect(panes.agentConversation !== panes.detail) + } + + @Test("Every stored pane publishes no size of its own") + func everyStoredPaneCarriesTheFirewall() { + let panes = WorkspacePanes() + let stored = Self.storedPanes(of: panes) + + #expect(stored.count >= 7) + for entry in stored { + #expect(entry.pane.sizingOptions.isEmpty, "\(entry.label) would publish its content's minimum width") + } + } + + /// Mounted in a window first, because a pane nothing ever laid out has nothing to dismantle and + /// would pass having proved nothing. + @Test("Teardown dismantles and unparents every stored pane, Agent mode's included") + func teardownReachesEveryStoredPane() throws { + let panes = WorkspacePanes() + let stored = Self.storedPanes(of: panes) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let container = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + window.contentView = container + defer { window.orderOut(nil) } + + var recorders: [String: PaneMountRecorder] = [:] + for entry in stored { + let recorder = PaneMountRecorder() + recorders[entry.label] = recorder + entry.pane.rootView = AnyView(PaneMountProbe(recorder: recorder)) + entry.pane.view.frame = container.bounds + container.addSubview(entry.pane.view) + } + window.orderFront(nil) + let deadline = Date(timeIntervalSinceNow: 10) + while recorders.values.contains(where: { $0.makeCount == 0 }), Date() < deadline { + container.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + } + for (label, recorder) in recorders { + try #require(recorder.makeCount == 1, "\(label) never mounted, so the case proves nothing about it") + } + + panes.teardown() + + for entry in stored { + #expect(recorders[entry.label]?.dismantleCount == 1, "\(entry.label) kept its content") + #expect(entry.pane.view.superview == nil, "\(entry.label) stayed in the window") + #expect(entry.pane.parent == nil, "\(entry.label) stayed a child") + } + } + + @Test("Each column draws its browse pane while browsing and its agent pane in Agent mode") + func columnPaneFollowsTheMode() { + let panes = WorkspacePanes() + + #expect(panes.sidebarPane(for: .browse) === panes.sidebar) + #expect(panes.sidebarPane(for: .agent) === panes.agentRail) + #expect(panes.detailPane(for: .browse) === panes.detail) + #expect(panes.detailPane(for: .agent) === panes.agentConversation) + } +} diff --git a/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift b/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift index d7b019812..ce5aaa73b 100644 --- a/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift +++ b/TableProTests/Core/Storage/ConnectionLocalStatePurgeTests.swift @@ -215,6 +215,50 @@ struct ConnectionLocalStatePurgeTests { #expect(!(await storage.planSnapshots(matching: keptIdentity, excluding: nil, limit: 10)).isEmpty) } + @Test("Purging a connection takes its trailing pane preferences with it") + @MainActor + func purgeClearsTrailingPaneKeys() throws { + let suite = "tablepro-purge-pane-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let deleted = UUID() + + defaults.set(TrailingPaneSurface.assistant.rawValue, forKey: TrailingPaneState.surfaceKey(deleted)) + defaults.set(InspectorViewMode.json.rawValue, forKey: RowInspectorState.viewModeKey(deleted)) + + ConnectionLocalState.purgeTrailingPaneKeys([deleted], defaults: defaults) + + #expect(defaults.string(forKey: TrailingPaneState.surfaceKey(deleted)) == nil) + #expect(defaults.string(forKey: RowInspectorState.viewModeKey(deleted)) == nil) + } + + /// A connection deleted on one device must not take another connection's pane with it, and the + /// keys are per connection precisely so it cannot. + @Test("Purging one connection leaves another connection's trailing pane alone") + @MainActor + func purgeLeavesOtherTrailingPanesAlone() throws { + let suite = "tablepro-purge-pane-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let deleted = UUID() + let kept = UUID() + + defaults.set(TrailingPaneSurface.assistant.rawValue, forKey: TrailingPaneState.surfaceKey(deleted)) + defaults.set(TrailingPaneSurface.assistant.rawValue, forKey: TrailingPaneState.surfaceKey(kept)) + defaults.set(InspectorViewMode.json.rawValue, forKey: RowInspectorState.viewModeKey(kept)) + + ConnectionLocalState.purgeTrailingPaneKeys([deleted], defaults: defaults) + + #expect(defaults.string(forKey: TrailingPaneState.surfaceKey(deleted)) == nil) + #expect( + defaults.string(forKey: TrailingPaneState.surfaceKey(kept)) + == TrailingPaneSurface.assistant.rawValue + ) + #expect( + defaults.string(forKey: RowInspectorState.viewModeKey(kept)) == InspectorViewMode.json.rawValue + ) + } + /// `ConnectionLocalState` exists because this list used to be written out at each delete site, /// and they drifted: the query history clear reached the two local sites and never the remote /// one. Anything reaching these stores itself is that drift starting again. diff --git a/TableProTests/Core/Transport/TransportRateLabelTests.swift b/TableProTests/Core/Transport/TransportRateLabelTests.swift deleted file mode 100644 index 9cd21387e..000000000 --- a/TableProTests/Core/Transport/TransportRateLabelTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// TransportRateLabelTests.swift -// TableProTests -// - -import AppKit -import Foundation -@testable import TablePro -import Testing - -@Suite("TransportRateLabel") -struct TransportRateLabelTests { - @Test("An idle transport reads zero rather than going blank") - func idleReadsZero() { - let text = TransportRateLabel.text(for: .zero) - - #expect(text.contains("0")) - #expect(text.hasSuffix("kB/s")) - } - - @Test("No reading yet reads the same as idle") - func absentRateReadsAsIdle() { - #expect(TransportRateLabel.text(for: nil) == TransportRateLabel.text(for: .zero)) - } - - /// `.byteCount` spells the smallest unit out in full: measured, `allowedUnits: [.kb]` still - /// returns "512 bytes" for 512 and "Zero kB" for 0. Both are four characters wider than the - /// figures around them, which is why the label is assembled rather than formatted. - @Test("A rate below a kilobyte never spells out bytes") - func subKilobyteNeverSpellsBytes() { - let text = TransportRateLabel.text(for: TransportRate(receivedPerSecond: 512, sentPerSecond: 0)) - - #expect(!text.lowercased().contains("byte")) - #expect(text.hasSuffix("kB/s")) - } - - @Test("Units step up at a thousand") - func unitsStepUp() { - let kilo = TransportRateLabel.text(for: TransportRate(receivedPerSecond: 145_408, sentPerSecond: 0)) - let mega = TransportRateLabel.text(for: TransportRate(receivedPerSecond: 4_700_000, sentPerSecond: 0)) - let giga = TransportRateLabel.text(for: TransportRate(receivedPerSecond: 2_400_000_000, sentPerSecond: 0)) - - #expect(kilo.hasSuffix("kB/s")) - #expect(mega.hasSuffix("MB/s")) - #expect(giga.hasSuffix("GB/s")) - } - - @Test("The busier direction picks the arrow") - func arrowFollowsTheBusierDirection() { - let down = TransportRateLabel.text(for: TransportRate(receivedPerSecond: 145_408, sentPerSecond: 12)) - let up = TransportRateLabel.text(for: TransportRate(receivedPerSecond: 12, sentPerSecond: 145_408)) - - #expect(down.hasPrefix("\u{2193}")) - #expect(up.hasPrefix("\u{2191}")) - } - - @Test("An arrow is never what VoiceOver is given to read") - func accessibilityValueNamesTheDirection() { - let down = TransportRateLabel.accessibilityValue(for: TransportRate(receivedPerSecond: 145_408, sentPerSecond: 0)) - let up = TransportRateLabel.accessibilityValue(for: TransportRate(receivedPerSecond: 0, sentPerSecond: 145_408)) - - #expect(!down.contains("\u{2193}")) - #expect(!up.contains("\u{2191}")) - #expect(down != up) - } - - /// The field measures itself against these once, so a format that can produce something wider - /// than all of them would clip its own text. - @Test("No rate renders wider than the widest candidate the field is sized from") - func noRateOutgrowsTheField() { - let font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) - let budget = TransportRateLabel.widestCandidates - .map { ($0 as NSString).size(withAttributes: [.font: font]).width } - .max() ?? 0 - - let rates: [Double] = [0, 1, 512, 1_000, 1_500, 9_900, 64_000, 145_408, 512_000, 999_000, - 1_200_000, 4_700_000, 12_500_000, 88_000_000, 999_000_000, 2_400_000_000] - - for value in rates { - let text = TransportRateLabel.text(for: TransportRate(receivedPerSecond: value, sentPerSecond: 0)) - let width = (text as NSString).size(withAttributes: [.font: font]).width - #expect(width <= budget, "\(value) B/s rendered \"\(text)\" at \(width)pt, past the \(budget)pt budget") - } - } - - /// Monospaced digits are what make the drawn text hold still inside a fixed field: the figure - /// changes, the glyph advances do not. - @Test("Every figure of the same shape draws to the same width") - func figuresOfOneShapeDrawAlike() { - let font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) - let threeDigitKilobytes: [Double] = [111_000, 145_408, 512_000, 999_000] - - let widths = threeDigitKilobytes.map { value -> CGFloat in - let text = TransportRateLabel.text(for: TransportRate(receivedPerSecond: value, sentPerSecond: 0)) - return (text as NSString).size(withAttributes: [.font: font]).width - } - - let widest = widths.max() ?? 0 - let narrowest = widths.min() ?? 0 - #expect(widest - narrowest < 0.01, "Monospaced digits must not vary: spread was \(widest - narrowest)pt") - } -} diff --git a/TableProTests/Helpers/AIFeatureScope.swift b/TableProTests/Helpers/AIFeatureScope.swift new file mode 100644 index 000000000..217e4ec3d --- /dev/null +++ b/TableProTests/Helpers/AIFeatureScope.swift @@ -0,0 +1,38 @@ +// +// AIFeatureScope.swift +// TableProTests +// +// The assistant, Agent mode and every surface that depends on them are drawn only with the AI +// feature on, which is the setting's default. A case that needs it runs inside this scope. +// + +import Foundation +@testable import TablePro + +@MainActor +enum AIFeatureScope { + /// Turns the feature on for the body and puts the setting back after. A machine where it is + /// already on, which is every fresh runner, is never written to: the setting is persisted and + /// synced, so a case that wrote it unconditionally would touch the developer's own preferences. + static func enabled(_ body: () throws -> T) rethrows -> T { + let previous = AppSettingsManager.shared.ai + guard !previous.enabled else { return try body() } + var enabled = previous + enabled.enabled = true + AppSettingsManager.shared.ai = enabled + defer { AppSettingsManager.shared.ai = previous } + return try body() + } + + /// The same scope for a case that has to suspend, which is how it lets another main-actor task + /// run: a synchronous spin of the run loop from inside the case does not. + static func enabled(_ body: () async throws -> T) async rethrows -> T { + let previous = AppSettingsManager.shared.ai + guard !previous.enabled else { return try await body() } + var enabled = previous + enabled.enabled = true + AppSettingsManager.shared.ai = enabled + defer { AppSettingsManager.shared.ai = previous } + return try await body() + } +} diff --git a/TableProTests/Models/AI/AgentArtifactCacheTests.swift b/TableProTests/Models/AI/AgentArtifactCacheTests.swift new file mode 100644 index 000000000..cdde9f3d4 --- /dev/null +++ b/TableProTests/Models/AI/AgentArtifactCacheTests.swift @@ -0,0 +1,200 @@ +// +// AgentArtifactCacheTests.swift +// TableProTests +// +// The result column projected the whole transcript and decoded the selected run's JSON from +// computed properties read in `body`, which `AgentResultDecoder`'s own header forbids: a reply +// streaming into the turn redraws the pane again and again, and each redraw did both once more. +// These cases pin what a rebuild costs and what it is keyed on. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("AgentArtifactCache") +@MainActor +struct AgentArtifactCacheTests { + /// Counting in a class rather than a captured `var`, because a `@MainActor` closure is `Sendable` + /// and cannot hold one. + @MainActor + private final class Tally { + var projections = 0 + var decodes = 0 + } + + private func makeCache(_ tally: Tally) -> AgentArtifactCache { + AgentArtifactCache( + project: { turns in + tally.projections += 1 + return AgentArtifactProjection.build(from: turns) + }, + decode: { json in + tally.decodes += 1 + return AgentResultDecoder.payload(fromResultJSON: json) + } + ) + } + + private func makeSession(id: UUID = UUID()) -> AgentSession { + AgentSession( + id: id, + connectionId: UUID(), + viewModel: AIChatViewModel(services: .live, sessionId: id, restoringConversation: nil) + ) + } + + private func toolUse(id: String, query: String, approval: ToolApprovalState = .approved) -> ChatContentBlock { + .toolUse(ToolUseBlock( + id: id, + name: "execute_query", + input: .object(["query": .string(query)]), + approvalState: approval + )) + } + + private func toolResult(id: String, content: String) -> ChatContentBlock { + .toolResult(ToolResultBlock(toolUseId: id, content: content, isError: false)) + } + + @Test("Text streaming into the open turn does not project the transcript again") + func streamingDoesNotReproject() { + let tally = Tally() + let cache = makeCache(tally) + let session = makeSession() + let reply = ChatTurn(role: .assistant, blocks: []) + session.viewModel.messages = [ChatTurn(role: .user, blocks: [.text("Which orders shipped late?")]), reply] + + _ = cache.artifact(for: session) + for chunk in ["Look", "ing at", " orders"] { + reply.appendStreamingToken(chunk) + _ = cache.artifact(for: session) + } + + #expect(tally.projections == 1, "A streaming reply rebuilt the projection \(tally.projections) times") + } + + /// A call waiting for an answer lands in the turn that is still open, so a key of finished turns + /// would keep it off the column until some later turn arrived. + @Test("A call proposed inside the open turn projects once") + func aProposedCallRebuildsOnce() { + let tally = Tally() + let cache = makeCache(tally) + let session = makeSession() + let reply = ChatTurn(role: .assistant, blocks: []) + session.viewModel.messages = [reply] + _ = cache.artifact(for: session) + + reply.appendBlock(toolUse(id: "call_0", query: "SELECT 1", approval: .pending)) + + #expect(cache.artifact(for: session).statements.map(\.sql) == ["SELECT 1"]) + #expect(cache.artifact(for: session).statements.count == 1) + #expect(tally.projections == 2) + } + + /// Answering the call changes the statement's state, and the state is in the key. + @Test("Approving a call projects again") + func approvalRebuilds() throws { + let tally = Tally() + let cache = makeCache(tally) + let session = makeSession() + let block = toolUse(id: "call_0", query: "DELETE FROM t", approval: .pending) + session.viewModel.messages = [ChatTurn(role: .assistant, blocks: [block])] + #expect(cache.artifact(for: session).statements.first?.state == .waiting) + + guard case .toolUse(var use) = block.kind else { + Issue.record("The block stopped being a call") + return + } + use.approvalState = .cancelled + block.setKind(.toolUse(use)) + + #expect(cache.artifact(for: session).statements.first?.state == .rejected) + #expect(tally.projections == 2) + } + + /// Copilot records a result in the turn that made the call, so the turn count never moves for it. + @Test("A result in the calling turn projects again") + func resultInTheSameTurnRebuilds() { + let tally = Tally() + let cache = makeCache(tally) + let session = makeSession() + let reply = ChatTurn(role: .assistant, blocks: [toolUse(id: "call_0", query: "SELECT 1")]) + session.viewModel.messages = [reply] + #expect(cache.artifact(for: session).runs.isEmpty) + + reply.appendBlock(toolResult(id: "call_0", content: #"{"columns":["n"],"rows":[[1]]}"#)) + + #expect(cache.artifact(for: session).runs.count == 1) + #expect(tally.projections == 2) + } + + /// Two conversations can agree on every provider call id, so the key names blocks by their own. + @Test("A different session projects again and keeps nothing of the first") + func switchingSessionRebuilds() { + let tally = Tally() + let cache = makeCache(tally) + let first = makeSession() + let second = makeSession() + let payload = #"{"columns":["n"],"rows":[[1]]}"# + first.viewModel.messages = [ + ChatTurn(role: .assistant, blocks: [toolUse(id: "call_0", query: "SELECT 1")]), + ChatTurn(role: .user, blocks: [toolResult(id: "call_0", content: payload)]), + ] + second.viewModel.messages = [ + ChatTurn(role: .assistant, blocks: [toolUse(id: "call_0", query: "SELECT 2")]), + ChatTurn(role: .user, blocks: [toolResult(id: "call_0", content: payload)]), + ] + + let firstRuns = cache.artifact(for: first).runs + let secondRuns = cache.artifact(for: second).runs + + #expect(firstRuns.map(\.sql) == ["SELECT 1"]) + #expect(secondRuns.map(\.sql) == ["SELECT 2"]) + #expect(tally.projections == 2) + } + + @Test("A run is decoded once however often the column draws it") + func decodesEachRunOnce() throws { + let tally = Tally() + let cache = makeCache(tally) + let session = makeSession() + session.viewModel.messages = [ + ChatTurn(role: .assistant, blocks: [toolUse(id: "call_0", query: "SELECT 1")]), + ChatTurn(role: .user, blocks: [toolResult(id: "call_0", content: #"{"columns":["n"],"rows":[[1]]}"#)]), + ] + let run = try #require(cache.artifact(for: session).runs.first) + + for _ in 0 ..< 5 { + guard case .rows(let rows) = cache.payload(for: run) else { + Issue.record("The run decoded to something other than rows") + return + } + #expect(rows.count == 1) + } + + #expect(tally.decodes == 1) + } + + /// The transcript is the only copy of a result, so a decode is worth keeping only while the run + /// it belongs to is still in the projection. + @Test("A run that leaves the transcript takes its decoded result with it") + func prunesDecodedRunsThatAreGone() throws { + let tally = Tally() + let cache = makeCache(tally) + let session = makeSession() + let payload = #"{"columns":["n"],"rows":[[1]]}"# + session.viewModel.messages = [ + ChatTurn(role: .assistant, blocks: [toolUse(id: "call_0", query: "SELECT 1")]), + ChatTurn(role: .user, blocks: [toolResult(id: "call_0", content: payload)]), + ] + let run = try #require(cache.artifact(for: session).runs.first) + _ = cache.payload(for: run) + + session.viewModel.messages = [] + _ = cache.artifact(for: session) + _ = cache.payload(for: run) + + #expect(tally.decodes == 2) + } +} diff --git a/TableProTests/Models/AI/AgentResultDecoderTests.swift b/TableProTests/Models/AI/AgentResultDecoderTests.swift new file mode 100644 index 000000000..63ce707d3 --- /dev/null +++ b/TableProTests/Models/AI/AgentResultDecoderTests.swift @@ -0,0 +1,80 @@ +// +// AgentResultDecoderTests.swift +// TableProTests +// +// The decoder used to answer with an optional, and the pane said "This query returned no rows." for +// every nil: an approved UPDATE, a reply it could not read, and a query that really did match +// nothing. Each of those is a different thing to tell someone. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("AgentResultDecoder") +struct AgentResultDecoderTests { + private func payload(_ json: String) -> AgentResultPayload { + AgentResultDecoder.payload(fromResultJSON: json) + } + + @Test("A result set with rows decodes to rows") + func rowsDecode() throws { + let decoded = payload(#"{"columns":["id","name"],"rows":[[1,"Ada"],[2,null]],"row_count":2,"rows_affected":0}"#) + + guard case .rows(let rows) = decoded else { + Issue.record("Expected rows, got \(decoded)") + return + } + #expect(rows.columns == ["id", "name"]) + #expect(rows.count == 2) + #expect(rows.value(at: 0, column: 1) == .text("Ada")) + #expect(rows.value(at: 1, column: 1) == .null) + } + + /// The columns are what separate the two: a query that matched nothing still names them. + @Test("A named result set with no rows is an empty query, not a write") + func emptyResultSet() { + guard case .noRows = payload(#"{"columns":["id"],"rows":[],"row_count":0,"rows_affected":0}"#) else { + Issue.record("A result set with columns and no rows is a query that returned no rows") + return + } + } + + @Test("A statement with no result set carries the rows it changed") + func writeCarriesItsCount() { + guard case .completed(let rowsAffected) = payload(#"{"columns":[],"rows":[],"row_count":0,"rows_affected":3}"#) + else { + Issue.record("A reply with no columns is a statement that returned no result set") + return + } + #expect(rowsAffected == 3) + } + + @Test("A statement that reports no count still reads as completed") + func writeWithoutACount() { + guard case .completed(let rowsAffected) = payload(#"{"columns":[],"rows":[]}"#) else { + Issue.record("A reply with no columns is a statement that returned no result set") + return + } + #expect(rowsAffected == nil) + } + + @Test( + "A reply the grid cannot read says so", + arguments: [ + "not json at all", + "[1, 2, 3]", + #"{"rows":[[1]]}"#, + #"{"columns":["id"]}"#, + #"{"columns":[],"rows":[[1]]}"#, + #"{"columns":["id"],"rows":[1]}"#, + ] + ) + func unreadableReplies(_ json: String) { + guard case .unreadable = payload(json) else { + Issue.record("Expected \(json) to be unreadable") + return + } + } +} diff --git a/TableProTests/Models/AI/AgentSessionConfirmationTests.swift b/TableProTests/Models/AI/AgentSessionConfirmationTests.swift new file mode 100644 index 000000000..63d84beaf --- /dev/null +++ b/TableProTests/Models/AI/AgentSessionConfirmationTests.swift @@ -0,0 +1,59 @@ +// +// AgentSessionConfirmationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Agent session confirmation") +struct AgentSessionConfirmationTests { + /// Closing keeps the conversation, so an idle session is closed without a question. A busy one + /// loses the reply or the statement it is holding, which is the part worth asking about. + @Test("Closing asks about a busy session and about no other") + func closingAsksOnlyWhenBusy() { + for status in AgentSessionStatus.allCases { + let confirmation = AgentSessionConfirmation.close("Late orders", status: status) + #expect((confirmation != nil) == status.isBusy, "\(status)") + } + } + + @Test("A busy session's question names what it is doing") + func closingNamesTheActivity() throws { + let working = try #require(AgentSessionConfirmation.close("Late orders", status: .working)) + let waiting = try #require(AgentSessionConfirmation.close("Late orders", status: .waitingOnYou)) + + #expect(working.title.contains("Late orders")) + #expect(working.message != waiting.message) + #expect(!working.isDestructive, "Closing keeps the conversation, so it is not the destructive shape") + #expect(waiting.confirmButton == working.confirmButton, "One command, one button") + } + + @Test("Deleting always asks, and says the conversation goes with it") + func deletingAlwaysAsks() { + let statuses = AgentSessionStatus.allCases + let messages = Set(statuses.map { AgentSessionConfirmation.delete("Late orders", status: $0).message }) + + for status in statuses { + let confirmation = AgentSessionConfirmation.delete("Late orders", status: status) + #expect(confirmation.title.contains("Late orders"), "\(status)") + #expect(confirmation.isDestructive, "\(status)") + #expect(!confirmation.message.isEmpty, "\(status)") + } + #expect(messages.count == 3, "Working, waiting on you and idle are three different questions") + } + + /// The busy wording says the reply or the statement is stopped, which is the only warning a + /// person gets before a session mid-reply is deleted. + @Test("Deleting a busy session says what it stops") + func deletingNamesWhatItStops() { + let working = AgentSessionConfirmation.delete("Late orders", status: .working) + let waiting = AgentSessionConfirmation.delete("Late orders", status: .waitingOnYou) + let ready = AgentSessionConfirmation.delete("Late orders", status: .ready) + + #expect(working.message != ready.message) + #expect(waiting.message != ready.message) + #expect(working.message != waiting.message) + } +} diff --git a/TableProTests/Models/PendingChangeKindTests.swift b/TableProTests/Models/PendingChangeKindTests.swift new file mode 100644 index 000000000..1da484b3f --- /dev/null +++ b/TableProTests/Models/PendingChangeKindTests.swift @@ -0,0 +1,170 @@ +// +// PendingChangeKindTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Pending change kind") +struct PendingChangeKindTests { + private static let contentKinds: [TabType] = [ + .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, + ] + + @Test("Nothing staged leaves no kind", arguments: contentKinds + [.createTable, .usersRoles]) + func nothingStaged(tabType: TabType) { + #expect( + PendingChangeKind.resolve( + tabType: tabType, + hasDataChanges: false, + hasStructureChanges: false, + hasCreateTablePending: false, + hasPrincipalChanges: false, + isFileDirty: false + ) == nil + ) + } + + @Test("Each source names its own kind", arguments: contentKinds) + func eachSource(tabType: TabType) { + let data = PendingChangeKind.resolve( + tabType: tabType, + hasDataChanges: true, + hasStructureChanges: false, + hasCreateTablePending: false, + hasPrincipalChanges: false, + isFileDirty: false + ) + let structure = PendingChangeKind.resolve( + tabType: tabType, + hasDataChanges: false, + hasStructureChanges: true, + hasCreateTablePending: false, + hasPrincipalChanges: false, + isFileDirty: false + ) + let file = PendingChangeKind.resolve( + tabType: tabType, + hasDataChanges: false, + hasStructureChanges: false, + hasCreateTablePending: false, + hasPrincipalChanges: false, + isFileDirty: true + ) + + #expect(data == .data) + #expect(structure == .structure) + #expect(file == .file) + } + + /// The defect this type exists for: the commit control was dim on a Users & Roles tab with + /// staged principals, because the one function that fed it never read that flag. + @Test("Staged principals are a pending change on a Users & Roles tab") + func principalsAreAPendingChange() { + #expect( + PendingChangeKind.resolve( + tabType: .usersRoles, + hasDataChanges: false, + hasStructureChanges: false, + hasCreateTablePending: false, + hasPrincipalChanges: true, + isFileDirty: false + ) == .principals + ) + } + + @Test("A committable definition is a pending change on a Create Table tab") + func createTableIsAPendingChange() { + #expect( + PendingChangeKind.resolve( + tabType: .createTable, + hasDataChanges: true, + hasStructureChanges: true, + hasCreateTablePending: true, + hasPrincipalChanges: true, + isFileDirty: true + ) == .createTable + ) + } + + /// A Create Table tab whose definition is not yet valid stages nothing, whatever else is true + /// elsewhere in the window. + @Test("An incomplete definition stages nothing, whatever else is set") + func incompleteDefinitionStagesNothing() { + #expect( + PendingChangeKind.resolve( + tabType: .createTable, + hasDataChanges: true, + hasStructureChanges: true, + hasCreateTablePending: false, + hasPrincipalChanges: true, + isFileDirty: true + ) == nil + ) + } + + @Test("Structure outranks data, and data outranks a dirty file", arguments: contentKinds) + func precedence(tabType: TabType) { + let structureOverData = PendingChangeKind.resolve( + tabType: tabType, + hasDataChanges: true, + hasStructureChanges: true, + hasCreateTablePending: false, + hasPrincipalChanges: false, + isFileDirty: true + ) + let dataOverFile = PendingChangeKind.resolve( + tabType: tabType, + hasDataChanges: true, + hasStructureChanges: false, + hasCreateTablePending: false, + hasPrincipalChanges: false, + isFileDirty: true + ) + + #expect(structureOverData == .structure) + #expect(dataOverFile == .data) + } + + /// Principals belong to their own tab. A stale flag left by a tab the user closed must not + /// light the commit control somewhere else. + @Test("Staged principals stage nothing outside a Users & Roles tab", arguments: contentKinds) + func principalsAreTabScoped(tabType: TabType) { + #expect( + PendingChangeKind.resolve( + tabType: tabType, + hasDataChanges: false, + hasStructureChanges: false, + hasCreateTablePending: false, + hasPrincipalChanges: true, + isFileDirty: false + ) == nil + ) + } + + @Test("A window with no selected tab still answers for its content sources") + func noSelectedTab() { + #expect( + PendingChangeKind.resolve( + tabType: nil, + hasDataChanges: true, + hasStructureChanges: false, + hasCreateTablePending: false, + hasPrincipalChanges: false, + isFileDirty: false + ) == .data + ) + #expect( + PendingChangeKind.resolve( + tabType: nil, + hasDataChanges: false, + hasStructureChanges: false, + hasCreateTablePending: false, + hasPrincipalChanges: false, + isFileDirty: false + ) == nil + ) + } +} diff --git a/TableProTests/Models/SafeModeFloorTests.swift b/TableProTests/Models/SafeModeFloorTests.swift index 494550035..f948506e2 100644 --- a/TableProTests/Models/SafeModeFloorTests.swift +++ b/TableProTests/Models/SafeModeFloorTests.swift @@ -64,6 +64,22 @@ struct SafeModeFloorTests { #expect(floor.explanation.contains(SafeModeLevel.safeModeFull.displayName)) } + /// The agent conversation's context strip has one line for all of this, so it carries the short + /// form beside the level's symbol and keeps the sentence for its tooltip. Each reason answers for + /// itself, or the strip would say the same thing whatever is holding the connection. + @Test("Every reason has a short form of its own, and it is shorter than the sentence") + func everyReasonSummarisesItself() { + let reasons: [SafeModeFloor.Reason] = [.readOnlyEngine, .remoteDatabaseFile, .managedPolicy, .agentMode] + let summaries = reasons.map { SafeModeFloor(level: .alert, reason: $0).summary } + + #expect(Set(summaries).count == reasons.count) + for (reason, summary) in zip(reasons, summaries) { + let floor = SafeModeFloor(level: .alert, reason: reason) + #expect(!summary.isEmpty, "\(reason)") + #expect(summary.count < floor.explanation.count, "\(reason)") + } + } + @Test("A read-only engine reads as Read-Only and keeps the user's own level", arguments: [ DatabaseType.cloudflareR2SQL, DatabaseType.beancount ]) @@ -249,4 +265,118 @@ struct SafeModeFloorTests { isAgentModeActive: false ) == nil) } + + // MARK: - What the Safe Mode list offers and takes + + private static let agentFloor = SafeModeFloor(level: .alert, reason: .agentMode) + + /// The defect this closes: a connection set to Silent is held at Alert in Agent mode, and picking + /// Silent from the list was stored as the user's level while the session stayed at Alert. The + /// pick changed nothing on screen and came back as their level once the mode ended. + @Test("Under Agent mode's floor, Silent is neither offered nor taken") + func agentFloorRefusesALevelBelowIt() { + let status = SafeModeStatus(level: .alert, floor: Self.agentFloor) + + #expect(!status.offeredLevels.contains(.silent)) + #expect(!status.offers(.silent)) + #expect(!status.accepts(.silent)) + } + + /// Under a floor the level in force can be the floor's rather than the user's. Writing it would + /// replace the level they chose, with nothing on screen moving. + @Test("The level in force is offered, and choosing it again is not taken") + func levelInForceIsNotTakenAgain() { + let status = SafeModeStatus(level: .alert, floor: Self.agentFloor) + + #expect(status.offers(.alert)) + #expect(!status.accepts(.alert)) + } + + @Test("A stricter level than the one in force is taken under Agent mode's floor") + func stricterLevelIsTaken() { + let status = SafeModeStatus(level: .alert, floor: Self.agentFloor) + + for level in [SafeModeLevel.alertFull, .safeMode, .safeModeFull, .readOnly] { + #expect(status.accepts(level), "\(level)") + } + } + + /// The rule the list and the write share: whatever is accepted is a level the floor lets stand, + /// so storing it moves the level on screen to exactly what was picked. + @Test("Every choice that is taken moves the level in force to what was picked") + func everyTakenChoiceMovesTheLevel() { + let floors: [SafeModeFloor?] = [ + nil, + Self.agentFloor, + SafeModeFloor(level: .safeMode, reason: .managedPolicy), + SafeModeFloor(level: .readOnly, reason: .readOnlyEngine), + ] + for floor in floors { + for preferred in SafeModeLevel.allCases { + let inForce = floor?.raising(preferred) ?? preferred + let status = SafeModeStatus(level: inForce, floor: floor) + for candidate in SafeModeLevel.allCases where status.accepts(candidate) { + let after = floor?.raising(candidate) ?? candidate + #expect(after == candidate, "\(String(describing: floor)) \(preferred) -> \(candidate)") + #expect(after != inForce, "\(String(describing: floor)) \(preferred) -> \(candidate)") + } + } + } + } + + @Test("With no floor every level is offered") + func noFloorOffersEveryLevel() { + let status = SafeModeStatus(level: .silent, floor: nil) + + #expect(status.offeredLevels == SafeModeLevel.allCases) + #expect(status.accepts(.readOnly)) + #expect(!status.accepts(.silent)) + } + + @Test("The offered levels are the ones the floor allows", arguments: [ + SafeModeFloor(level: .alert, reason: .agentMode), + SafeModeFloor(level: .alertFull, reason: .managedPolicy), + SafeModeFloor(level: .readOnly, reason: .remoteDatabaseFile), + ]) + func offeredLevelsFollowTheFloor(floor: SafeModeFloor) { + let status = SafeModeStatus(level: floor.level, floor: floor) + #expect(status.offeredLevels == SafeModeFloor.levels(allowedBy: floor)) + } + + @Test("The toolbar tooltip names the level, and the floor's reason when one holds it") + func toolTipCarriesTheReason() { + let free = SafeModeStatus(level: .alertFull, floor: nil) + let held = SafeModeStatus(level: .alert, floor: Self.agentFloor) + + #expect(free.toolTip == String(format: String(localized: "Safe Mode: %@"), SafeModeLevel.alertFull.displayName)) + #expect(held.toolTip.hasPrefix(String(format: String(localized: "Safe Mode: %@"), SafeModeLevel.alert.displayName))) + #expect(held.toolTip.hasSuffix(Self.agentFloor.explanation)) + } + + /// Nothing is in Agent mode unless a window shows it so, which no window in a unit test does, so + /// the status is the connection's own: its floor, and its own level raised to it. + @Test("A connection no window shows in Agent mode is judged against its own floor") + func statusWithoutAgentModeIsTheConnectionsOwn() { + let engine = DatabaseConnection(name: "R2", type: .cloudflareR2SQL, safeModeLevel: .alert) + let plain = DatabaseConnection(name: "PG", type: .postgresql, safeModeLevel: .alert) + + #expect(AgentModeSafeModeFloor.status(for: engine) == SafeModeStatus( + level: .readOnly, + floor: SafeModeFloor(level: .readOnly, reason: .readOnlyEngine) + )) + #expect(AgentModeSafeModeFloor.status(for: plain) == SafeModeStatus(level: .alert, floor: nil)) + } + + @Test("Picking the level already in force on an ordinary connection writes nothing") + func chooseCurrentLevelOnOrdinaryConnectionWritesNothing() { + let connection = DatabaseConnection(name: "PG", type: .postgresql, safeModeLevel: .alert) + DatabaseManager.shared.injectSession(ConnectionSession(connection: connection), for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let versionBefore = DatabaseManager.shared.connectionStatusVersions[connection.id] + + DatabaseManager.shared.chooseSafeModeLevel(.alert, for: connection.id) + + #expect(DatabaseManager.shared.connectionStatusVersions[connection.id] == versionBefore) + #expect(DatabaseManager.shared.session(for: connection.id)?.connection.preferredSafeModeLevel == .alert) + } } diff --git a/TableProTests/Models/ShortcutUniquenessTests.swift b/TableProTests/Models/ShortcutUniquenessTests.swift index b0f7d88b4..94c49aa5f 100644 --- a/TableProTests/Models/ShortcutUniquenessTests.swift +++ b/TableProTests/Models/ShortcutUniquenessTests.swift @@ -49,4 +49,38 @@ struct ShortcutUniquenessTests { #expect(ShortcutAction.allCases.contains(action)) } } + + /// Fourteen actions ship with nothing bound: the six that always did, and the eight the connection + /// window's revamp made rebindable for the first time. Counted rather than listed, because the + /// number is the claim: adding a default to one of them is a decision about a combo that is + /// already taken, and it has to be made on purpose. + @Test("Fourteen actions ship unbound") + func unboundActionsAreCounted() { + let unbound = ShortcutAction.allCases.filter { KeyboardSettings.defaultShortcuts[$0] == nil } + #expect(unbound.count == 14, "Unbound: \(unbound.map(\.rawValue).sorted())") + for action in unbound { + #expect(KeyboardSettings.default.shortcut(for: action) == nil, "\(action.rawValue)") + } + } + + /// The eight are new rows in Settings, so each needs a category to be listed under and a name to + /// be listed by. Both switches are exhaustive, so the compiler already forces an arm; what this + /// holds is that the arm is not an empty string nobody would recognise. + @Test("Each newly rebindable command is listed under a category with a name", arguments: [ + ShortcutAction.showTablesList, .showFavoritesList, .restorePreviousValues, + .newAgentSession, .openAgentSession, .closeAgentSession, .deleteAgentSession, .newAIConversation, + ]) + func newlyRebindableCommandsAreListable(action: ShortcutAction) { + #expect(!action.displayName.isEmpty) + #expect(ShortcutCategory.allCases.contains(action.category)) + } + + /// It reverses rows the grid is showing, so it belongs in the grid's context the way Add Row and + /// Delete do. Left to the `context` switch's `default:` it would be `.global`, and the recorder + /// would then call a grid combo free for it. + @Test("Restore Previous Values is a data-grid command") + func restorePreviousValuesIsAGridCommand() { + #expect(ShortcutAction.restorePreviousValues.context == .dataGrid) + #expect(ShortcutAction.restorePreviousValues.category == .dataGrid) + } } diff --git a/TableProTests/Models/TrailingPaneHeaderModelTests.swift b/TableProTests/Models/TrailingPaneHeaderModelTests.swift new file mode 100644 index 000000000..4c923e128 --- /dev/null +++ b/TableProTests/Models/TrailingPaneHeaderModelTests.swift @@ -0,0 +1,213 @@ +// +// TrailingPaneHeaderModelTests.swift +// TableProTests +// +// The pane's three surfaces drew three different headers: a title over a subtitle beside a picker, +// a headline beside two buttons, and an icon-only picker as the whole top edge. They draw one now, +// from one value, and these pin what that value says for each surface in each mode. +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@Suite("Trailing pane header") +@MainActor +struct TrailingPaneHeaderModelTests { + // MARK: - Segments and title + + @Test("Browsing with AI on offers a picker between the inspector and the assistant") + func browsingOffersBothSurfaces() { + for surface in [TrailingPaneSurface.inspector, .assistant] { + let model = TrailingPaneHeaderModel(surface: surface, contentMode: .browse, isAIEnabled: true) + #expect(model.segments == [.inspector, .assistant], "\(surface)") + #expect(model.showsPicker, "\(surface)") + } + } + + /// A single segment is a control with nothing to choose, which is what the AI setting being off + /// leaves, so the header names the surface instead. + @Test("With AI off the header names the inspector rather than drawing a one-segment picker") + func aiOffDrawsATitle() { + let model = TrailingPaneHeaderModel(surface: .inspector, contentMode: .browse, isAIEnabled: false) + #expect(model.segments == [.inspector]) + #expect(!model.showsPicker) + #expect(model.title == TrailingPaneSurface.inspector.localizedTitle) + } + + @Test("Agent mode names the result, with nothing to choose") + func agentModeDrawsTheResultTitle() { + let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .agent, isAIEnabled: true) + #expect(model.segments.isEmpty) + #expect(!model.showsPicker) + #expect(model.title == String(localized: "Result")) + } + + /// The picker draws the surface it sits over as selected, so a surface outside the segments would + /// be a picker with nothing selected. + @Test("A surface the picker does not offer never draws the picker") + func unofferedSurfaceDrawsATitle() { + let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .browse, isAIEnabled: true) + #expect(!model.showsPicker) + } + + // MARK: - The menu + + @Test("The inspector's menu offers its renderings, and the JSON commands only over JSON") + func inspectorMenu() { + let fields = TrailingPaneHeaderModel( + surface: .inspector, + contentMode: .browse, + isAIEnabled: true, + inspectorRendering: .fields + ) + #expect(fields.menuSections == [.inspectorRendering]) + + let json = TrailingPaneHeaderModel( + surface: .inspector, + contentMode: .browse, + isAIEnabled: true, + inspectorRendering: .json + ) + #expect(json.menuSections == [.inspectorRendering, .jsonReading]) + } + + /// A schema grid's column definition has no JSON form, and a pane with no row draws table info or + /// nothing. A dimmed choice there still checked the stored rendering, which after JSON was picked + /// elsewhere on the connection read JSON over a pane drawing fields. + @Test("A selection with one rendering offers no choice between two") + func singleRenderingOffersNoChoice() { + let model = TrailingPaneHeaderModel( + surface: .inspector, + contentMode: .browse, + isAIEnabled: true, + inspectorRendering: nil + ) + #expect(!model.menuSections.contains(.inspectorRendering)) + #expect(!model.menuSections.contains(.jsonReading)) + } + + /// Clear Recents is its own section because it deletes, and its confirmation stays with it. + @Test("The assistant's menu offers its conversations, with Clear Recents apart") + func assistantMenu() { + let model = TrailingPaneHeaderModel(surface: .assistant, contentMode: .browse, isAIEnabled: true) + #expect(model.menuSections == [.conversations, .clearRecents]) + } + + @Test("The result's menu offers its views") + func resultMenu() { + let model = TrailingPaneHeaderModel(surface: .agentResult, contentMode: .agent, isAIEnabled: true) + #expect(model.menuSections == [.resultView]) + } + + /// Every command in the menu acts on a row, a conversation or a session a window without a live + /// connection does not have. + @Test("A pane with nothing behind it offers no menu") + func noContentNoMenu() { + for surface in TrailingPaneSurface.allCases { + let model = TrailingPaneHeaderModel( + surface: surface, + contentMode: .browse, + isAIEnabled: true, + hasContent: false + ) + #expect(model.menuSections.isEmpty, "\(surface)") + } + } + + /// The ellipsis carries no text, so its label is the only name VoiceOver and the tooltip have. + @Test("Each surface's menu has a name of its own") + func menuLabelsAreDistinct() { + let labels = TrailingPaneSurface.allCases.map { + TrailingPaneHeaderModel(surface: $0, contentMode: .browse, isAIEnabled: true).menuLabel + } + #expect(Set(labels).count == labels.count) + #expect(!labels.contains { $0.isEmpty }) + } + + /// Two unrelated histories in one window stopped sharing `clock`; the surfaces cannot share a + /// glyph either, or the picker's segments are told apart by their tooltips alone. + @Test("Each surface has a glyph of its own, none of them the pane's") + func surfaceGlyphsAreDistinct() { + let symbols = TrailingPaneSurface.allCases.map(\.symbolName) + #expect(Set(symbols).count == symbols.count) + #expect(!symbols.contains("sidebar.right")) + } + + // MARK: - The view + + /// The complaint the shared header answers: switching surface changed the height of the pane's + /// top edge, so everything under it jumped. A picker, a plain title, and no menu at all must all + /// come out the same height. + @Test("The header is the same height on every surface") + func headerHeightIsConstant() { + let paneState = TrailingPaneState() + let heights = AIFeatureScope.enabled { + [ + measuredHeight(TrailingPaneHeaderView(surface: .inspector, contentMode: .browse, paneState: paneState) { _ in + EmptyView() + }), + measuredHeight(TrailingPaneHeaderView(surface: .assistant, contentMode: .browse, paneState: paneState) { _ in + EmptyView() + }), + measuredHeight(TrailingPaneHeaderView(surface: .agentResult, contentMode: .agent, paneState: nil) { _ in + EmptyView() + }), + measuredHeight(TrailingPaneHeaderView( + surface: .inspector, + contentMode: .browse, + paneState: nil, + hasContent: false + ) { _ in + EmptyView() + }), + ] + } + #expect(Set(heights).count == 1, "heights: \(heights)") + #expect(heights.allSatisfy { $0 >= TrailingPaneHeaderMetrics.height }) + } + + private func measuredHeight(_ header: some View) -> CGFloat { + let host = NSHostingView(rootView: header.frame(width: 270)) + host.frame = NSRect(x: 0, y: 0, width: 270, height: 200) + host.layoutSubtreeIfNeeded() + return host.fittingSize.height + } +} + +/// The result column's answer to what it can draw. A connection that is down is the reason no +/// session can run, so it is named as such rather than reported as an empty session list, and a +/// session that exists is drawn only over a connection that is up. +@Suite("Trailing pane unavailable reason") +struct TrailingPaneUnavailableReasonTests { + @Test("A live connection with no session says no session is open") + func liveConnectionHasNoSession() { + #expect(TrailingPaneUnavailableView.Reason.agentResult(pane: .content, hasSession: false) == .noSession) + } + + @Test("A live connection with a session draws it") + func liveConnectionDrawsItsSession() { + #expect(TrailingPaneUnavailableView.Reason.agentResult(pane: .content, hasSession: true) == nil) + } + + /// The column used to keep a session's SQL and Results over a dropped connection, statements and + /// rows that could no longer run or be refreshed, beside a detail column already showing the + /// unavailable screen. + @Test("A connection that is not up says so, with or without a session", arguments: [false, true]) + func downConnectionIsNamed(hasSession: Bool) { + let panes: [ConnectionWindowPane] = [ + .connecting, + .unavailable(.notConnected), + .unavailable(.disconnected(nil)), + .empty, + ] + for pane in panes { + #expect( + TrailingPaneUnavailableView.Reason.agentResult(pane: pane, hasSession: hasSession) == .notConnected, + "\(pane)" + ) + } + } +} diff --git a/TableProTests/Models/TrailingPaneSurfaceResolverTests.swift b/TableProTests/Models/TrailingPaneSurfaceResolverTests.swift new file mode 100644 index 000000000..6e7deec89 --- /dev/null +++ b/TableProTests/Models/TrailingPaneSurfaceResolverTests.swift @@ -0,0 +1,129 @@ +// +// TrailingPaneSurfaceResolverTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Trailing pane surface resolver") +struct TrailingPaneSurfaceResolverTests { + @Test( + "Agent mode draws the result pane whatever the user last chose", + arguments: TrailingPaneSurface.allCases + ) + func agentModeImposesTheResult(stored: TrailingPaneSurface) { + #expect( + TrailingPaneSurfaceResolver.resolve(stored: stored, contentMode: .agent, isAIEnabled: true) + == .agentResult + ) + } + + @Test("Browsing draws the surface the user chose") + func browsingHonoursTheStoredSurface() { + #expect( + TrailingPaneSurfaceResolver.resolve(stored: .inspector, contentMode: .browse, isAIEnabled: true) + == .inspector + ) + #expect( + TrailingPaneSurfaceResolver.resolve(stored: .assistant, contentMode: .browse, isAIEnabled: true) + == .assistant + ) + } + + /// The assistant is the one surface a setting can take away, and the stored value is restored + /// per connection without anything asking whether the surface still exists. + @Test("With AI off, every surface resolves to the inspector", arguments: TrailingPaneSurface.allCases) + func aiOffFallsBackToTheInspector(stored: TrailingPaneSurface) { + #expect( + TrailingPaneSurfaceResolver.resolve(stored: stored, contentMode: .browse, isAIEnabled: false) + == .inspector + ) + } + + /// Agent mode is the AI feature, so a window left in it with the setting off is browsing, and + /// its pane must not go on drawing a result the mode no longer imposes. + @Test("Agent mode with AI off resolves as browsing", arguments: TrailingPaneSurface.allCases) + func agentModeWithAIOffResolvesAsBrowsing(stored: TrailingPaneSurface) { + #expect( + TrailingPaneSurfaceResolver.resolve(stored: stored, contentMode: .agent, isAIEnabled: false) + == .inspector + ) + } + + @Test("The header offers both surfaces while browsing with AI on") + func selectableWhileBrowsing() { + #expect( + TrailingPaneSurfaceResolver.selectable(contentMode: .browse, isAIEnabled: true) + == [.inspector, .assistant] + ) + #expect( + TrailingPaneSurfaceResolver.selectable(contentMode: .browse, isAIEnabled: false) == [.inspector] + ) + } + + /// Empty rather than one segment: the mode chose, so the header draws a plain title. + @Test("The header offers nothing to choose in Agent mode") + func selectableInAgentMode() { + #expect(TrailingPaneSurfaceResolver.selectable(contentMode: .agent, isAIEnabled: true).isEmpty) + } + + @Test("A resolved surface is one the header offers, unless the mode imposed it") + func resolvedSurfaceIsOffered() { + for mode in ConnectionWorkspaceContentMode.allCases { + for aiEnabled in [true, false] { + for stored in TrailingPaneSurface.allCases { + let resolved = TrailingPaneSurfaceResolver.resolve( + stored: stored, + contentMode: mode, + isAIEnabled: aiEnabled + ) + let offered = TrailingPaneSurfaceResolver.selectable( + contentMode: mode, + isAIEnabled: aiEnabled + ) + let imposed = offered.isEmpty && resolved == .agentResult + #expect(imposed || offered.contains(resolved)) + } + } + } + } + + /// A command that revealed the pane for a surface it will not draw opened it on another one. + @Test("Only the surface a mode and setting allow is drawn when asked for") + func drawsOnlyWhatTheModeAllows() { + for mode in ConnectionWorkspaceContentMode.allCases { + for aiEnabled in [true, false] { + for surface in TrailingPaneSurface.allCases { + let drawn = TrailingPaneSurfaceResolver.resolve( + stored: surface, + contentMode: mode, + isAIEnabled: aiEnabled + ) + #expect( + TrailingPaneSurfaceResolver.draws(surface, contentMode: mode, isAIEnabled: aiEnabled) + == (drawn == surface), + "\(mode) ai=\(aiEnabled) \(surface)" + ) + } + } + } + #expect(TrailingPaneSurfaceResolver.draws(.agentResult, contentMode: .agent, isAIEnabled: true)) + #expect(!TrailingPaneSurfaceResolver.draws(.inspector, contentMode: .agent, isAIEnabled: true)) + #expect(!TrailingPaneSurfaceResolver.draws(.assistant, contentMode: .browse, isAIEnabled: false)) + } + + /// The result pane belongs to a mode rather than to a command, so it never reaches the stored + /// per-connection preference and never appears in the header's choices. + @Test("The result surface is never user selectable") + func resultIsNeverSelectable() { + #expect(TrailingPaneSurface.agentResult.isUserSelectable == false) + for mode in ConnectionWorkspaceContentMode.allCases { + #expect( + TrailingPaneSurfaceResolver.selectable(contentMode: mode, isAIEnabled: true) + .contains(.agentResult) == false + ) + } + } +} diff --git a/TableProTests/Models/TrailingPaneSurfaceTests.swift b/TableProTests/Models/TrailingPaneSurfaceTests.swift index 76fe15d00..f862a47ff 100644 --- a/TableProTests/Models/TrailingPaneSurfaceTests.swift +++ b/TableProTests/Models/TrailingPaneSurfaceTests.swift @@ -39,12 +39,20 @@ struct TrailingPaneSurfaceTests { #expect(!surface.localizedTitle.isEmpty) } } + + /// The header's picker is icon-only, so a surface without a glyph would be a blank segment. + @Test("Every surface has a glyph") + func everySurfaceHasAGlyph() { + for surface in TrailingPaneSurface.allCases { + #expect(!surface.symbolName.isEmpty) + } + } } @Suite("Inspector view mode") struct InspectorViewModeTests { - /// Both modes are renderings of one selection, which is what makes a segmented control the - /// right control for them. The assistant used to be a third case here. + /// Both modes are renderings of one selection, which is what makes them one exclusive choice in + /// the pane header's menu rather than two commands. The assistant used to be a third case here. @Test("The inspector offers exactly its two renderings of the selected row") func offersTwoRenderings() { #expect(InspectorViewMode.allCases == [.fields, .json]) diff --git a/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift b/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift index e0cd1d809..11b616052 100644 --- a/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift +++ b/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift @@ -34,13 +34,13 @@ struct SplitPaneHoldingPriorityTests { struct MainSplitViewControllerDetailWidthTests { @Test("Nil tab type falls back to the default detail minimum") func nilTabTypeUsesDefault() { - let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: nil) + let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: nil, contentMode: .browse) #expect(resolved == MainSplitViewController.defaultDetailMinThickness) } @Test("Users & Roles declares the width its panes actually need") func usersRolesDeclaresItsOwnMinimum() { - let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: .usersRoles) + let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: .usersRoles, contentMode: .browse) #expect(resolved == UsersRolesLayoutMetrics.tabMinimumWidth) #expect(resolved == 560) } @@ -50,11 +50,23 @@ struct MainSplitViewControllerDetailWidthTests { arguments: [TabType.query, .table, .createTable, .erDiagram, .serverDashboard] ) func otherTabTypesUseDefault(tabType: TabType) { - let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: tabType) + let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: tabType, contentMode: .browse) #expect(resolved == MainSplitViewController.defaultDetailMinThickness) #expect(resolved == 400) } + /// A tab's minimum describes the tab's own content, and in Agent mode the conversation fills the + /// detail column instead. A Users & Roles tab left selected behind it set the conversation's + /// floor to the privilege editor's 560pt. + @Test( + "Agent mode keeps the default detail minimum, whatever tab is behind the conversation", + arguments: [nil, TabType.usersRoles, .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource] + ) + func agentModeUsesTheDefault(tabType: TabType?) { + let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: tabType, contentMode: .agent) + #expect(resolved == MainSplitViewController.defaultDetailMinThickness) + } + @Test("Users & Roles fits its privilege editor once the principal list collapses") func usersRolesMinimumFitsCollapsedLayout() { let privilegeEditorWidth = UsersRolesLayoutMetrics.privilegeScopeMinimumWidth @@ -89,7 +101,7 @@ struct MainSplitViewControllerDetailWidthTests { @Test("A Users & Roles tab widens the window minimum instead of pinning the inspector") func usersRolesWidensWindowMinimum() { let width = MainSplitViewController.resolveWindowMinWidth( - detailMinimum: MainSplitViewController.resolveDetailMinimumThickness(for: .usersRoles), + detailMinimum: MainSplitViewController.resolveDetailMinimumThickness(for: .usersRoles, contentMode: .browse), sidebarVisible: true, inspectorVisible: true, sidebarMinimum: MainSplitViewController.resolveSidebarMinimumThickness(railAllowance: 0), diff --git a/TableProTests/Services/MainWindowToolbarLayoutTests.swift b/TableProTests/Services/MainWindowToolbarLayoutTests.swift index 0ecaa7641..62810747c 100644 --- a/TableProTests/Services/MainWindowToolbarLayoutTests.swift +++ b/TableProTests/Services/MainWindowToolbarLayoutTests.swift @@ -11,26 +11,28 @@ import Testing @MainActor struct MainWindowToolbarLayoutTests { - @Test("Sidebar toggle is ordered into the sidebar's titlebar strip") - func sidebarToggleOrderedBeforeTrackingSeparator() throws { + /// Items ahead of `.sidebarTrackingSeparator` lay out in the sidebar's own titlebar strip and + /// follow its divider. The sidebar toggle is the only thing that belongs there: the list chooser + /// that used to share the strip now sits at the top of the sidebar, over the list it switches. + @Test("AppKit's sidebar toggle is alone in the sidebar's titlebar strip") + func sidebarToggleIsAloneBeforeTheTrackingSeparator() throws { let identifiers = MainWindowToolbar.defaultItemIdentifiers - let toggleIndex = try #require(identifiers.firstIndex(of: MainWindowToolbar.sidebarToggle)) let separatorIndex = try #require(identifiers.firstIndex(of: .sidebarTrackingSeparator)) - #expect(toggleIndex < separatorIndex) + #expect(Array(identifiers[.. Show Assistant and + /// ⌥⌘A are the command, and `TrailingPaneCommandTitleTests` is what pins their behaviour. + @Test("No toolbar item opens the assistant") + func assistantHasNoToolbarItem() { + let identifiers = Set(MainWindowToolbar.allowedItemIdentifiers).union( + MainWindowToolbar.defaultItemIdentifiers + ) + #expect(!identifiers.contains { $0.rawValue.hasSuffix(".assistant") }) } /// Ahead of the separator the toggle lands in the content section, which measured wrong in both @@ -85,7 +92,6 @@ struct MainWindowToolbarInspectorPlacementTests { } @available(macOS 14.0, *) - @Test("The inspector item is AppKit's standard toggle, not a private identifier") func inspectorIsTheStandardIdentifier() { #expect(MainWindowToolbar.inspector == NSToolbarItem.Identifier.toggleInspector) @@ -101,6 +107,7 @@ struct MainWindowToolbarInspectorPlacementTests { let standard = MainWindowToolbar.allowedItemIdentifiers.filter { $0.rawValue.hasPrefix("NSToolbar") } #expect(standard.contains(MainWindowToolbar.inspector)) + #expect(standard.contains(.toggleSidebar)) for identifier in standard { let item = owner.toolbar( owner.managedToolbar, @@ -154,12 +161,15 @@ struct MainWindowToolbarOverflowValidationTests { let (owner, _) = vendedItems() let expected: [Selector: NSToolbarItem.Identifier] = [ #selector(MainWindowToolbar.performRefresh(_:)): MainWindowToolbar.refresh, + #selector(MainWindowToolbar.performSaveChanges(_:)): MainWindowToolbar.saveChanges, #selector(MainWindowToolbar.performNewTab(_:)): MainWindowToolbar.newTab, #selector(MainWindowToolbar.performOpenQuickSwitcher(_:)): MainWindowToolbar.quickSwitcher, #selector(MainWindowToolbar.performExport(_:)): MainWindowToolbar.exportTables, #selector(MainWindowToolbar.performOpenDatabaseSwitcher(_:)): MainWindowToolbar.database, #selector(MainWindowToolbar.performToggleResults(_:)): MainWindowToolbar.results, #selector(MainWindowToolbar.performShowDashboard(_:)): MainWindowToolbar.dashboard, + #selector(MainWindowToolbar.performAddRow(_:)): MainWindowToolbar.addRow, + #selector(MainWindowToolbar.performRestorePreviousValues(_:)): MainWindowToolbar.restorePreviousValues, ] for (action, identifier) in expected { @@ -170,14 +180,27 @@ struct MainWindowToolbarOverflowValidationTests { } } - /// The import control carries no action of its own; its submenu entries do. - @Test("The import submenu validates as the import item") - func importSubmenuResolvesToTheImportItem() { + /// The Import item carries no action of its own, and its format entries carry no target, so + /// they reach the window's controller through the responder chain and are validated there. An + /// entry targeted at the toolbar would be validated by `MainWindowToolbar.validateMenuItem`, + /// which answers true for every action it did not build. + @Test("The import formats resolve through the responder chain, never through the toolbar") + func importFormatsResolveThroughTheResponderChain() { let owner = MainWindowToolbar() - _ = owner.subitemImport() + let item = owner.makeImportItem() + let format = ImportFormatMenuDelegate.item(for: ImportFormatOption(id: "csv", name: "CSV")) + + #expect(item.action == nil) + #expect(item.menuFormRepresentation?.submenu?.delegate === owner.importFormatMenuDelegate) + #expect(format.target == nil) + #expect(format.action == #selector(MainSplitViewController.importDataFormat(_:))) + #expect(format.representedObject as? String == "csv") + #expect(owner.itemIdentifier(forMenuFormAction: format.action) == nil) #expect( - owner.itemIdentifier(forMenuFormAction: #selector(MainWindowToolbar.performImportFormat(_:))) - == MainWindowToolbar.importTables + MainSplitViewController.resolvedEnablement( + #selector(MainSplitViewController.importDataFormat(_:)), + context: MenuValidationContext() + ) == false ) } @@ -190,7 +213,7 @@ struct MainWindowToolbarOverflowValidationTests { action: #selector(MainWindowToolbar.performRefresh(_:)), keyEquivalent: "" ) - _ = owner.subitemRefresh() + _ = owner.makeRefreshItem() #expect(!owner.validateMenuItem(menuItem)) } @@ -202,60 +225,3 @@ struct MainWindowToolbarOverflowValidationTests { #expect(owner.validateMenuItem(menuItem)) } } - -@MainActor -struct MainWindowToolbarCustomizationTests { - /// Opening Customize Toolbar makes AppKit ask the delegate again, with the flag off, for the - /// palette copies. Those used to overwrite the retained hosting controllers, releasing the ones - /// whose views were on screen, and the connection group and status item collapsed to nothing. - @Test("Only the item going into the toolbar claims the retained controller") - func paletteCopiesDoNotClaimTheSlot() { - #expect(MainWindowToolbar.claimsItemSlot(willBeInsertedIntoToolbar: true)) - #expect(!MainWindowToolbar.claimsItemSlot(willBeInsertedIntoToolbar: false)) - } - - /// The sidebar segmented control keeps a slot of the same shape, and it never read the guard. - /// A palette copy took the slot, so every later `syncSidebarSelection()` wrote into a discarded - /// group and the segments stopped following the sidebar until the window was reopened. - @Test("A palette copy does not take over the live sidebar control") - func paletteCopyDoesNotClaimTheSidebarGroup() throws { - let owner = MainWindowToolbar() - let live = try #require(owner.makeSidebarToggleItem(claimsSlot: true) as? NSToolbarItemGroup) - #expect(owner.sidebarGroup === live) - - let palette = try #require(owner.makeSidebarToggleItem(claimsSlot: false) as? NSToolbarItemGroup) - #expect(palette !== live) - #expect(owner.sidebarGroup === live) - } - - /// The delegate is the path Customize Toolbar actually takes. - @Test("Vending a palette item through the delegate leaves the live control alone") - func delegatePaletteVendLeavesTheSidebarGroupIntact() throws { - let owner = MainWindowToolbar() - _ = owner.toolbar( - owner.managedToolbar, - itemForItemIdentifier: MainWindowToolbar.sidebarToggle, - willBeInsertedIntoToolbar: true - ) - let live = try #require(owner.sidebarGroup) - - _ = owner.toolbar( - owner.managedToolbar, - itemForItemIdentifier: MainWindowToolbar.sidebarToggle, - willBeInsertedIntoToolbar: false - ) - #expect(owner.sidebarGroup === live) - } - - /// The identifier is the autosave name, and changing it discards every user's arrangement. It - /// moved to v3 with the rewrite that dropped the hosted status item, because a stored v2 list - /// names identifiers the delegate no longer vends and would leave those users the crowded - /// toolbar the rewrite exists to fix. It moved to v4 for the throughput readout, whose - /// identifier a stored v3 arrangement does not name, so a reader who had customized the toolbar - /// would never see it. It is not free, so it does not move again without the same - /// justification. - @Test("The toolbar identifier is stable") - func identifierIsStable() { - #expect(MainWindowToolbar.toolbarIdentifier == "com.TablePro.main.toolbar.v4") - } -} diff --git a/TableProTests/Services/MainWindowToolbarNativeContractTests.swift b/TableProTests/Services/MainWindowToolbarNativeContractTests.swift index 214c93e07..559f07282 100644 --- a/TableProTests/Services/MainWindowToolbarNativeContractTests.swift +++ b/TableProTests/Services/MainWindowToolbarNativeContractTests.swift @@ -38,84 +38,16 @@ struct MainWindowToolbarNativeContractTests { } } - /// The one exception, and the reason it is safe. A view-less item would carry the figure in - /// `title`, and a title re-measures: written into one with `validateVisibleItems()` called, the - /// group went 219pt, 233pt, 232pt, 251pt across `0 kB/s`, `145 kB/s`, `1.2 MB/s` and - /// `888.8 MB/s`, walking its own midpoint 16pt and sliding the connection name beside it once a - /// second. A view pinned to a width holds still: the group and field frames were byte-identical - /// across the same four figures. - /// - /// What made the old hosted status item undroppable was that it had no width of its own to give - /// back. This one is pinned to a width measured from the widest figure it can ever draw, so it - /// never needs compressing, and it is only in the group at all for a connection whose bytes the - /// app carries. - @Test("The throughput readout is view-backed, and pinned to a width it cannot outgrow") - func throughputReadoutIsPinned() throws { - let owner = MainWindowToolbar() - let field = try #require(owner.transportRateItem.view as? NSTextField) - let pinned = field.constraints.filter { $0.firstAttribute == .width && $0.relation == .equal } - let constant = try #require(pinned.first?.constant) - - #expect(pinned.count == 1, "The readout must carry exactly one width constraint") - - let font = try #require(field.font) - for candidate in TransportRateLabel.widestCandidates { - let width = (candidate as NSString).size(withAttributes: [.font: font]).width - #expect(width <= constant, "\"\(candidate)\" needs \(width)pt but the field is \(constant)pt") - } - } - - /// Bare text, no capsule, which is what Xcode does with the one comparable thing it ships: its - /// Window Title/Activity readout draws as plain text beside the Back/Forward capsule, measured - /// on a running Xcode. `isBordered` here would give the readout a platter of its own and make - /// the centre three capsules for two controls and one number. - @Test("The throughput readout wears no capsule") - func throughputReadoutIsUnbordered() { - #expect(!MainWindowToolbar().transportRateItem.isBordered) - } - - /// Beside the centred pair, never inside it. A group is laid out around its own midpoint, so a - /// readout among the subitems pushes the connection and database capsules off centre by half its - /// width. Measured at 1400pt: as its own adjacent item the group sits at x=647.0 midX=772.8, - /// byte-identical to carrying no readout at all. - @Test("The readout sits beside the centred group, not inside it and not centred itself") - func readoutIsAdjacentToTheCentre() throws { - let owner = MainWindowToolbar() - let group = try #require( - owner.toolbar( - owner.managedToolbar, - itemForItemIdentifier: MainWindowToolbar.connectionGroup, - willBeInsertedIntoToolbar: true - ) as? NSToolbarItemGroup - ) - - #expect(!group.subitems.contains { $0 === owner.transportRateItem }) - #expect(!owner.managedToolbar.centeredItemIdentifiers.contains(TransportRateToolbarItem.identifier)) - - let identifiers = MainWindowToolbar.defaultItemIdentifiers - let centre = try #require(identifiers.firstIndex(of: MainWindowToolbar.connectionGroup)) - let readout = try #require(identifiers.firstIndex(of: TransportRateToolbarItem.identifier)) - #expect(readout == centre + 1, "The readout must follow the centred group immediately") - } - - /// Emptying the readout's own group is how it leaves the toolbar. Measured, nothing else hides - /// it cleanly: a hidden view keeps its 75pt and a zero-width constraint still leaves 24pt, and - /// `NSToolbarItem.isHidden` is macOS 15 against a macOS 14 floor. - @Test("A connection with no measurable transport shows no readout") - func unmeasuredConnectionsCarryNoReadout() { - #expect(MainWindowToolbar().transportRateGroup.subitems.isEmpty) - } - /// Availability is `isEnabled`, never presence. Measured on three running Apple apps, Xcode, /// Finder in column view and System Settings all keep the 75pt Back/Forward capsule and dim the /// direction that has nowhere to go; the HIG says the same for the menu bar, "disable the action /// instead of hiding it". /// /// This asserts on the VENDED item and on a toolbar with no coordinator, which is the state a - /// hidden pair would report. Testing the pure `isEnabled(itemIdentifier:context:)` predicate - /// cannot catch the regression this replaces: four such cases in - /// `MainWindowToolbarValidationTests` stayed green for the whole life of the hiding commit, - /// because they never look at composition. + /// hidden pair would report. Testing the pure `ToolbarContextResolver.isEnabled` predicate + /// cannot catch the regression this replaces: four such cases stayed green for the whole life + /// of the hiding commit, because they never look at composition. The pair is offered by + /// Customize Toolbar rather than the default set, and a user who puts it back gets it whole. @Test("Back and forward are present and dimmed, never absent") func navigationIsPresentAndDimmed() throws { let owner = MainWindowToolbar() @@ -130,7 +62,8 @@ struct MainWindowToolbarNativeContractTests { #expect(group.subitems.count == 2, "The pair is installed unconditionally") #expect(group.subitems.map(\.itemIdentifier) == [MainWindowToolbar.navigateBack, MainWindowToolbar.navigateForward]) /// What puts the pair on the leading edge, where the HIG keeps items that return to the - /// previous document and where they are not customizable away. + /// previous document, once a user has dragged it in from Customize Toolbar. The default set + /// no longer carries it; ⌃⌘[ and ⌃⌘] and the Actions pull-down on a table tab do instead. #expect(group.isNavigational) for subitem in group.subitems { @@ -157,49 +90,30 @@ struct MainWindowToolbarNativeContractTests { } } - /// The readout is a readout: it publishes no action, so AppKit never validates it and it has no - /// menu-bar command of its own. That is the trade the placement makes, and it is pinned here so - /// a later change that gives it an action has to say so. - /// - /// It still gets an overflow entry, because the centred group is the first region AppKit sheds - /// when the window narrows and the figure should not vanish with the controls beside it. The - /// entry is disabled: there is nothing to click. - @Test("The throughput readout claims no action but still reports in the overflow menu") - func throughputReadoutIsInertButVisible() throws { - let owner = MainWindowToolbar() - let item = owner.transportRateItem - - #expect(item.action == nil) - - let entry = try #require(item.menuFormRepresentation) - #expect(entry.action == nil) - #expect(!entry.isEnabled) - #expect(!entry.title.isEmpty) - } - - /// An arrow glyph is what the field draws; it is not what the overflow entry or VoiceOver - /// should be handed, because neither reads it as a direction. - @Test("The overflow entry names the direction rather than drawing an arrow") - func overflowEntryNamesTheDirection() throws { - let owner = MainWindowToolbar() - owner.transportRateItem.apply(rate: TransportRate(receivedPerSecond: 145_408, sentPerSecond: 0)) - let entry = try #require(owner.transportRateItem.menuFormRepresentation) - - #expect(!entry.title.contains("\u{2193}")) - #expect(!entry.title.contains("\u{2191}")) - } - - /// Finder ships 8 controls and Xcode 13. The default set was 17 plus a hosted status blob, and - /// the HIG asks that items be chosen "deliberately to avoid overcrowding". Spaces do not count, - /// because they cost no titlebar width of their own. + /// Finder ships 8 controls and Xcode 13. The default set was 17 hit targets behind 11 + /// identifiers, and the guard that stood here counted identifiers, which is how a two-segment + /// control was added to a full titlebar and passed. So this counts what a pointer can hit, with + /// every group vended and expanded to the subitems it draws. Spaces and tracking separators take + /// no click and are not counted. @available(macOS 14.0, *) - @Test("The default set stays inside a titlebar") + @Test("The default set is at most eight things to click") func defaultSetIsNotCrowded() { + let owner = MainWindowToolbar() let spaces: Set = [ .flexibleSpace, .space, .sidebarTrackingSeparator, .inspectorTrackingSeparator, ] - let controls = MainWindowToolbar.defaultItemIdentifiers.filter { !spaces.contains($0) } - #expect(controls.count <= 12, "default set has \(controls.count) controls") + let targets = MainWindowToolbar.defaultItemIdentifiers + .filter { !spaces.contains($0) } + .map { identifier -> Int in + let item = owner.toolbar( + owner.managedToolbar, + itemForItemIdentifier: identifier, + willBeInsertedIntoToolbar: true + ) + return (item as? NSToolbarItemGroup).map { max($0.subitems.count, 1) } ?? 1 + } + .reduce(0, +) + #expect(targets <= 8, "default set has \(targets) hit targets") } /// The HIG's centre area is for "common, useful controls", and SwiftUI's `principal` placement @@ -208,10 +122,30 @@ struct MainWindowToolbarNativeContractTests { /// field, and both are controls that open a chooser. Xcode centres the same shape, measured /// through its accessibility tree: a list of role "path" holding Active Scheme and Active Run /// Destination. - @Test("The connection and container are the centred principal item") - func connectionGroupIsCentred() { + /// + /// Two top-level items, not a group. Measured on macOS 27, a popover anchored on a subitem + /// raised `NSInvalidArgumentException` whenever its group was hidden or clipped, and a + /// top-level item raised in none of 16 presentations across the same states. + @Test("The connection and container are the centred principal pair, as two top-level items") + func centredPairIsTwoTopLevelItems() throws { let owner = MainWindowToolbar() - #expect(owner.managedToolbar.centeredItemIdentifiers == [MainWindowToolbar.connectionGroup]) + let pair: Set = [MainWindowToolbar.connection, MainWindowToolbar.database] + #expect(owner.managedToolbar.centeredItemIdentifiers == pair) + + let identifiers = MainWindowToolbar.defaultItemIdentifiers + let connection = try #require(identifiers.firstIndex(of: MainWindowToolbar.connection)) + #expect(identifiers.indices.contains(connection + 1)) + #expect(identifiers[connection + 1] == MainWindowToolbar.database, "The pair centres as one run") + + for identifier in pair { + let item = owner.toolbar( + owner.managedToolbar, + itemForItemIdentifier: identifier, + willBeInsertedIntoToolbar: true + ) + #expect(item != nil) + #expect(!(item is NSToolbarItemGroup), "\(identifier.rawValue) must not be a group") + } } /// The centre is the first region AppKit sheds, and the two names it carries have no length @@ -228,8 +162,8 @@ struct MainWindowToolbarNativeContractTests { itemForItemIdentifier: identifier, willBeInsertedIntoToolbar: true ) else { continue } - let expected: NSToolbarItem.VisibilityPriority = - identifier == MainWindowToolbar.connectionGroup ? .standard : .high + let centred = identifier == MainWindowToolbar.connection || identifier == MainWindowToolbar.database + let expected: NSToolbarItem.VisibilityPriority = centred ? .standard : .high #expect(item.visibilityPriority == expected, "\(identifier.rawValue)") } } @@ -238,25 +172,89 @@ struct MainWindowToolbarNativeContractTests { /// is what lets the centred pair read as words while every other item stays a glyph. A centred /// item with no title would be two anonymous glyphs in the middle of the window. @Test("The centred items carry a title, not just a label") - func centredItemsCarryTitles() throws { + func centredItemsCarryTitles() { let owner = MainWindowToolbar() - let group = try #require( + for identifier in [MainWindowToolbar.connection, MainWindowToolbar.database] { + let item = owner.toolbar( + owner.managedToolbar, + itemForItemIdentifier: identifier, + willBeInsertedIntoToolbar: true + ) + #expect(item is StatefulToolbarItem, "\(identifier.rawValue)") + #expect((item as? StatefulToolbarItem)?.titleProvider != nil, "\(identifier.rawValue)") + } + } + + /// The pull-down carries no action. Given one, AppKit splits the control into a body that sends + /// it and a chevron that opens the menu, so a click on the body would open nothing. Its overflow + /// entry is AppKit's: measured on macOS 27, an `NSMenuToolbarItem` answers with a fresh item over + /// its own menu whatever was assigned, so a narrow window's overflow offers what the control + /// would. If that ever changes, the overflow stops being filled, and this is where it shows. + @Test("The Actions item opens a menu its delegate fills, from the control and from the overflow") + func actionsItemIsAPullDown() throws { + let owner = MainWindowToolbar() + let item = try #require( owner.toolbar( owner.managedToolbar, - itemForItemIdentifier: MainWindowToolbar.connectionGroup, + itemForItemIdentifier: MainWindowToolbar.actions, willBeInsertedIntoToolbar: true - ) as? NSToolbarItemGroup + ) as? NSMenuToolbarItem ) - #expect(group.subitems.count == 2) - for subitem in group.subitems { - #expect(subitem is StatefulToolbarItem, "\(subitem.itemIdentifier.rawValue)") - } + + #expect(item.action == nil) + #expect(item.menu.delegate === owner.actionsMenuDelegate) + let overflow = try #require(item.menuFormRepresentation) + #expect(overflow.submenu === item.menu) + #expect(overflow.title == item.label) + #expect(!item.label.isEmpty) + } + + /// The commit control says what its tab commits. The palette, the overflow entry and the + /// tooltip all read the label, so a Create Table tab offering to Save Changes is the defect. + /// + /// Vended with nothing staged on purpose: the label is the tab's, so a definition that does not + /// validate yet still reads Create Table, and an edit that makes it valid cannot relabel the + /// control and reflow a labelled titlebar. + @Test("The commit control is labelled with the verb its tab commits with") + func commitControlNamesItsVerb() throws { + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "db_a"), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + let owner = MainWindowToolbar() + coordinator.tabManager.addCreateTableTab() + #expect(coordinator.toolbarState.pendingChange == nil) + owner.repoint(to: coordinator) + + let item = try #require( + owner.toolbar( + owner.managedToolbar, + itemForItemIdentifier: MainWindowToolbar.saveChanges, + willBeInsertedIntoToolbar: true + ) + ) + #expect(item.label == String(localized: "Create Table")) + #expect(item.menuFormRepresentation?.title == String(localized: "Create Table")) + + coordinator.tabManager.addTab() + coordinator.toolbarState.pendingChange = .createTable + let query = try #require( + owner.toolbar( + owner.managedToolbar, + itemForItemIdentifier: MainWindowToolbar.saveChanges, + willBeInsertedIntoToolbar: true + ) + ) + #expect(query.label == String(localized: "Save Changes"), "A query tab saves, whatever is staged") } /// The Safe Mode glyph and its tooltip both name the level, because the glyph alone cannot: /// `lock` and `lock.open` differ by a few pixels and VoiceOver reads no image at all. Writing a - /// bare `toolTip` after `levelProvider` used to overwrite it permanently, since the item only - /// rewrites the tooltip when the level it applied changes. + /// bare `toolTip` after `statusProvider` used to overwrite it permanently, since the item only + /// rewrites the tooltip when the status it applied changes. @Test("The Safe Mode item's tooltip names the level, not just the control") func safeModeTooltipNamesTheLevel() throws { let owner = MainWindowToolbar() @@ -272,6 +270,29 @@ struct MainWindowToolbarNativeContractTests { #expect(item.image != nil) } + /// Agent mode raises the floor to Alert, and a connection the user already set stricter keeps + /// its level, so the floor arrives and leaves without the level moving. The tooltip is rewritten + /// only when what it says changes, and it has to count the floor as part of that or the reason + /// would never appear, or never go. + @Test("The Safe Mode item's tooltip says why a floor holds the level, and drops it when the floor lifts") + func safeModeTooltipCarriesTheFloor() { + let item = SafeModeToolbarItem(itemIdentifier: MainWindowToolbar.safeMode) + let floor = SafeModeFloor(level: .alert, reason: .agentMode) + let source = SafeModeStatusSource(SafeModeStatus(level: .safeMode, floor: nil)) + item.statusProvider = { source.status } + #expect(item.toolTip?.contains(floor.explanation) == false) + + source.status = SafeModeStatus(level: .safeMode, floor: floor) + item.validate() + #expect(item.toolTip?.contains(SafeModeLevel.safeMode.displayName) == true) + #expect(item.toolTip?.contains(floor.explanation) == true) + + source.status = SafeModeStatus(level: .safeMode, floor: nil) + item.validate() + #expect(item.toolTip?.contains(floor.explanation) == false) + #expect(item.toolTip?.contains(SafeModeLevel.safeMode.displayName) == true) + } + /// An overflowed item survives only as its `menuFormRepresentation`, and AppKit writes that /// entry's image once when the item is vended, so a glyph that follows the connection left the /// previous engine's icon in the menu. @@ -283,15 +304,12 @@ struct MainWindowToolbarNativeContractTests { @Test("The connection glyph reaches the overflow entry too") func engineGlyphReachesTheMenuForm() throws { let owner = MainWindowToolbar() - let group = try #require( + let connection = try #require( owner.toolbar( owner.managedToolbar, - itemForItemIdentifier: MainWindowToolbar.connectionGroup, + itemForItemIdentifier: MainWindowToolbar.connection, willBeInsertedIntoToolbar: true - ) as? NSToolbarItemGroup - ) - let connection = try #require( - group.subitems.first { $0.itemIdentifier == MainWindowToolbar.connection } + ) ) #expect(connection.image != nil) @@ -311,8 +329,9 @@ struct MainWindowToolbarNativeContractTests { } /// The HIG's macOS rule: "Make every toolbar item available as a command in the menu bar." The - /// rewrite moved the sidebar's two lists out of the toolbar's segmented control, and a command - /// with no toolbar item and no menu item is unreachable. + /// rewrite moved the sidebar's two lists out of the toolbar's segmented control and into the + /// sidebar's own scope control, which a collapsed sidebar takes away with it, so the menu bar is + /// what keeps both lists reachable. /// /// The other relocated commands live in submenus their delegate fills on open, so they are /// checked where that is true of them: `safeModeSubmenuOffersEveryLevel` and @@ -333,6 +352,23 @@ struct MainWindowToolbarNativeContractTests { } } + /// Import Data… takes the driver's first format, and the per-format list used to live only in + /// the toolbar, which is not a menu-bar command. The Actions pull-down now offers the list under + /// this same title, so the menu bar carries its twin, filled by the same class. + @Test("File > Import offers the command and, right under it, the list of formats") + func fileImportOffersTheFormatList() throws { + let menu = MainMenuBuilder.build(keyboard: KeyboardSettings()) + let file = try #require(menu.items.first { $0.submenu?.title == String(localized: "File") }?.submenu) + let importMenu = try #require(file.items.first { $0.title == String(localized: "Import") }?.submenu) + let leaf = try #require(importMenu.items.first { $0.title == String(localized: "Import Data…") }) + let list = try #require(importMenu.items.first { $0.title == String(localized: "Import Data From") }) + + #expect(leaf.action == #selector(MainSplitViewController.importData(_:))) + #expect(leaf.submenu == nil) + #expect(list.submenu?.delegate is ImportFormatMenuDelegate) + #expect(importMenu.index(of: list) == importMenu.index(of: leaf) + 1) + } + /// Safe Mode's list does not depend on a session, so its delegate fills it every time and all /// six levels have to be there. A five-level list would silently strip a level from the only /// menu-bar route to it. @@ -427,6 +463,8 @@ struct MainWindowToolbarSingleSourceTests { func windowCarriesNoSubtitle() { let resolved = WindowTitleResolver.resolveWindow( pane: .content, + contentMode: .browse, + agentSessionTitle: nil, connection: TestFixtures.makeConnection(database: "myapp", type: .postgresql), tab: nil, hasTabs: true, @@ -451,3 +489,14 @@ struct MainWindowToolbarSingleSourceTests { } } } + +/// What a toolbar item's status provider reads, changed between validation passes the way a +/// connection's level and floor change under a live item. +@MainActor +private final class SafeModeStatusSource { + var status: SafeModeStatus + + init(_ status: SafeModeStatus) { + self.status = status + } +} diff --git a/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift b/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift index b6a0da8b9..03975af14 100644 --- a/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift +++ b/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift @@ -22,7 +22,7 @@ struct MainWindowToolbarShortcutHintTests { ) } - /// Most commands ride a group, so the delegate vends no standalone item for them. Resolving + /// Back and Forward ride a group, so the delegate vends no standalone item for either. Resolving /// through the group is what the tests have to do, and it is also what the overflow menu and /// the customization palette do. private func vendSubitem( @@ -52,11 +52,7 @@ struct MainWindowToolbarShortcutHintTests { @Test("A vended item takes its key equivalent from the user's binding, not a literal") func vendedItemFollowsCustomBinding() { withKeyboard(.character("j", command: true, control: true), for: .quickSwitcher) { owner in - let item = vendSubitem( - MainWindowToolbar.quickSwitcher, - of: MainWindowToolbar.editorGroup, - from: owner - ) + let item = vend(MainWindowToolbar.quickSwitcher, from: owner) let menuItem = item?.menuFormRepresentation #expect(menuItem?.keyEquivalent == "j") #expect(menuItem?.keyEquivalentModifierMask == [.command, .control]) @@ -66,11 +62,7 @@ struct MainWindowToolbarShortcutHintTests { @Test("A vended item's tooltip names the user's binding") func vendedItemTooltipNamesCustomBinding() { withKeyboard(.character("j", command: true, control: true), for: .quickSwitcher) { owner in - let item = vendSubitem( - MainWindowToolbar.quickSwitcher, - of: MainWindowToolbar.editorGroup, - from: owner - ) + let item = vend(MainWindowToolbar.quickSwitcher, from: owner) #expect(item?.toolTip?.contains("⌃⌘J") == true) } } @@ -87,25 +79,24 @@ struct MainWindowToolbarShortcutHintTests { return keyboard }() - let owner = MainWindowToolbar() - guard let item = vendSubitem( - MainWindowToolbar.quickSwitcher, - of: MainWindowToolbar.editorGroup, - from: owner - ) else { + /// A private identifier with autosave off. Inserting into a toolbar named like the app's own + /// writes an arrangement into the test host's defaults, which are the app's. + let identifier = NSToolbar.Identifier("com.TablePro.tests.\(UUID().uuidString)") + let owner = MainWindowToolbar(managedToolbar: NSToolbar(identifier: identifier)) + owner.managedToolbar.autosavesConfiguration = false + guard let item = vend(MainWindowToolbar.quickSwitcher, from: owner) else { Issue.record("Toolbar did not vend the Open Quickly item") return } - owner.managedToolbar.insertItem(withItemIdentifier: MainWindowToolbar.editorGroup, at: 0) + owner.managedToolbar.insertItem(withItemIdentifier: MainWindowToolbar.quickSwitcher, at: 0) var keyboard = AppSettingsManager.shared.keyboard keyboard.setShortcut(.character("j", command: true, control: true), for: .quickSwitcher) AppSettingsManager.shared.keyboard = keyboard - let group = owner.managedToolbar.items.first { - $0.itemIdentifier == MainWindowToolbar.editorGroup - } as? NSToolbarItemGroup - let vendedItem = group?.subitems.first { $0.itemIdentifier == MainWindowToolbar.quickSwitcher } + let vendedItem = owner.managedToolbar.items.first { + $0.itemIdentifier == MainWindowToolbar.quickSwitcher + } /// The toolbar refreshes off the settings write rather than inside it, so the hop through /// the main run loop has to complete before the item can be read. #expect(spinRunLoopUntil { vendedItem?.menuFormRepresentation?.keyEquivalent == "j" }) @@ -155,12 +146,10 @@ struct MainWindowToolbarShortcutHintTests { /// The Import item opens a submenu, so its menu form carries the submenu rather than a key. /// Writing a key equivalent onto it would put a shortcut on a row that only opens a menu. - /// It is only ever vended inside the Export & Import group, never on its own. @Test("The Import item keeps its submenu and takes no key equivalent") func submenuItemTakesNoKeyEquivalent() { let owner = MainWindowToolbar() - let group = vend(MainWindowToolbar.exportImportGroup, from: owner) as? NSToolbarItemGroup - let item = group?.subitems.first { $0.itemIdentifier == MainWindowToolbar.importTables } + let item = vend(MainWindowToolbar.importTables, from: owner) #expect(item?.menuFormRepresentation?.submenu != nil) #expect(item?.menuFormRepresentation?.keyEquivalent == "") #expect(item?.toolTip?.isEmpty == false) diff --git a/TableProTests/Services/MainWindowToolbarValidationTests.swift b/TableProTests/Services/MainWindowToolbarValidationTests.swift index 68ed63a69..fedc4e70b 100644 --- a/TableProTests/Services/MainWindowToolbarValidationTests.swift +++ b/TableProTests/Services/MainWindowToolbarValidationTests.swift @@ -25,6 +25,8 @@ private final class RecordingToolbar: NSToolbar { } } +/// Every toolbar item answers from `ToolbarContextResolver`, so the rules are pinned against a +/// `ToolbarContext` value, and the cases that need a live toolbar build one. @MainActor struct MainWindowToolbarValidationTests { private let sessionScopedIdentifiers: [NSToolbarItem.Identifier] = [ @@ -32,51 +34,52 @@ struct MainWindowToolbarValidationTests { MainWindowToolbar.quickSwitcher, MainWindowToolbar.newTab, MainWindowToolbar.exportTables, - MainWindowToolbar.sidebarToggle, MainWindowToolbar.saveChanges, MainWindowToolbar.previewSQL, MainWindowToolbar.database, MainWindowToolbar.dashboard, MainWindowToolbar.importTables, - MainWindowToolbar.results + MainWindowToolbar.results, + MainWindowToolbar.safeMode, + MainWindowToolbar.history, ] private func makeContext( connected: Bool = true, - isTableTab: Bool = false, - canAddRow: Bool = false, - canRestorePreviousValues: Bool = false, - hasPendingChanges: Bool = false, + tabKind: TabType? = .query, + pendingChange: PendingChangeKind? = nil, hasDataPendingChanges: Bool = false, blocksAllWrites: Bool = false, fileBased: Bool = false, supportsContainerSwitching: Bool = true, supportsImport: Bool = true, - supportsServerDashboard: Bool = true, - canNavigateBack: Bool = false, - canNavigateForward: Bool = false - ) -> MainWindowToolbar.ValidationContext { - MainWindowToolbar.ValidationContext( - connected: connected, - isTableTab: isTableTab, - canAddRow: canAddRow, - canRestorePreviousValues: canRestorePreviousValues, - hasPendingChanges: hasPendingChanges, + supportsServerDashboard: Bool = true + ) -> ToolbarContext { + ToolbarContext( + tabKind: tabKind, + pane: connected ? .content : .unavailable(.notConnected), + isConnected: connected, + hasSelectedWorkspace: true, + pendingChange: pendingChange, hasDataPendingChanges: hasDataPendingChanges, blocksAllWrites: blocksAllWrites, - fileBased: fileBased, + isFileBased: fileBased, supportsContainerSwitching: supportsContainerSwitching, supportsImport: supportsImport, - supportsServerDashboard: supportsServerDashboard, - canNavigateBack: canNavigateBack, - canNavigateForward: canNavigateForward + supportsServerDashboard: supportsServerDashboard ) } + private func isEnabled(_ identifier: NSToolbarItem.Identifier, _ context: ToolbarContext) -> Bool { + ToolbarContextResolver.isEnabled(identifier, context: context) + } + private func makeRecordingOwner() -> (owner: MainWindowToolbar, toolbar: RecordingToolbar) { let identifier = NSToolbar.Identifier("com.TablePro.tests.toolbar.\(UUID().uuidString)") let toolbar = RecordingToolbar(identifier: identifier) - return (MainWindowToolbar(managedToolbar: toolbar), toolbar) + let owner = MainWindowToolbar(managedToolbar: toolbar) + toolbar.autosavesConfiguration = false + return (owner, toolbar) } private func waitForValidation(_ toolbar: RecordingToolbar, after baseline: Int) async { @@ -94,74 +97,70 @@ struct MainWindowToolbarValidationTests { @Test("Save Changes disabled when safe mode blocks writes") func saveChangesBlockedBySafeMode() { - let context = makeContext( - connected: true, - hasPendingChanges: true, - blocksAllWrites: true - ) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.saveChanges, context: context) == false) + let context = makeContext(pendingChange: .data, blocksAllWrites: true) + #expect(isEnabled(MainWindowToolbar.saveChanges, context) == false) } @Test("Save Changes disabled when no pending changes") func saveChangesDisabledWhenNoPending() { - let context = makeContext(connected: true, hasPendingChanges: false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.saveChanges, context: context) == false) + #expect(isEnabled(MainWindowToolbar.saveChanges, makeContext()) == false) } @Test("Save Changes enabled when pending changes, connected, writes allowed") func saveChangesEnabledHappyPath() { - let context = makeContext(connected: true, hasPendingChanges: true, blocksAllWrites: false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.saveChanges, context: context) == true) + #expect(isEnabled(MainWindowToolbar.saveChanges, makeContext(pendingChange: .data))) + } + + /// The defect `PendingChangeKind` was introduced for: staged principals are a pending change, + /// and the commit control has to answer for them like any other kind. + @Test("Save Changes answers for every kind of staged change", arguments: [ + PendingChangeKind.data, .structure, .createTable, .principals, .file, + ]) + func saveChangesAnswersForEveryKind(kind: PendingChangeKind) { + #expect(isEnabled(MainWindowToolbar.saveChanges, makeContext(pendingChange: kind))) } @Test("Save Changes disabled when disconnected") func saveChangesDisabledWhenDisconnected() { - let context = makeContext(connected: false, hasPendingChanges: true) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.saveChanges, context: context) == false) + let context = makeContext(connected: false, pendingChange: .data) + #expect(isEnabled(MainWindowToolbar.saveChanges, context) == false) } - @Test("Results enabled only off table tabs") - func resultsDisabledOnTableTab() { - let onTable = makeContext(connected: true, isTableTab: true) - let onQuery = makeContext(connected: true, isTableTab: false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.results, context: onTable) == false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.results, context: onQuery) == true) + /// The results pane belongs to the query editor. The shipped rule was `!isTableTab`, which + /// enabled it on the five kinds that have no results pane at all. + @Test("Results answers on a query tab and nowhere else", arguments: [ + TabType.query, .table, .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, + ]) + func resultsIsPerTabKind(tabKind: TabType) { + #expect(isEnabled(MainWindowToolbar.results, makeContext(tabKind: tabKind)) == (tabKind == .query)) } @Test("Database switcher disabled for file-based connections") func databaseDisabledForFileBased() { - let fileBased = makeContext(connected: true, fileBased: true) - let networked = makeContext(connected: true, fileBased: false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.database, context: fileBased) == false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.database, context: networked) == true) + #expect(isEnabled(MainWindowToolbar.database, makeContext(fileBased: true)) == false) + #expect(isEnabled(MainWindowToolbar.database, makeContext(fileBased: false))) } @Test("Database switcher requires plugin support") func databaseRequiresPluginSupport() { - let unsupported = makeContext(connected: true, supportsContainerSwitching: false) - let supported = makeContext(connected: true, supportsContainerSwitching: true) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.database, context: unsupported) == false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.database, context: supported) == true) + #expect(isEnabled(MainWindowToolbar.database, makeContext(supportsContainerSwitching: false)) == false) + #expect(isEnabled(MainWindowToolbar.database, makeContext(supportsContainerSwitching: true))) } @Test("Import disabled when safe mode blocks writes") func importBlockedBySafeMode() { - let context = makeContext(connected: true, blocksAllWrites: true, supportsImport: true) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.importTables, context: context) == false) + #expect(isEnabled(MainWindowToolbar.importTables, makeContext(blocksAllWrites: true)) == false) } @Test("Import requires plugin support") func importRequiresPluginSupport() { - let context = makeContext(connected: true, supportsImport: false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.importTables, context: context) == false) + #expect(isEnabled(MainWindowToolbar.importTables, makeContext(supportsImport: false)) == false) } @Test("Export requires only connection") func exportRequiresConnection() { - let connected = makeContext(connected: true) - let disconnected = makeContext(connected: false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.exportTables, context: connected) == true) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.exportTables, context: disconnected) == false) + #expect(isEnabled(MainWindowToolbar.exportTables, makeContext(connected: true))) + #expect(isEnabled(MainWindowToolbar.exportTables, makeContext(connected: false)) == false) } @Test("Preview SQL requires data pending changes and connection") @@ -170,66 +169,108 @@ struct MainWindowToolbarValidationTests { let onlyConnected = makeContext(connected: true, hasDataPendingChanges: false) let onlyPending = makeContext(connected: false, hasDataPendingChanges: true) let both = makeContext(connected: true, hasDataPendingChanges: true) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.previewSQL, context: neither) == false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.previewSQL, context: onlyConnected) == false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.previewSQL, context: onlyPending) == false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.previewSQL, context: both) == true) + #expect(isEnabled(MainWindowToolbar.previewSQL, neither) == false) + #expect(isEnabled(MainWindowToolbar.previewSQL, onlyConnected) == false) + #expect(isEnabled(MainWindowToolbar.previewSQL, onlyPending) == false) + #expect(isEnabled(MainWindowToolbar.previewSQL, both)) + } + + /// A dirty query file raises the commit control and has no grid SQL to preview. The two are + /// computed from different inputs, and this is the case that tells them apart. + @Test("A dirty query file lights Save and leaves Preview SQL dim") + func dirtyFileIsNotPreviewable() { + let context = makeContext(pendingChange: .file, hasDataPendingChanges: false) + #expect(isEnabled(MainWindowToolbar.saveChanges, context)) + #expect(isEnabled(MainWindowToolbar.previewSQL, context) == false) } @Test("Dashboard requires plugin support and connection") func dashboardRequirements() { - let unsupported = makeContext(connected: true, supportsServerDashboard: false) - let disconnected = makeContext(connected: false, supportsServerDashboard: true) - let happy = makeContext(connected: true, supportsServerDashboard: true) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.dashboard, context: unsupported) == false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.dashboard, context: disconnected) == false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.dashboard, context: happy) == true) + #expect(isEnabled(MainWindowToolbar.dashboard, makeContext(supportsServerDashboard: false)) == false) + #expect(isEnabled(MainWindowToolbar.dashboard, makeContext(connected: false)) == false) + #expect(isEnabled(MainWindowToolbar.dashboard, makeContext())) } - @Test("Connection and History stay enabled regardless of connection state") - func alwaysEnabledItems() { + /// Switch Connection is the window's command and the route back from a connection that failed, + /// so it answers with no session. Query History used to share that arm and was live and inert + /// over a window that had never connected. + @Test("Connection answers without a session, and History does not") + func connectionIsTheOnlyItemThatNeedsNoSession() { let disconnected = makeContext(connected: false) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.connection, context: disconnected) == true) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.history, context: disconnected) == true) + #expect(isEnabled(MainWindowToolbar.connection, disconnected)) + #expect(isEnabled(MainWindowToolbar.history, disconnected) == false) + #expect(isEnabled(MainWindowToolbar.history, makeContext())) + } + + /// The drawer is not mounted in Agent mode, and toggling it there flipped a persisted flag that + /// sprang it open on the way back to browsing. + @Test("History is gated on browsing") + func historyIsGatedOnBrowsing() { + let agent = ToolbarContext( + tabKind: .query, + contentMode: .agent, + pane: .content, + isConnected: true, + hasSelectedWorkspace: true + ) + #expect(isEnabled(MainWindowToolbar.history, agent) == false) } - /// said Switch Connection stays enabled and the runtime disagreed: validation - /// returned false before reaching that case whenever the connection had gone, which is the one - /// state the command exists for. It answers off the window now, ahead of any session context. - /// The sidebar item stays out of it, however window-owned the sidebar itself is: it is the - /// Tables/Favorites segmented control, its action reaches `coordinator?.splitViewController`, - /// and the tab it selects is per-connection state. Marking it window-scoped would enable a - /// control whose clicks go nowhere. - @Test("Switch Connection answers without a connection behind the toolbar") - func connectionItemIsWindowScoped() { - #expect(MainWindowToolbar.isWindowScoped(MainWindowToolbar.connection)) - #expect(!MainWindowToolbar.isWindowScoped(MainWindowToolbar.sidebarToggle)) + /// The toolbar with nothing behind it at all: no window, no coordinator. Switch Connection still + /// answers through the live validation path, because the connection that went away is exactly + /// what a user reaches for it to leave. + @Test("Switch Connection answers with nothing behind the toolbar") + func connectionItemAnswersWithNoSubject() { + let owner = MainWindowToolbar() + #expect(owner.validateToolbarItem(NSToolbarItem(itemIdentifier: MainWindowToolbar.connection))) + #expect(isEnabled(MainWindowToolbar.connection, ToolbarContext())) } /// Everything else here acts on the connection that is showing, so no subject still disables /// it rather than leaving a live-looking button that does nothing. @Test("Every other toolbar item still needs the connection it acts on") - func otherItemsAreNotWindowScoped() { - let connectionScoped = [ - MainWindowToolbar.database, - MainWindowToolbar.refresh, - MainWindowToolbar.newTab, - MainWindowToolbar.exportTables, - MainWindowToolbar.sidebarToggle, - MainWindowToolbar.addRow, - MainWindowToolbar.saveChanges, - MainWindowToolbar.dashboard - ] - for identifier in connectionScoped { - #expect(!MainWindowToolbar.isWindowScoped(identifier)) + func otherItemsNeedASubject() { + let owner = MainWindowToolbar() + let connectionScoped = MainWindowToolbar.allowedItemIdentifiers.filter { + $0 != MainWindowToolbar.connection && !$0.rawValue.hasPrefix("NSToolbar") + } + for identifier in connectionScoped + [MainWindowToolbar.navigateBack, MainWindowToolbar.navigateForward] { + #expect( + !owner.validateToolbarItem(NSToolbarItem(itemIdentifier: identifier)), + "\(identifier.rawValue) answered with no connection behind it" + ) } } - @Test("Unknown identifier defaults to enabled") - func unknownIdentifierEnabled() { - let context = makeContext(connected: false) + /// The old switch ended in `default: return true`, so every identifier nobody had thought about + /// was live, including over a window with no coordinator and no session. + @Test("An unknown identifier does not answer") + func unknownIdentifierIsDisabled() { let unknown = NSToolbarItem.Identifier("com.test.unknown") - #expect(MainWindowToolbar.isEnabled(itemIdentifier: unknown, context: context) == true) + #expect(isEnabled(unknown, makeContext()) == false) + #expect(isEnabled(unknown, makeContext(connected: false)) == false) + } + + /// On macOS 13 the delegate builds its own Inspector item targeting the toolbar, so this arm is + /// what that button draws. It follows whether the pane can be toggled, which is AppKit's own + /// rule on 14 and later: a connection that drops with the pane open can still close it, and a + /// live session is not by itself a reason to open one. + @Test("The inspector toggle answers whether the pane can be toggled, not whether a session is up") + func inspectorFollowsTheTrailingPane() { + let closable = ToolbarContext( + pane: .unavailable(.notConnected), + isConnected: false, + hasSelectedWorkspace: true, + canToggleTrailingPane: true + ) + let stranded = ToolbarContext( + pane: .content, + isConnected: true, + hasSelectedWorkspace: true, + canToggleTrailingPane: false + ) + #expect(isEnabled(MainWindowToolbar.inspector, closable)) + #expect(isEnabled(MainWindowToolbar.inspector, stranded) == false) } /// The health monitor writes `.connecting` on every reconnect attempt while the window keeps @@ -270,11 +311,11 @@ struct MainWindowToolbarValidationTests { func sessionScopedItemsStayEnabledWhileExecuting() { let context = makeContext( connected: MainWindowToolbar.hasLiveSession(.connected), - hasPendingChanges: true, + pendingChange: .data, hasDataPendingChanges: true ) for identifier in sessionScopedIdentifiers { - #expect(MainWindowToolbar.isEnabled(itemIdentifier: identifier, context: context) == true) + #expect(isEnabled(identifier, context), "\(identifier.rawValue)") } } @@ -283,11 +324,11 @@ struct MainWindowToolbarValidationTests { for state: ToolbarConnectionState in [.disconnected, .error("boom")] { let context = makeContext( connected: MainWindowToolbar.hasLiveSession(state), - hasPendingChanges: true, + pendingChange: .data, hasDataPendingChanges: true ) for identifier in sessionScopedIdentifiers { - #expect(MainWindowToolbar.isEnabled(itemIdentifier: identifier, context: context) == false) + #expect(isEnabled(identifier, context) == false, "\(identifier.rawValue)") } } } @@ -349,6 +390,9 @@ struct MainWindowToolbarValidationTests { #expect(cleanSnapshot.hasDataPendingChanges == false) } + /// The overflow menu validates as menu items, through `validateMenuItem`, so it has to reach the + /// same resolver the buttons do. `pendingChange` is what `updateToolbarPendingState()` writes + /// beside `hasPendingChanges`, and it is the one the commit control reads. @Test("Overflow Save and Preview use the pending-change predicates") func overflowPendingActionsValidateAgainstCurrentState() throws { let coordinator = makeCoordinator() @@ -360,15 +404,12 @@ struct MainWindowToolbarValidationTests { coordinator.toolbarState.connectionState = .connected owner.repoint(to: coordinator) - let saveGroup = try #require( + let saveItem = try #require( owner.toolbar( owner.managedToolbar, - itemForItemIdentifier: MainWindowToolbar.refreshSaveGroup, + itemForItemIdentifier: MainWindowToolbar.saveChanges, willBeInsertedIntoToolbar: true - ) as? NSToolbarItemGroup - ) - let saveItem = try #require( - saveGroup.subitems.first { $0.itemIdentifier == MainWindowToolbar.saveChanges } + ) ) let saveMenuItem = try #require(saveItem.menuFormRepresentation) let previewItem = try #require( @@ -380,12 +421,12 @@ struct MainWindowToolbarValidationTests { ) let previewMenuItem = try #require(previewItem.menuFormRepresentation) - coordinator.toolbarState.hasPendingChanges = true + coordinator.toolbarState.pendingChange = .data coordinator.toolbarState.hasDataPendingChanges = true #expect(owner.validateMenuItem(saveMenuItem) == true) #expect(owner.validateMenuItem(previewMenuItem) == true) - coordinator.toolbarState.hasPendingChanges = false + coordinator.toolbarState.pendingChange = nil coordinator.toolbarState.hasDataPendingChanges = false #expect(owner.validateMenuItem(saveMenuItem) == false) #expect(owner.validateMenuItem(previewMenuItem) == false) @@ -467,7 +508,6 @@ struct MainWindowToolbarValidationTests { toolbarState: ConnectionToolbarState() ) } - } @MainActor @@ -551,9 +591,10 @@ struct MainWindowToolbarRepointTests { #expect(provider() == "rectangle.bottomhalf.inset.filled") } - /// The delegate used to answer nil for every identifier when it had no coordinator. With - /// `autosavesConfiguration` on, a vend in that state pruned the user's saved arrangement for - /// good, which this project has already paid for once. + /// The delegate used to answer nil for every identifier when it had no coordinator. Measured on + /// macOS 27 across separate launches, AppKit prunes an identifier from the saved arrangement as + /// soon as the delegate stops vending it, so a vend that answered nil in that state removed the + /// user's placed items for good. @Test("The delegate builds every advertised item with no subject") func delegateNeverAnswersNil() { let owner = MainWindowToolbar() @@ -577,39 +618,28 @@ struct MainWindowToolbarNavigationValidationTests { connected: Bool = true, canNavigateBack: Bool = false, canNavigateForward: Bool = false - ) -> MainWindowToolbar.ValidationContext { - MainWindowToolbar.ValidationContext( - connected: connected, - isTableTab: true, - canAddRow: false, - canRestorePreviousValues: false, - hasPendingChanges: false, - hasDataPendingChanges: false, - blocksAllWrites: false, - fileBased: false, - supportsContainerSwitching: true, - supportsImport: true, - supportsServerDashboard: true, + ) -> ToolbarContext { + ToolbarContext( + tabKind: .table, + pane: connected ? .content : .unavailable(.notConnected), + isConnected: connected, + hasSelectedWorkspace: true, canNavigateBack: canNavigateBack, - canNavigateForward: canNavigateForward + canNavigateForward: canNavigateForward, + supportsContainerSwitching: true ) } @Test("Back is disabled with an empty history rather than hidden") func backDisabledWithoutHistory() { - #expect( - MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.navigateBack, - context: context() - ) == false - ) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.navigateBack, context: context()) == false) } @Test("Back is enabled once the tab has somewhere to go back to") func backEnabledWithHistory() { #expect( - MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.navigateBack, + ToolbarContextResolver.isEnabled( + MainWindowToolbar.navigateBack, context: context(canNavigateBack: true) ) ) @@ -618,36 +648,25 @@ struct MainWindowToolbarNavigationValidationTests { @Test("Back and Forward run out independently") func backAndForwardAreSeparate() { let onlyBack = context(canNavigateBack: true) - #expect(MainWindowToolbar.isEnabled(itemIdentifier: MainWindowToolbar.navigateBack, context: onlyBack)) - #expect( - MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.navigateForward, - context: onlyBack - ) == false - ) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.navigateBack, context: onlyBack)) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.navigateForward, context: onlyBack) == false) } @Test("Neither is offered without a connection") func bothNeedAConnection() { let disconnected = context(connected: false, canNavigateBack: true, canNavigateForward: true) - #expect( - MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.navigateBack, - context: disconnected - ) == false - ) - #expect( - MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.navigateForward, - context: disconnected - ) == false - ) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.navigateBack, context: disconnected) == false) + #expect(ToolbarContextResolver.isEnabled(MainWindowToolbar.navigateForward, context: disconnected) == false) } - @Test("The group is offered by default so it reaches an existing toolbar") - func groupIsADefaultItem() { - #expect(MainWindowToolbar.defaultItemIdentifiers.contains(MainWindowToolbar.backForwardGroup)) + /// Two permanent hit targets for a command only a table tab has, so the pair left the default + /// set. It is still offered by Customize Toolbar, and a user who puts it back gets a control + /// that is always present and dims, since only the default set is ever hidden. + @Test("The group is offered by the palette, not the default set") + func groupIsPaletteOnly() { + #expect(!MainWindowToolbar.defaultItemIdentifiers.contains(MainWindowToolbar.backForwardGroup)) #expect(MainWindowToolbar.allowedItemIdentifiers.contains(MainWindowToolbar.backForwardGroup)) + #expect(!ToolbarContextResolver.hideableIdentifiers.contains(MainWindowToolbar.backForwardGroup)) } } @@ -656,36 +675,30 @@ struct MainWindowToolbarNavigationValidationTests { struct MainWindowToolbarAddRowValidationTests { private func context( connected: Bool, canAddRow: Bool, canRestorePreviousValues: Bool = false - ) -> MainWindowToolbar.ValidationContext { - MainWindowToolbar.ValidationContext( - connected: connected, - isTableTab: true, + ) -> ToolbarContext { + ToolbarContext( + tabKind: .table, + resultsMode: .data, + pane: connected ? .content : .unavailable(.notConnected), + isConnected: connected, + hasSelectedWorkspace: true, canAddRow: canAddRow, - canRestorePreviousValues: canRestorePreviousValues, - hasPendingChanges: false, - hasDataPendingChanges: false, - blocksAllWrites: false, - fileBased: false, - supportsContainerSwitching: true, - supportsImport: true, - supportsServerDashboard: true, - canNavigateBack: false, - canNavigateForward: false + canRestorePreviousValues: canRestorePreviousValues ) } @Test("Add Row needs a live session and a tab that can take a row") func addRowEnablement() { - #expect(MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.addRow, + #expect(ToolbarContextResolver.isEnabled( + MainWindowToolbar.addRow, context: context(connected: true, canAddRow: true) )) - #expect(!MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.addRow, + #expect(!ToolbarContextResolver.isEnabled( + MainWindowToolbar.addRow, context: context(connected: true, canAddRow: false) )) - #expect(!MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.addRow, + #expect(!ToolbarContextResolver.isEnabled( + MainWindowToolbar.addRow, context: context(connected: false, canAddRow: true) )) } @@ -694,16 +707,16 @@ struct MainWindowToolbarAddRowValidationTests { /// what the licence buys. A dimmed item explains nothing. @Test("Restore Previous Values follows the tab, not the licence") func restorePreviousValuesValidation() { - #expect(MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.restorePreviousValues, + #expect(ToolbarContextResolver.isEnabled( + MainWindowToolbar.restorePreviousValues, context: context(connected: true, canAddRow: false, canRestorePreviousValues: true) )) - #expect(!MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.restorePreviousValues, + #expect(!ToolbarContextResolver.isEnabled( + MainWindowToolbar.restorePreviousValues, context: context(connected: true, canAddRow: true, canRestorePreviousValues: false) )) - #expect(!MainWindowToolbar.isEnabled( - itemIdentifier: MainWindowToolbar.restorePreviousValues, + #expect(!ToolbarContextResolver.isEnabled( + MainWindowToolbar.restorePreviousValues, context: context(connected: false, canAddRow: true, canRestorePreviousValues: true) )) } diff --git a/TableProTests/Services/ToolbarHiddenSetTests.swift b/TableProTests/Services/ToolbarHiddenSetTests.swift new file mode 100644 index 000000000..284dac617 --- /dev/null +++ b/TableProTests/Services/ToolbarHiddenSetTests.swift @@ -0,0 +1,273 @@ +// +// ToolbarHiddenSetTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +@MainActor +private final class CountingToolbar: NSToolbar { + private(set) var validationCount = 0 + + override func validateVisibleItems() { + validationCount += 1 + } +} + +/// The context is written onto a live toolbar through `isHidden`, onto the items the app placed and +/// onto nothing else. These run against a real `NSToolbar`, because the rules they pin are about +/// what AppKit does with the writes, which no pure test can see. +@Suite("Toolbar hidden set") +@MainActor +struct ToolbarHiddenSetTests { + /// The app's own items in the order the default set gives them, without the spaces and tracking + /// separators, which need a window's split view to mean anything. + private static let placed: [NSToolbarItem.Identifier] = [ + MainWindowToolbar.connection, + MainWindowToolbar.database, + MainWindowToolbar.refresh, + MainWindowToolbar.saveChanges, + MainWindowToolbar.actions, + MainWindowToolbar.safeMode, + ] + + /// A private identifier with autosave off, so no arrangement written here reaches the app's own + /// defaults, which the test host shares. + private func makeOwner() -> (owner: MainWindowToolbar, toolbar: CountingToolbar) { + let toolbar = CountingToolbar(identifier: "com.TablePro.tests.hidden.\(UUID().uuidString)") + let owner = MainWindowToolbar(managedToolbar: toolbar) + toolbar.autosavesConfiguration = false + for (index, identifier) in Self.placed.enumerated() { + toolbar.insertItem(withItemIdentifier: identifier, at: index) + } + return (owner, toolbar) + } + + private func item(_ identifier: NSToolbarItem.Identifier, in toolbar: NSToolbar) -> NSToolbarItem? { + toolbar.items.first { $0.itemIdentifier == identifier } + } + + /// Lets what a case's setup asked for arrive before the case starts counting: the pass the + /// toolbar's own inserts asked for, and the observer callbacks a repoint queued. + /// + /// Wall-clock time, not main-actor hops. Measured in the test host, an insert's announcement + /// reaches the delegate only once the run loop turns, and five main-actor hops after building + /// the toolbar had not yet run the pass its six inserts asked for. + private func settle() async { + try? await Task.sleep(for: .milliseconds(250)) + } + + /// Waits until the condition holds, bounded at two seconds. A sleep hands the main thread back + /// to the run loop, which is what delivers both an insert's announcement and every observer + /// that receives on `RunLoop.main`. A chain of main-actor hops can drain inside one pass of the + /// main queue without the run loop ever turning, and a spun run loop runs no main-actor job at + /// all, because the test is itself one. + private func waitForRunLoop(until condition: () -> Bool) async -> Bool { + for _ in 0..<200 { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() + } + + private func makeCoordinator() -> MainContentCoordinator { + MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "db_a"), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + } + + @available(macOS 15.0, *) + @Test("Applying a context hides exactly its set and validates once") + func applyWritesTheResolversSet() throws { + let (owner, toolbar) = makeOwner() + let context = ToolbarContext(tabKind: .erDiagram, isFileBased: true, supportsContainerSwitching: false) + let visibility = ToolbarContextResolver.visibility(for: context.visibilityKey) + #expect(visibility.hidden == [MainWindowToolbar.database, MainWindowToolbar.saveChanges]) + + let baseline = toolbar.validationCount + owner.apply(visibility) + + #expect(toolbar.validationCount == baseline + 1) + #expect(owner.visibility == visibility) + for identifier in Self.placed { + let placedItem = try #require(item(identifier, in: toolbar)) + #expect(placedItem.isHidden == visibility.hides(identifier), "\(identifier.rawValue)") + } + } + + /// Measured on macOS 27 with a palette item dragged between two of the app's own: 200 passes + /// that hid the items around it left it at its index, visible, with its frame unchanged. The + /// filter lives in the loop, so it holds even for a record that names the user's item. + @available(macOS 15.0, *) + @Test("An item added from the palette keeps its place and stays visible through a pass") + func paletteItemSurvivesAPass() throws { + let (owner, toolbar) = makeOwner() + toolbar.insertItem(withItemIdentifier: MainWindowToolbar.previewSQL, at: 3) + let before = toolbar.items.map(\.itemIdentifier) + + owner.apply(ToolbarVisibility(hidden: [MainWindowToolbar.previewSQL, MainWindowToolbar.saveChanges])) + + let preview = try #require(item(MainWindowToolbar.previewSQL, in: toolbar)) + #expect(preview.isHidden == false) + #expect(toolbar.items.map(\.itemIdentifier) == before) + #expect(toolbar.items.firstIndex { $0.itemIdentifier == MainWindowToolbar.previewSQL } == 3) + #expect(item(MainWindowToolbar.saveChanges, in: toolbar)?.isHidden == true) + } + + /// Never written, not merely written false: an item that arrived hidden from anywhere else + /// keeps whatever it had, because a pass has no business with an item the app did not place. + @available(macOS 15.0, *) + @Test("A pass leaves an item the user added untouched") + func paletteItemIsNeverWritten() throws { + let (owner, toolbar) = makeOwner() + toolbar.insertItem(withItemIdentifier: MainWindowToolbar.history, at: 2) + let history = try #require(item(MainWindowToolbar.history, in: toolbar)) + history.isHidden = true + + owner.apply(ToolbarVisibility()) + + #expect(history.isHidden == true) + #expect(item(MainWindowToolbar.database, in: toolbar)?.isHidden == false) + } + + /// Measured on macOS 27: with an instance cached across vends, an item the user dragged back in + /// from the palette was that same instance, arrived with `isHidden` still true, took its slot + /// and drew nothing. + @Test("Every hideable item is built fresh on each vend") + func hideableItemsAreNeverCached() { + let owner = MainWindowToolbar() + let owned = ToolbarContextResolver.hideableIdentifiers.filter { !$0.rawValue.hasPrefix("NSToolbar") } + + #expect(!owned.isEmpty) + for identifier in owned { + let vends = [true, false].map { flag in + owner.toolbar(owner.managedToolbar, itemForItemIdentifier: identifier, willBeInsertedIntoToolbar: flag) + } + /// Both, before the identity comparison: a second vend of nil is also "not the same + /// instance", and would pass a delegate that stopped building the item at all. + guard let first = vends[0], let second = vends[1] else { + Issue.record("\(identifier.rawValue) vended nil: \(vends.map { $0 == nil })") + continue + } + #expect(first !== second, "\(identifier.rawValue) handed back a cached instance") + } + } + + /// The item a drop announces is not in `NSToolbar.items` until the announcement returns, so the + /// pass that picks it up runs on a later turn. Until then the fresh item shows, which is the + /// safe direction to be wrong in for a moment. + @available(macOS 15.0, *) + @Test("An item put back from the palette takes the context on the next turn") + func droppedItemTakesTheContextNextTurn() async throws { + let (owner, toolbar) = makeOwner() + await settle() + owner.apply(ToolbarVisibility(hidden: [MainWindowToolbar.refresh])) + let index = try #require(toolbar.items.firstIndex { $0.itemIdentifier == MainWindowToolbar.refresh }) + toolbar.removeItem(at: index) + + toolbar.insertItem(withItemIdentifier: MainWindowToolbar.refresh, at: index) + let dropped = try #require(item(MainWindowToolbar.refresh, in: toolbar)) + #expect(dropped.isHidden == false, "A fresh item arrives visible, and the pass is not run in the notification") + + #expect(await waitForRunLoop { dropped.isHidden }) + } + + /// A Customize Toolbar drop of several items announces each one, and each announcement asks for + /// a pass. They coalesce into one, because every pass validates every visible item. + /// + /// The pass the toolbar's own inserts asked for is let through first, and the count is read + /// again after a quiet interval rather than the moment it first moves, so a second pass that + /// arrived late would still be counted. + /// + /// Every wait reads `NSToolbar.items`. Measured in the test host, a toolbar with no window + /// announces an insert to its delegate only once its items are next read: a wait on the pass + /// count alone saw no pass for two seconds, and the pass followed the first read of the items. + /// A window reads them on its next layout, so in the app the pass follows the drop; here the + /// waits stand in for that layout. + @available(macOS 15.0, *) + @Test("Two items put back in one turn cost exactly one pass") + func aBurstOfDropsIsOnePass() async throws { + let (owner, toolbar) = makeOwner() + #expect( + await waitForRunLoop { toolbar.items.count == Self.placed.count && toolbar.validationCount > 0 }, + "Building the toolbar asked for no pass" + ) + await settle() + owner.apply(ToolbarVisibility(hidden: [MainWindowToolbar.refresh, MainWindowToolbar.saveChanges])) + for identifier in [MainWindowToolbar.saveChanges, MainWindowToolbar.refresh] { + let index = try #require(toolbar.items.firstIndex { $0.itemIdentifier == identifier }) + toolbar.removeItem(at: index) + } + await settle() + + let beforeDrops = toolbar.validationCount + toolbar.insertItem(withItemIdentifier: MainWindowToolbar.refresh, at: 2) + toolbar.insertItem(withItemIdentifier: MainWindowToolbar.saveChanges, at: 3) + let afterDrops = toolbar.validationCount + #expect(afterDrops == beforeDrops, "The pass must not run inside the announcement") + + #expect( + await waitForRunLoop { toolbar.items.count == Self.placed.count && toolbar.validationCount > afterDrops }, + "No pass ran for the drops at all" + ) + await settle() + #expect(toolbar.validationCount == afterDrops + 1) + #expect(item(MainWindowToolbar.refresh, in: toolbar)?.isHidden == true) + #expect(item(MainWindowToolbar.saveChanges, in: toolbar)?.isHidden == true) + } + + // MARK: - The commit verb on a live item + + /// A Create Table draft raises and drops its staged change as it becomes valid and invalid, so a + /// label that followed it flipped while the user typed and, with labels shown, reflowed the + /// titlebar. The observer running is proven by the pass it asks for, so the unchanged label is + /// not the absence of a callback. + @Test("Staging a change under a live commit control leaves its label alone") + func stagingLeavesTheLabelAlone() async throws { + let (owner, toolbar) = makeOwner() + let coordinator = makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addTab() + owner.repoint(to: coordinator) + await settle() + let commit = try #require(item(MainWindowToolbar.saveChanges, in: toolbar)) + #expect(commit.label == String(localized: "Save Changes")) + + for staged in [PendingChangeKind.createTable, .principals, nil] { + let before = toolbar.validationCount + coordinator.toolbarState.pendingChange = staged + #expect( + await waitForRunLoop { toolbar.validationCount > before }, + "The toolbar-state observer never ran, so the label check below proves nothing" + ) + #expect(commit.label == String(localized: "Save Changes"), "\(String(describing: staged))") + #expect(commit.menuFormRepresentation?.title == String(localized: "Save Changes")) + } + } + + /// The verb is the tab's, so it moves when the tab does, on the same observer that moves the + /// item set, and reaches the overflow entry as well as the label. + @Test("Switching to another kind of tab relabels the live commit control") + func tabSwitchRelabelsTheCommitControl() async throws { + let (owner, toolbar) = makeOwner() + let coordinator = makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addTab() + owner.repoint(to: coordinator) + let commit = try #require(item(MainWindowToolbar.saveChanges, in: toolbar)) + #expect(commit.label == String(localized: "Save Changes")) + + coordinator.tabManager.addCreateTableTab() + #expect(await waitForRunLoop { commit.label == String(localized: "Create Table") }) + #expect(commit.menuFormRepresentation?.title == String(localized: "Create Table")) + + coordinator.tabManager.selectTab(at: 0) + #expect(await waitForRunLoop { commit.label == String(localized: "Save Changes") }) + } +} diff --git a/TableProTests/Services/ToolbarSourceAccessTests.swift b/TableProTests/Services/ToolbarSourceAccessTests.swift new file mode 100644 index 000000000..31a359fc9 --- /dev/null +++ b/TableProTests/Services/ToolbarSourceAccessTests.swift @@ -0,0 +1,241 @@ +// +// ToolbarSourceAccessTests.swift +// TableProTests +// + +import Foundation +import Testing + +/// Nothing in the app may act on what AppKit says is on screen in a toolbar. +/// +/// Measured on macOS 27: one visit to Customize Toolbar leaves `NSToolbar.visibleItems` and +/// `NSToolbarItem.isVisible` over-reporting for the life of the item instance, listing items that +/// are hidden or clipped. The error always runs toward "on screen", and acting on it is what hands +/// `NSPopover` an anchor with no window, an `NSInvalidArgumentException` Swift cannot catch. The +/// toolbar keeps its own record instead, `ToolbarVisibility`, and nothing enforces reading that +/// record over AppKit's but this scan, the same shape `SyncMapperFieldAccessTests` uses to keep raw +/// `record["` out of the sync mappers. +/// +/// Two scans, because the two properties are not equally ambiguous. `visibleItems` means one thing +/// anywhere, so the whole app is scanned for it. `isVisible` is also a window's, a panel's and a +/// dozen view models', so across the app it is flagged only on a receiver named for a toolbar item; +/// that heuristic misses `$0.isVisible`, `\.isVisible` and `items[i].isVisible`, which are exactly +/// how a toolbar's items get filtered. So the files that handle toolbar items are scanned for every +/// `isVisible` whatever its receiver, and the one read allowed there is named: the switcher's +/// `toolbar.isVisible`, which is whether the toolbar itself is shown and was measured to read +/// correctly after a palette visit. +@Suite("Toolbar visibility reads") +struct ToolbarSourceAccessTests { + private static let rootDirectory: URL = { + var directory = URL(fileURLWithPath: #filePath) + for _ in 0..<3 { directory.deleteLastPathComponent() } + return directory + }() + + private static let appDirectory: URL? = { + let app = rootDirectory.appendingPathComponent("TablePro") + return FileManager.default.fileExists(atPath: app.path) ? app : nil + }() + + /// Every file that builds, reads or presents from a toolbar item. The directories are scanned + /// whole, so a file added to one of them is covered the day it is added. + private static let toolbarDirectories = [ + "TablePro/Core/Services/Infrastructure/Toolbar", + "TablePro/Views/Toolbar", + ] + + private static let toolbarFiles = [ + "TablePro/Views/Components/PopoverPresenter.swift", + "TablePro/Views/Compare/CompareEndpointToolbarController.swift", + ] + + /// Every `MainWindowToolbar` source, by prefix, beside the class itself. + private static let toolbarFilePrefix = "TablePro/Core/Services/Infrastructure/MainWindowToolbar" + + /// The one read the toolbar scan allows, and where. Named by file and by the exact expression, + /// so a second `toolbar.isVisible` anywhere else is still flagged. + private static let allowedRead = ( + path: "TablePro/Views/Toolbar/ToolbarSwitcherPresenter.swift", + expression: "toolbar.isVisible" + ) + + private static let isVisibleRead = try? NSRegularExpression( + pattern: #"([A-Za-z_][A-Za-z0-9_]*)\s*[?!]?\s*\.isVisible\b"# + ) + + private static let anyVisibilityRead = try? NSRegularExpression( + pattern: #"\.(isVisible|visibleItems)\b"# + ) + + private static func sources() throws -> [(path: String, text: String)] { + guard let appDirectory, + let enumerator = FileManager.default.enumerator(at: appDirectory, includingPropertiesForKeys: nil) + else { return [] } + return try enumerator + .compactMap { $0 as? URL } + .filter { $0.pathExtension == "swift" } + .map { url in + let path = url.path.replacingOccurrences(of: appDirectory.path, with: "TablePro") + return (path, try String(contentsOf: url, encoding: .utf8)) + } + } + + static func isToolbarSource(_ path: String) -> Bool { + path.hasPrefix(toolbarFilePrefix) + || toolbarFiles.contains(path) + || toolbarDirectories.contains { path.hasPrefix($0 + "/") } + } + + private static func toolbarSources() throws -> [(path: String, text: String)] { + try sources().filter { isToolbarSource($0.path) } + } + + /// The code on a line, with any comment dropped. The rule is written about in the comments that + /// explain it, so a doc comment naming `visibleItems` is not a read of it. + private static func code(of line: String) -> String { + guard let comment = line.range(of: "//") else { return line } + return String(line[.. Bool { + let code = code(of: line) + if code.contains(".visibleItems") { return true } + guard let isVisibleRead else { return false } + let range = NSRange(code.startIndex..., in: code) + return isVisibleRead.matches(in: code, range: range).contains { match in + guard let receiverRange = Range(match.range(at: 1), in: code) else { return false } + let receiver = code[receiverRange].lowercased() + return receiver.hasSuffix("item") || receiver.hasSuffix("items") + } + } + + /// The toolbar-file rule: every `isVisible` and `visibleItems` in code, whatever the receiver, + /// key paths included. + static func readsAnyVisibility(_ line: String) -> Bool { + let code = code(of: line) + guard let anyVisibilityRead else { return false } + return anyVisibilityRead.firstMatch(in: code, range: NSRange(code.startIndex..., in: code)) != nil + } + + /// Whether a line's only visibility read is the allowed one. A line that also reads something + /// else is not excused by carrying it. + static func isAllowedRead(_ line: String, path: String) -> Bool { + guard path == allowedRead.path else { return false } + let remainder = code(of: line).replacingOccurrences(of: allowedRead.expression, with: "") + return code(of: line).contains(allowedRead.expression) && !readsAnyVisibility(remainder) + } + + private static func hits( + in sources: [(path: String, text: String)], + where reads: (String) -> Bool + ) -> [(path: String, line: Int, code: String)] { + sources.flatMap { source in + source.text + .components(separatedBy: .newlines) + .enumerated() + .filter { reads($0.element) } + .map { (source.path, $0.offset + 1, $0.element.trimmingCharacters(in: .whitespaces)) } + } + } + + // MARK: - Reach + + @Test("The app scan reaches the app's sources") + func sourcesAreReachable() throws { + let sources = try Self.sources() + #expect(sources.count > 100, "Found \(sources.count) sources; the guard below would pass vacuously") + } + + /// A moved or renamed file would otherwise drop out of the toolbar scan and leave it green. + @Test("The toolbar scan reaches every file it names, and the allowed read is among them") + func toolbarScanReachesItsFiles() throws { + let paths = Set(try Self.toolbarSources().map(\.path)) + + #expect(paths.count >= 10, "Only \(paths.count) toolbar sources scanned: \(paths.sorted())") + for file in Self.toolbarFiles + [Self.allowedRead.path] { + #expect(paths.contains(file), "\(file) is not being scanned") + } + for directory in Self.toolbarDirectories { + #expect(paths.contains { $0.hasPrefix(directory + "/") }, "Nothing scanned under \(directory)") + } + #expect(paths.contains(Self.toolbarFilePrefix + ".swift")) + #expect(paths.filter { $0.hasPrefix(Self.toolbarFilePrefix) }.count >= 5) + } + + // MARK: - Matchers + + @Test("The app-wide matcher tells a toolbar item's visibility from the toolbar's own") + func matcherKeysOnTheReceiver() { + #expect(Self.readsToolbarVisibility("let shown = toolbar.visibleItems ?? []")) + #expect(Self.readsToolbarVisibility("guard item.isVisible else { return }")) + #expect(Self.readsToolbarVisibility("if toolbarItem?.isVisible == true {")) + #expect(Self.readsToolbarVisibility("let on = subitem.isVisible && group.isHidden")) + + #expect(!Self.readsToolbarVisibility("guard let toolbar = window?.toolbar, toolbar.isVisible else {")) + #expect(!Self.readsToolbarVisibility("toolbarVisible: window.toolbar?.isVisible ?? false")) + #expect(!Self.readsToolbarVisibility("mentionState.isVisible = true")) + #expect(!Self.readsToolbarVisibility("/// `NSToolbar.visibleItems` over-reports after a palette visit.")) + } + + /// The forms the receiver heuristic cannot see, which is why the toolbar files take this one. + @Test("The toolbar matcher flags every read, whatever its receiver") + func toolbarMatcherFlagsEveryRead() { + #expect(Self.readsAnyVisibility("let shown = toolbar.items.filter { $0.isVisible }")) + #expect(Self.readsAnyVisibility("let shown = toolbar.items.filter(\\.isVisible)")) + #expect(Self.readsAnyVisibility("if toolbar.items[i].isVisible {")) + #expect(Self.readsAnyVisibility("let on = toolbar.items.first { $0.itemIdentifier == id }?.isVisible")) + #expect(Self.readsAnyVisibility("guard anchor.isVisible else { return nil }")) + #expect(Self.readsAnyVisibility("let shown = toolbar.visibleItems ?? []")) + + #expect(!Self.readsAnyVisibility("/// Reading `item.isVisible` after a palette visit over-reports.")) + #expect(!Self.readsAnyVisibility("let hidden = visibility.hides(id) // not `.isVisible`")) + #expect(!Self.readsAnyVisibility("item.isHidden = visibility.hides(item.itemIdentifier)")) + #expect(!Self.readsAnyVisibility("let isVisibleNow = true")) + } + + @Test("Only the switcher's own toolbar read is allowed, and only on its own") + func allowanceIsExact() { + let path = Self.allowedRead.path + #expect(Self.isAllowedRead("guard let toolbar = window?.toolbar, toolbar.isVisible else { return nil }", path: path)) + #expect(!Self.isAllowedRead("guard toolbar.isVisible, item.isVisible else { return nil }", path: path)) + #expect(!Self.isAllowedRead("guard toolbar.isVisible else { return nil }", path: "TablePro/Views/Toolbar/Other.swift")) + #expect(!Self.isAllowedRead("guard anchor.isVisible else { return nil }", path: path)) + } + + // MARK: - Scans + + @Test("Nothing in the app reads NSToolbar.visibleItems or an item's isVisible") + func noVisibilityReads() throws { + let offenders = Self.hits(in: try Self.sources(), where: Self.readsToolbarVisibility) + .filter { !Self.isAllowedRead($0.code, path: $0.path) } + .map { "\($0.path):\($0.line): \($0.code)" } + + #expect(offenders.isEmpty, """ + These read AppKit's report of which toolbar items are on screen, which one Customize Toolbar \ + visit leaves over-reporting for good. Ask the toolbar's own record, \ + `MainWindowToolbar.visibility`, whether an item is hidden, and `NSToolbar.items` which \ + instance it is. + \(offenders.joined(separator: "\n")) + """) + } + + @Test("The toolbar files read no visibility at all, beyond whether the toolbar is shown") + func toolbarFilesReadNoVisibility() throws { + let hits = Self.hits(in: try Self.toolbarSources(), where: Self.readsAnyVisibility) + let allowed = hits.filter { Self.isAllowedRead($0.code, path: $0.path) } + let offenders = hits + .filter { !Self.isAllowedRead($0.code, path: $0.path) } + .map { "\($0.path):\($0.line): \($0.code)" } + + #expect(allowed.count == 1, """ + The switcher's `toolbar.isVisible` read is the one allowance, and it should be there exactly \ + once. Found \(allowed.count): \(allowed.map { "\($0.path):\($0.line)" }) + """) + #expect(offenders.isEmpty, """ + A toolbar file reads `isVisible` or `visibleItems`, which one Customize Toolbar visit leaves \ + over-reporting. Ask `MainWindowToolbar.visibility` whether an item is hidden. + \(offenders.joined(separator: "\n")) + """) + } +} diff --git a/TableProTests/Services/ToolbarSwitcherAnchorTests.swift b/TableProTests/Services/ToolbarSwitcherAnchorTests.swift index 3de0e725b..f96bf3e98 100644 --- a/TableProTests/Services/ToolbarSwitcherAnchorTests.swift +++ b/TableProTests/Services/ToolbarSwitcherAnchorTests.swift @@ -12,24 +12,21 @@ import Testing /// when it is not. Getting that decision wrong is not a layout glitch: `NSPopover.show(relativeTo:)` /// throws `NSInvalidArgumentException` when it cannot locate the item, and Swift cannot catch it, /// so this is the guard that keeps a missing anchor from being a crash. +/// +/// The decision reads the app's own record of what it hid and `NSToolbar.items`, and nothing +/// AppKit reports about visibility, because one Customize Toolbar visit is measured to leave +/// `visibleItems` and `NSToolbarItem.isVisible` over-reporting for good. @Suite("ToolbarSwitcherPresenter anchor resolution") @MainActor struct ToolbarSwitcherAnchorTests { private static let identifier = NSToolbarItem.Identifier("com.TablePro.tests.anchor") + private static let sibling = NSToolbarItem.Identifier("com.TablePro.tests.anchor.sibling") private final class Delegate: NSObject, NSToolbarDelegate { var identifiers: [NSToolbarItem.Identifier] - let groupIdentifier: NSToolbarItem.Identifier - let subitemIdentifiers: [NSToolbarItem.Identifier] - - init( - identifiers: [NSToolbarItem.Identifier], - groupIdentifier: NSToolbarItem.Identifier, - subitemIdentifiers: [NSToolbarItem.Identifier] - ) { + + init(identifiers: [NSToolbarItem.Identifier]) { self.identifiers = identifiers - self.groupIdentifier = groupIdentifier - self.subitemIdentifiers = subitemIdentifiers } func toolbar( @@ -37,12 +34,7 @@ struct ToolbarSwitcherAnchorTests { itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool ) -> NSToolbarItem? { - guard itemIdentifier == groupIdentifier else { - return NSToolbarItem(itemIdentifier: itemIdentifier) - } - let group = NSToolbarItemGroup(itemIdentifier: itemIdentifier) - group.subitems = subitemIdentifiers.map { NSToolbarItem(itemIdentifier: $0) } - return group + NSToolbarItem(itemIdentifier: itemIdentifier) } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { @@ -57,18 +49,17 @@ struct ToolbarSwitcherAnchorTests { /// Returned so the caller can hold it with `withExtendedLifetime`: `NSToolbar` keeps its /// delegate weakly, and a deallocated one leaves a toolbar with no items, which would make every /// case here "pass" for the wrong reason. - private func makeWindow(containing identifiers: [NSToolbarItem.Identifier]) -> (NSWindow, Delegate) { + private func makeWindow( + containing identifiers: [NSToolbarItem.Identifier], + width: CGFloat = 800 + ) -> (NSWindow, Delegate) { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + contentRect: NSRect(x: 0, y: 0, width: width, height: 400), styleMask: [.titled], backing: .buffered, defer: true ) - let delegate = Delegate( - identifiers: identifiers, - groupIdentifier: Self.groupIdentifier, - subitemIdentifiers: [Self.leadingIdentifier, Self.trailingIdentifier] - ) + let delegate = Delegate(identifiers: identifiers) let toolbar = NSToolbar(identifier: "com.TablePro.tests.toolbar") toolbar.delegate = delegate window.toolbar = toolbar @@ -83,19 +74,18 @@ struct ToolbarSwitcherAnchorTests { func resolvesItemInVisibleToolbar() { let (window, delegate) = makeWindow(containing: [Self.identifier]) withExtendedLifetime(delegate) { - let item = ToolbarSwitcherPresenter.anchor(in: window, Self.identifier) + let item = ToolbarSwitcherPresenter.anchor(in: window, Self.identifier, hiddenBy: nil) #expect(item?.itemIdentifier == Self.identifier) } } /// What Customize Toolbar leaves behind. A clipped item is a different state and keeps its - /// place in `toolbar.items`, so it still resolves and still takes the popover branch; that one - /// needs a real overflowing toolbar and so is not reachable from a unit test. + /// place in `toolbar.items`, so it still resolves and still takes the popover branch. @Test("An item the toolbar does not carry has no anchor") func missingItemHasNoAnchor() { let (window, delegate) = makeWindow(containing: []) withExtendedLifetime(delegate) { - #expect(ToolbarSwitcherPresenter.anchor(in: window, Self.identifier) == nil) + #expect(ToolbarSwitcherPresenter.anchor(in: window, Self.identifier, hiddenBy: nil) == nil) } } @@ -109,7 +99,7 @@ struct ToolbarSwitcherAnchorTests { window.toolbar?.isVisible = false #expect(window.toolbar?.items.contains { $0.itemIdentifier == Self.identifier } == true) - #expect(ToolbarSwitcherPresenter.anchor(in: window, Self.identifier) == nil) + #expect(ToolbarSwitcherPresenter.anchor(in: window, Self.identifier, hiddenBy: nil) == nil) } } @@ -122,82 +112,63 @@ struct ToolbarSwitcherAnchorTests { defer: true ) - #expect(ToolbarSwitcherPresenter.anchor(in: window, Self.identifier) == nil) + #expect(ToolbarSwitcherPresenter.anchor(in: window, Self.identifier, hiddenBy: nil) == nil) } @Test("No window has no anchor") func noWindowHasNoAnchor() { - #expect(ToolbarSwitcherPresenter.anchor(in: nil, Self.identifier) == nil) + #expect(ToolbarSwitcherPresenter.anchor(in: nil, Self.identifier, hiddenBy: nil) == nil) } - // MARK: - Group subitems - - private static let groupIdentifier = NSToolbarItem.Identifier("com.TablePro.tests.anchor.group") - private static let leadingIdentifier = NSToolbarItem.Identifier("com.TablePro.tests.anchor.leading") - private static let trailingIdentifier = NSToolbarItem.Identifier("com.TablePro.tests.anchor.trailing") - - private func makeGroup() -> NSToolbarItemGroup { - let group = NSToolbarItemGroup(itemIdentifier: Self.groupIdentifier) - group.subitems = [ - NSToolbarItem(itemIdentifier: Self.leadingIdentifier), - NSToolbarItem(itemIdentifier: Self.trailingIdentifier), - ] - return group - } - - /// The centred pair are subitems of one group, and the group is two capsules wide. Anchoring - /// both choosers to the group put each of them on the seam between the capsules: measured on a - /// 1200pt window, the group's midpoint is 600.0 while the two capsules sit at 543.2 and 671.8. - @Test("A subitem of a visible group is the anchor, not the group") - func resolvesSubitemOfVisibleGroup() { - let group = makeGroup() - - let anchor = ToolbarSwitcherPresenter.anchor(Self.trailingIdentifier, in: [group], visible: [group]) - - #expect(anchor?.itemIdentifier == Self.trailingIdentifier) - } - - /// A subitem of a clipped group has no view, and `NSPopover.show(relativeTo:)` raises - /// `NSInvalidArgumentException` for one, which Swift cannot catch. The group still resolves, - /// because AppKit presents a clipped item from another affordance in the window itself. - @Test("A subitem of an overflowed group falls back to the group") - func fallsBackToOverflowedGroup() { - let group = makeGroup() - - let anchor = ToolbarSwitcherPresenter.anchor(Self.leadingIdentifier, in: [group], visible: []) + /// An item the context took out of the titlebar is still in `toolbar.items`, because hiding is + /// how the context is expressed. A popover anchored on it lands at the window's centre, measured, + /// attached to nothing, so the record sends the chooser to the floating panel instead. + @Test("An item the context hid has no anchor, although the toolbar still carries it") + func hiddenItemHasNoAnchor() { + let (window, delegate) = makeWindow(containing: [Self.identifier, Self.sibling]) + withExtendedLifetime(delegate) { + let visibility = ToolbarVisibility(hidden: [Self.identifier]) - #expect(anchor?.itemIdentifier == Self.groupIdentifier) + #expect(window.toolbar?.items.contains { $0.itemIdentifier == Self.identifier } == true) + #expect(ToolbarSwitcherPresenter.anchor(in: window, Self.identifier, hiddenBy: visibility) == nil) + } } - /// What Customize Toolbar leaves behind for the centred pair: neither subitem is an allowed - /// identifier of its own, so removing the group takes both choosers' anchors with it. - @Test("A subitem of a group the toolbar does not carry has no anchor") - func missingGroupHasNoAnchor() { - #expect(ToolbarSwitcherPresenter.anchor(Self.leadingIdentifier, in: [], visible: []) == nil) + /// Hiding one of the centred pair is the file-based case, and the other has to keep its anchor. + @Test("An item the context did not hide still anchors beside one it did") + func siblingOfAHiddenItemAnchors() { + let (window, delegate) = makeWindow(containing: [Self.identifier, Self.sibling]) + withExtendedLifetime(delegate) { + let visibility = ToolbarVisibility(hidden: [Self.sibling]) + let anchor = ToolbarSwitcherPresenter.anchor(in: window, Self.identifier, hiddenBy: visibility) + #expect(anchor?.itemIdentifier == Self.identifier) + } } - /// The toolbar's own item wins without consulting the visible list, which is what keeps a - /// clipped top-level item resolving. - @Test("A top-level item resolves even when it is not visible") - func resolvesOverflowedTopLevelItem() { - let item = NSToolbarItem(itemIdentifier: Self.identifier) - - let anchor = ToolbarSwitcherPresenter.anchor(Self.identifier, in: [item], visible: []) - - #expect(anchor?.itemIdentifier == Self.identifier) + /// Nil is a toolbar with no context resolver, which hides nothing, so everything the toolbar + /// carries resolves. + @Test("With no record, everything the toolbar carries resolves") + func noRecordHidesNothing() { + let (window, delegate) = makeWindow(containing: [Self.identifier, Self.sibling]) + withExtendedLifetime(delegate) { + for identifier in [Self.identifier, Self.sibling] { + let anchor = ToolbarSwitcherPresenter.anchor(in: window, identifier, hiddenBy: nil) + #expect(anchor?.itemIdentifier == identifier) + } + } } - /// The whole path both switchers take: an identifier that names no item of the toolbar still - /// reaches the capsule it belongs to. - @Test("The window lookup resolves a subitem of the toolbar's group") - func windowLookupResolvesSubitem() { - let (window, delegate) = makeWindow(containing: [Self.groupIdentifier]) + /// A clipped top-level item still resolves, because the answer comes from `toolbar.items` + /// rather than from anything that reports what is laid out. AppKit anchors the popover on the + /// clipped-items indicator itself, measured on macOS 27 with no raise in any state. + @Test("An item resolves whether or not the window has room for it") + func resolvesWhateverTheWidth() { + let identifiers = (0..<12).map { NSToolbarItem.Identifier("com.TablePro.tests.anchor.\($0)") } + let (window, delegate) = makeWindow(containing: identifiers, width: 120) withExtendedLifetime(delegate) { - #expect(window.toolbar?.items.contains { $0.itemIdentifier == Self.leadingIdentifier } == false) - - let anchor = ToolbarSwitcherPresenter.anchor(in: window, Self.leadingIdentifier) - - #expect(anchor?.itemIdentifier == Self.leadingIdentifier) + let last = identifiers[identifiers.count - 1] + let anchor = ToolbarSwitcherPresenter.anchor(in: window, last, hiddenBy: ToolbarVisibility()) + #expect(anchor?.itemIdentifier == last) } } } diff --git a/TableProTests/Services/WindowTitleResolverWindowTests.swift b/TableProTests/Services/WindowTitleResolverWindowTests.swift index 6a4e800ce..e8419eca5 100644 --- a/TableProTests/Services/WindowTitleResolverWindowTests.swift +++ b/TableProTests/Services/WindowTitleResolverWindowTests.swift @@ -43,6 +43,8 @@ struct WindowTitleResolverWindowTests { for pane in Self.nonContentPanes { let resolved = WindowTitleResolver.resolveWindow( pane: pane, + contentMode: .browse, + agentSessionTitle: nil, connection: connection, tab: nil, hasTabs: false, @@ -61,6 +63,8 @@ struct WindowTitleResolverWindowTests { func connectingIgnoresRestoredTabs() { let resolved = WindowTitleResolver.resolveWindow( pane: .connecting, + contentMode: .browse, + agentSessionTitle: nil, connection: Self.connection(), tab: nil, hasTabs: true, @@ -75,6 +79,8 @@ struct WindowTitleResolverWindowTests { func emptyContentWindowHasNoSubtitle() { let resolved = WindowTitleResolver.resolveWindow( pane: .content, + contentMode: .browse, + agentSessionTitle: nil, connection: Self.connection(), tab: nil, hasTabs: false, @@ -92,6 +98,8 @@ struct WindowTitleResolverWindowTests { for pane in Self.nonContentPanes + [.content] { let resolved = WindowTitleResolver.resolveWindow( pane: pane, + contentMode: .browse, + agentSessionTitle: nil, connection: connection, tab: nil, hasTabs: false, @@ -106,6 +114,8 @@ struct WindowTitleResolverWindowTests { func blankConnectionNameFallsBack() { let resolved = WindowTitleResolver.resolveWindow( pane: .connecting, + contentMode: .browse, + agentSessionTitle: nil, connection: Self.connection(name: " "), tab: nil, hasTabs: false, @@ -120,6 +130,8 @@ struct WindowTitleResolverWindowTests { func missingConnectionFallsBack() { let resolved = WindowTitleResolver.resolveWindow( pane: .empty, + contentMode: .browse, + agentSessionTitle: nil, connection: nil, tab: nil, hasTabs: false, @@ -141,6 +153,8 @@ struct WindowTitleResolverWindowTests { let resolved = WindowTitleResolver.resolveWindow( pane: .content, + contentMode: .browse, + agentSessionTitle: nil, connection: connection, tab: tab, hasTabs: true, @@ -149,4 +163,167 @@ struct WindowTitleResolverWindowTests { #expect(resolved.title == "Weekly Query") } + + // MARK: - Agent mode + + /// Agent mode puts the conversation in the detail column and the editor tabs behind it. The + /// titlebar went on naming whichever tab was selected when the mode came on. + @Test("Agent mode names the session, never the tab behind the conversation") + func agentModeNamesTheSession() { + let resolved = WindowTitleResolver.resolveWindow( + pane: .content, + contentMode: .agent, + agentSessionTitle: "Orders shipped late", + connection: Self.connection(), + tab: Self.tableTab(), + hasTabs: true, + queryLanguageName: "PostgreSQL" + ) + + #expect(resolved.title == "Orders shipped late") + #expect(resolved.subtitle.isEmpty) + } + + /// The conversation is drawn while the connection is still coming up, so it is what the window + /// is showing then too. + @Test("Agent mode names the session over a connection that is still connecting") + func agentModeNamesTheSessionWhileConnecting() { + let resolved = WindowTitleResolver.resolveWindow( + pane: .connecting, + contentMode: .agent, + agentSessionTitle: "Orders shipped late", + connection: Self.connection(), + tab: nil, + hasTabs: true, + queryLanguageName: "PostgreSQL" + ) + + #expect(resolved.title == "Orders shipped late") + } + + /// A session has no name until its first question or reply gives it one, and a blank title is + /// never allowed to reach the window. + @Test("A session with no name yet, or no session at all, names the mode", arguments: [nil, "", " "]) + func unnamedSessionNamesTheMode(sessionTitle: String?) { + let resolved = WindowTitleResolver.resolveWindow( + pane: .content, + contentMode: .agent, + agentSessionTitle: sessionTitle, + connection: Self.connection(), + tab: Self.tableTab(), + hasTabs: true, + queryLanguageName: "PostgreSQL" + ) + + #expect(resolved.title == ConnectionWorkspaceContentMode.agent.localizedTitle) + #expect(!resolved.title.isBlank) + #expect(resolved.subtitle.isEmpty) + } + + /// The unavailable screen is what the detail column shows then, whichever mode the window is in, + /// and it is the connection that is not there. + @Test("Agent mode over a connection that cannot be reached names the connection") + func agentModeOverAnUnreachableConnectionNamesIt() { + let panes: [ConnectionWindowPane] = [ + .empty, + .unavailable(.notConnected), + .unavailable(.disconnected(nil)), + .unavailable(.failed(ConnectionFailureInfo(message: "refused"))), + ] + for pane in panes { + let resolved = WindowTitleResolver.resolveWindow( + pane: pane, + contentMode: .agent, + agentSessionTitle: "Orders shipped late", + connection: Self.connection(), + tab: nil, + hasTabs: false, + queryLanguageName: "PostgreSQL" + ) + + #expect(resolved.title == "Prod DB", "\(pane)") + } + } + + @Test("A session's name is ignored while browsing") + func browsingIgnoresTheSession() { + let resolved = WindowTitleResolver.resolveWindow( + pane: .content, + contentMode: .browse, + agentSessionTitle: "Orders shipped late", + connection: Self.connection(), + tab: Self.tableTab(), + hasTabs: true, + queryLanguageName: "PostgreSQL" + ) + + #expect(resolved.title == "orders") + } + + // MARK: - Proxy icon + + /// The proxy icon is decided with the title, so only the tab the window names can set it. + @Test("The file behind the named tab is the window's proxy icon") + func fileTabSetsTheProxyIcon() { + let resolved = WindowTitleResolver.resolveWindow( + pane: .content, + contentMode: .browse, + agentSessionTitle: nil, + connection: Self.connection(), + tab: Self.fileTab(), + hasTabs: true, + queryLanguageName: "PostgreSQL" + ) + + #expect(resolved.representedURL == Self.fileURL) + } + + /// A conversation is not a file. The browse content used to set the icon straight on the + /// window, so the tab behind the conversation kept its file's icon beside the session's name. + @Test("Agent mode shows no proxy icon, whatever file the tab behind it came from") + func agentModeHasNoProxyIcon() { + let resolved = WindowTitleResolver.resolveWindow( + pane: .content, + contentMode: .agent, + agentSessionTitle: "Orders shipped late", + connection: Self.connection(), + tab: Self.fileTab(), + hasTabs: true, + queryLanguageName: "PostgreSQL" + ) + + #expect(resolved.representedURL == nil) + } + + /// A window that is not showing content is not showing the tab's file either. + @Test("A window that is not showing content has no proxy icon") + func nonContentPanesHaveNoProxyIcon() { + for pane in Self.nonContentPanes { + let resolved = WindowTitleResolver.resolveWindow( + pane: pane, + contentMode: .browse, + agentSessionTitle: nil, + connection: Self.connection(), + tab: Self.fileTab(), + hasTabs: true, + queryLanguageName: "PostgreSQL" + ) + + #expect(resolved.representedURL == nil, "\(pane)") + } + } + + private static let fileURL = URL(fileURLWithPath: "/tmp/orders.sql") + + private static func fileTab() -> QueryTab { + var tab = QueryTab(id: UUID(), title: "orders.sql", query: "SELECT 1", tabType: .query) + tab.content.sourceFileURL = fileURL + return tab + } + + private static func tableTab() -> QueryTab { + var tab = QueryTab(id: UUID(), title: "orders", query: "SELECT * FROM orders", tabType: .table) + tab.tableContext.tableName = "orders" + return tab + } } diff --git a/TableProTests/Views/AIChat/ChatContentWidthTests.swift b/TableProTests/Views/AIChat/ChatContentWidthTests.swift new file mode 100644 index 000000000..9dc52a627 --- /dev/null +++ b/TableProTests/Views/AIChat/ChatContentWidthTests.swift @@ -0,0 +1,38 @@ +// +// ChatContentWidthTests.swift +// TableProTests +// +// The chat panel is one view with two widths: it fills the 270pt trailing pane it was measured in, +// and takes a reading measure in Agent mode, where the same view is the window's content column and +// filling it ran a line the whole width of the window. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Chat content width") +@MainActor +struct ChatContentWidthTests { + @Test("A pane conversation is capped at nothing and a reading one at a column") + func widthsAreWhatTheyClaim() { + #expect(ChatContentWidth.pane.maxWidth == nil) + #expect(ChatContentWidth.reading.maxWidth == 720) + } + + /// The default is what keeps the trailing pane exactly as it was: every other caller of the panel + /// passes nothing, and a `.reading` default would have capped a column that is already narrower + /// than the cap and centred it in the gap. + @Test("The panel fills its column unless it is asked for a reading measure") + func theDefaultFillsThePane() { + let connection = TestFixtures.makeConnection(type: .mysql) + let sessionId = UUID() + let viewModel = AIChatViewModel(services: .live, sessionId: sessionId, restoringConversation: nil) + + let pane = AIChatPanelView(connection: connection, viewModel: viewModel) + let conversation = AIChatPanelView(connection: connection, viewModel: viewModel, contentWidth: .reading) + + #expect(pane.contentWidth == .pane) + #expect(conversation.contentWidth == .reading) + } +} diff --git a/TableProTests/Views/HistoryRowTintTests.swift b/TableProTests/Views/HistoryRowTintTests.swift index ecac77a54..43eff3405 100644 --- a/TableProTests/Views/HistoryRowTintTests.swift +++ b/TableProTests/Views/HistoryRowTintTests.swift @@ -91,8 +91,8 @@ struct HistoryRowTintTests { /// A connection colour is a stored value, so it stayed itself on the fill. Green is the clearest /// of the palette to count against an accent-blue background. @available(macOS 14.0, *) - @Test("The connection dot leaves the accent fill when the row is emphasized") - func connectionDotAdaptsToProminence() { + @Test("The connection glyph leaves the accent fill when the row is emphasized") + func connectionGlyphAdaptsToProminence() { let label = HistoryConnectionLabel(name: "Chinook", color: .green) let standard = offTintPixels( diff --git a/TableProTests/Views/Main/EditorTabStripGestureConventionTests.swift b/TableProTests/Views/Main/EditorTabStripGestureConventionTests.swift index 32fe26a9c..31c920b1c 100644 --- a/TableProTests/Views/Main/EditorTabStripGestureConventionTests.swift +++ b/TableProTests/Views/Main/EditorTabStripGestureConventionTests.swift @@ -31,6 +31,14 @@ struct EditorTabStripGestureConventionTests { return url }() + /// Agent mode's session rail is the other list a row opens from, and it carried the banned + /// spelling for as long as it shipped, because this scan only ever read one file. The scan is + /// widened by tree rather than by file so the next view added under it is covered as it lands. + /// + /// `QuickSwitcherPanelView` has the same shape and is left out on purpose: it is not part of this + /// work, and a scan that fails on a file nobody touched is a scan that gets disabled. + private static let scannedTrees = ["TablePro/Views/Agent"] + /// One spelling covers both, because `onTapGesture(count:` contains `TapGesture(count:`. /// Listing them separately made a single offence report twice. private static let bannedGestures = ["TapGesture(count:"] @@ -61,6 +69,37 @@ struct EditorTabStripGestureConventionTests { ) } + /// The rail opens a session on the list's own primary action, which is `NSTableView`'s + /// `doubleAction` underneath and costs a single click nothing. A `count: 2` tap gesture on the + /// row is the same 371ms on every click there as it is in the tab strip. + @Test("Agent mode's views compose no multi-click tap gesture") + func agentViewsUseNoMultiClickTapGesture() throws { + var scanned = 0 + var offenders: [String] = [] + for tree in Self.scannedTrees { + let root = Self.repositoryRoot.appendingPathComponent(tree) + let enumerator = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + for case let url as URL in enumerator where url.pathExtension == "swift" { + scanned += 1 + let source = code(of: try String(contentsOf: url, encoding: .utf8)) + for spelling in Self.bannedGestures where source.contains(spelling) { + offenders.append("\(tree)/\(url.lastPathComponent)") + } + } + } + + /// Guards the scan itself: a path that stopped resolving reads as a clean run. + #expect(scanned >= 6, "Expected to scan Agent mode's views, scanned \(scanned) files") + #expect( + offenders.isEmpty, + """ + \(offenders.sorted()) compose a multi-click SwiftUI tap gesture, which delays every \ + single click by ~371ms. Open from the list's primary action and the row's accessibility \ + action instead. + """ + ) + } + /// A scan that stops matching anything is a test that passes forever. This pins both halves: /// a real call is still caught, and the comment that documents it is still ignored. @Test("The scan catches a real gesture and ignores one named in a comment") diff --git a/TableProTests/Views/Main/TriggerStructTests.swift b/TableProTests/Views/Main/TriggerStructTests.swift index 30ac60229..402879bfe 100644 --- a/TableProTests/Views/Main/TriggerStructTests.swift +++ b/TableProTests/Views/Main/TriggerStructTests.swift @@ -92,7 +92,8 @@ struct PendingChangeTriggerTests { pendingDeletes: Set = [], hasStructureChanges: Bool = false, isFileDirty: Bool = false, - hasCreateTablePending: Bool = false + hasCreateTablePending: Bool = false, + hasPrincipalChanges: Bool = false ) -> PendingChangeTrigger { PendingChangeTrigger( hasDataChanges: hasDataChanges, @@ -100,10 +101,19 @@ struct PendingChangeTriggerTests { pendingDeletes: pendingDeletes, hasStructureChanges: hasStructureChanges, isFileDirty: isFileDirty, - hasCreateTablePending: hasCreateTablePending + hasCreateTablePending: hasCreateTablePending, + hasPrincipalChanges: hasPrincipalChanges ) } + /// The fifth source, and the reason the commit control was dim on a Users & Roles tab however + /// many principal edits were staged: without this field the trigger never changed, so the one + /// function that recomputes what is pending was never re-run. + @Test("Different hasPrincipalChanges produces unequal triggers") + func differentHasPrincipalChanges() { + #expect(makeTrigger(hasPrincipalChanges: true) != makeTrigger(hasPrincipalChanges: false)) + } + @Test("Same values are equal") func sameValuesAreEqual() { let truncate = TestFixtures.makeTableRef(name: "t1") diff --git a/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift b/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift new file mode 100644 index 000000000..ee17b45b1 --- /dev/null +++ b/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift @@ -0,0 +1,227 @@ +// +// SidebarScopeControlTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import Testing + +/// The Tables and Favorites choice, in its own row at the top of the sidebar. +@Suite("Sidebar scope control", .serialized) +@MainActor +struct SidebarScopeControlTests { + /// The sidebar's own minimum, and the insets the row is laid out with. + private static let sidebarMinimum = MainSplitViewController.sidebarMinThickness + private static let rowInset: CGFloat = 10 + + @Test("Two worded segments, one of which is selected at a time") + func shape() { + let control = SidebarScopeControl() + + #expect(control.segmentCount == 2) + #expect(control.trackingMode == .selectOne) + #expect(control.segmentDistribution == .fillEqually) + #expect(control.label(forSegment: 0) == String(localized: "Tables")) + #expect(control.label(forSegment: 1) == String(localized: "Favorites")) + } + + /// The toolbar version was measured announcing its SF Symbol names, "List" and "favorite". A + /// worded segment publishes its own label, measured on macOS 27 as a radio group of two radio + /// buttons under the control. + @Test("Each segment names itself for assistive clients") + func segmentsAreNamed() throws { + let control = SidebarScopeControl() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: Self.sidebarMinimum, height: 120), + styleMask: [.titled], + backing: .buffered, + defer: true + ) + window.contentView?.addSubview(control) + control.frame = NSRect(x: Self.rowInset, y: 40, width: Self.sidebarMinimum - 2 * Self.rowInset, height: 24) + defer { control.removeFromSuperview() } + + var labels: [String] = [] + func collect(_ element: Any, depth: Int) { + guard depth < 4, let object = element as? NSObject else { return } + if (object.value(forKey: "accessibilityRole") as? String) == NSAccessibility.Role.radioButton.rawValue, + let label = object.value(forKey: "accessibilityLabel") as? String { + labels.append(label) + } + for child in (object.value(forKey: "accessibilityChildren") as? [Any]) ?? [] { + collect(child, depth: depth + 1) + } + } + collect(control, depth: 0) + + #expect(labels == [String(localized: "Tables"), String(localized: "Favorites")]) + } + + @Test("The selected tab reads back, and nil selects nothing") + func selectedTabRoundTrips() { + let control = SidebarScopeControl() + + control.selectedTab = .favorites + #expect(control.selectedSegment == 1) + #expect(control.selectedTab == .favorites) + + control.selectedTab = nil + #expect(control.selectedSegment == -1) + #expect(control.selectedTab == nil) + + control.selectedTab = .tables + #expect(control.selectedTab == .tables) + } + + /// Measured by laying the real sidebar chrome out at the sidebar's minimum width: the control + /// takes the row's width and never less than it asks for, so no segment is clipped. + @Test("The control fits the sidebar at its minimum width") + func fitsAtTheSidebarMinimum() throws { + let container = SidebarContainerViewController() + container.view.frame = NSRect(x: 0, y: 0, width: Self.sidebarMinimum, height: 600) + container.view.layoutSubtreeIfNeeded() + let control = try #require(container.view.subviews.compactMap { $0 as? SidebarScopeControl }.first) + + #expect(control.frame.width >= control.intrinsicContentSize.width) + #expect(control.frame.minX >= Self.rowInset - 0.5) + #expect(control.frame.maxX <= Self.sidebarMinimum - Self.rowInset + 0.5) + } + + /// The same measurement for every language the app ships, from the catalog's own translations: + /// a label that only fits in English is a label clipped in Turkish. + @Test("Both labels fit at the sidebar minimum in every shipped language") + func fitsInEveryLanguage() throws { + let available = Self.sidebarMinimum - 2 * Self.rowInset + for (language, pair) in try Self.shippedLabels() { + let control = SidebarScopeControl() + control.setLabel(pair.tables, forSegment: 0) + control.setLabel(pair.favorites, forSegment: 1) + #expect( + control.intrinsicContentSize.width <= available, + "\(language) needs \(control.intrinsicContentSize.width)pt of \(available)pt" + ) + } + } + + /// View > Show Tables and Show Favorites write the connection's state, and the control reads + /// it back, so all three stay in step whichever route moved them. + @Test("The control follows the sidebar state in both directions") + func selectionFollowsTheState() async throws { + let connectionId = UUID() + defer { SharedSidebarState.removeConnection(connectionId) } + let state = SharedSidebarState.forConnection(connectionId) + state.selectedSidebarTab = .tables + + let container = SidebarContainerViewController() + container.view.frame = NSRect(x: 0, y: 0, width: Self.sidebarMinimum, height: 600) + var chosen: [SidebarTab] = [] + container.onScopeSelection = { chosen.append($0) } + + container.updateSidebarState(state) + #expect(container.selectedScope == .tables) + #expect(container.isScopeEnabled) + + state.selectedSidebarTab = .favorites + #expect(await Self.waitFor { container.selectedScope == .favorites }) + + let control = try #require(container.view.subviews.compactMap { $0 as? SidebarScopeControl }.first) + control.selectedTab = .tables + control.sendAction(control.action, to: control.target) + #expect(chosen == [.tables]) + + container.updateSidebarState(nil) + #expect(container.selectedScope == nil) + #expect(!container.isScopeEnabled) + } + + /// Measured on macOS 27, a click on the segment already selected sends the action again. The + /// command behind it collapses the sidebar on a second press of the list it shows, so from a + /// control inside the sidebar that press is dropped rather than forwarded. + @Test("Pressing the selected segment again does nothing") + func reselectingIsNotForwarded() throws { + let connectionId = UUID() + defer { SharedSidebarState.removeConnection(connectionId) } + let state = SharedSidebarState.forConnection(connectionId) + state.selectedSidebarTab = .favorites + + let container = SidebarContainerViewController() + container.view.frame = NSRect(x: 0, y: 0, width: Self.sidebarMinimum, height: 600) + var chosen: [SidebarTab] = [] + container.onScopeSelection = { chosen.append($0) } + container.updateSidebarState(state) + defer { container.updateSidebarState(nil) } + + let control = try #require(container.view.subviews.compactMap { $0 as? SidebarScopeControl }.first) + #expect(control.selectedTab == .favorites) + control.sendAction(control.action, to: control.target) + #expect(chosen.isEmpty) + } + + /// Agent mode draws its session rail where the object list goes, so the scope and the filter + /// both stand down, and the list takes their height rather than sitting under an empty band. + @Test("Agent mode hides the scope row and the filter row, and the list takes their height") + func agentModeHidesTheChrome() throws { + let container = SidebarContainerViewController() + container.view.frame = NSRect(x: 0, y: 0, width: Self.sidebarMinimum, height: 600) + container.view.layoutSubtreeIfNeeded() + let list = try #require(container.children.first?.view) + let browsingTop = list.frame.maxY + + container.setChromeHidden(true) + container.view.layoutSubtreeIfNeeded() + let control = try #require(container.view.subviews.compactMap { $0 as? SidebarScopeControl }.first) + let filter = try #require(container.view.subviews.compactMap { $0 as? NSStackView }.first) + + #expect(container.isChromeHidden) + #expect(control.isHidden) + #expect(filter.isHidden) + #expect(list.frame.maxY > browsingTop, "The list stayed under the hidden rows") + + container.setChromeHidden(false) + container.view.layoutSubtreeIfNeeded() + #expect(!control.isHidden) + #expect(!filter.isHidden) + #expect(abs(list.frame.maxY - browsingTop) < 0.5) + } + + /// The state reaches the control through a run-loop hop and then a main-actor job, so the test + /// has to give the main thread back rather than spin it: a run loop spun inside this test runs + /// no main-actor job, measured, because the test is one. Bounded by a count of short sleeps. + private static func waitFor(_ condition: () -> Bool) async -> Bool { + for _ in 0..<200 { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() + } + + /// The two labels in every language the app catalog carries, English included. + private static func shippedLabels() throws -> [(String, (tables: String, favorites: String))] { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<3 { directory.deleteLastPathComponent() } + let url = directory.appendingPathComponent("TablePro/Resources/Localizable.xcstrings") + let data = try Data(contentsOf: url) + let catalog = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let strings = try #require(catalog?["strings"] as? [String: Any]) + + func translations(of key: String) -> [String: String] { + let localizations = (strings[key] as? [String: Any])?["localizations"] as? [String: Any] ?? [:] + var values = ["en": key] + for (language, localization) in localizations { + let unit = (localization as? [String: Any])?["stringUnit"] as? [String: Any] + if let value = unit?["value"] as? String { values[language] = value } + } + return values + } + + let tables = translations(of: "Tables") + let favorites = translations(of: "Favorites") + #expect(tables.count > 1, "No translations found; the measurement would be English only") + return tables.keys.sorted().compactMap { language in + guard let table = tables[language], let favorite = favorites[language] else { return nil } + return (language, (table, favorite)) + } + } +} diff --git a/TableProTests/Views/Toolbar/MainWindowToolbarIdentifierTests.swift b/TableProTests/Views/Toolbar/MainWindowToolbarIdentifierTests.swift index 0b613472c..0c44039bb 100644 --- a/TableProTests/Views/Toolbar/MainWindowToolbarIdentifierTests.swift +++ b/TableProTests/Views/Toolbar/MainWindowToolbarIdentifierTests.swift @@ -33,6 +33,57 @@ struct MainWindowToolbarIdentifierTests { #expect(missing.isEmpty, "Default items missing from the allowed set: \(missing.map(\.rawValue))") } + /// The palette's tail is where the commands that left the default set went, so the two lists + /// being equal would mean a user could no longer put any of them back. + @Test("The allowed set offers more than the default set") + func allowedIsAProperSuperset() { + let allowed = Set(MainWindowToolbar.allowedItemIdentifiers) + let defaults = Set(MainWindowToolbar.defaultItemIdentifiers) + #expect(defaults.isStrictSubset(of: allowed)) + } + + /// The named test for the hideability rule. An identifier is hideable exactly when the app puts + /// it in the toolbar itself, so a context can never take out an item the user dragged in, and a + /// change that adds to the hidden set without adding to the default list, or collapses the two + /// lists into one, fails here. + @Test("Hideable is exactly the default set") + func hideableIsExactlyTheDefaultSet() { + #expect(ToolbarContextResolver.hideableIdentifiers == Set(MainWindowToolbar.defaultItemIdentifiers)) + + let paletteOnly = Set(MainWindowToolbar.allowedItemIdentifiers) + .subtracting(MainWindowToolbar.defaultItemIdentifiers) + #expect(ToolbarContextResolver.hideableIdentifiers.isDisjoint(with: paletteOnly)) + } + + /// The autosave name. Measured on macOS 27, AppKit splices a new default identifier into a + /// saved arrangement at its default position and prunes one the delegate stops vending, so the + /// default set can change without discarding anyone's arrangement. A bump discards it, along + /// with the display mode they chose, so it has to be a decision with a reason rather than a + /// reflex; this is where that decision is made visible. + @Test("The toolbar identifier stays at v4") + func toolbarIdentifierIsPinned() { + #expect(MainWindowToolbar.toolbarIdentifier == "com.TablePro.main.toolbar.v4") + } + + /// AppKit asks the delegate for every allowed identifier to fill the palette, and with + /// `autosavesConfiguration` on an identifier the delegate answers nil for is pruned from the + /// saved arrangement. The standard identifiers are built by AppKit itself and never asked for. + @Test("The delegate builds every allowed identifier it owns") + func everyAllowedIdentifierIsVendable() { + let owner = MainWindowToolbar() + let owned = MainWindowToolbar.allowedItemIdentifiers.filter { !$0.rawValue.hasPrefix("NSToolbar") } + + #expect(!owned.isEmpty) + for identifier in owned { + let item = owner.toolbar( + owner.managedToolbar, + itemForItemIdentifier: identifier, + willBeInsertedIntoToolbar: false + ) + #expect(item?.itemIdentifier == identifier, "\(identifier.rawValue) must build from the palette") + } + } + private func expectNoDuplicates(in identifiers: [NSToolbarItem.Identifier], list: String) { let spaces: Set = [.space, .flexibleSpace] var seen: Set = [] diff --git a/TableProTests/Views/TrailingPaneHouseRuleTests.swift b/TableProTests/Views/TrailingPaneHouseRuleTests.swift new file mode 100644 index 000000000..c8a24b6ef --- /dev/null +++ b/TableProTests/Views/TrailingPaneHouseRuleTests.swift @@ -0,0 +1,96 @@ +// +// TrailingPaneHouseRuleTests.swift +// TableProTests +// +// The connection window's panes carry no decorative dot and no middle-dot separator. Both read as +// generated rather than designed, and there were five: an unsaved-edit marker drawn as a coloured +// circle, the chat's typing indicator drawn as three of them, a middle dot between the counts in +// the CSV inspector's status bar, and in the query history drawer a connection dot and two more +// middle dots. Status is an SF Symbol or words; separation is space. +// + +import Foundation +import Testing + +@Suite("Connection window pane house rules") +struct TrailingPaneHouseRuleTests { + private static let repositoryRoot: URL = { + var url = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 3 { + url.deleteLastPathComponent() + } + return url + }() + + private static let scannedTrees = [ + "TablePro/Views/RowInspector", + "TablePro/Views/Inspector", + "TablePro/Views/AIChat", + /// Agent mode's result column is the third trailing surface, and its rail and conversation + /// answer to the same rule: the session on screen is marked with a glyph, not with a dot. + "TablePro/Views/Agent", + /// The query history drawer is the window's other pane of rows, and it had both defects at + /// once: the connection named by a filled dot, and two middle dots holding its three facts + /// apart. + "TablePro/Views/Editor/History", + ] + + /// `Circle()` is the dot itself, and a bare `"circle.fill"` is the same dot drawn as a symbol, + /// which is what the history drawer named its connection with. Matching the literal with its + /// opening quote is what keeps the compound symbols the panes do use out of it: every one of + /// them, `checkmark.circle.fill` and the rest, carries a word between the quote and `circle`. + /// + /// The middle dot is banned both as the character and as its escape, since either spelling + /// draws the same separator. + private static let bannedSpellings = ["Circle()", "\"circle.fill\"", "\u{00B7}", "\\u{00B7}", "\\u{00b7}"] + + /// Comments are dropped before the scan, because a comment may name what was removed. + private static func code(of source: String) -> String { + source + .split(separator: "\n", omittingEmptySubsequences: false) + .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") } + .joined(separator: "\n") + } + + @Test("No decorative dot and no middle-dot separator in the connection window's panes") + func surfacesCarryNoDots() throws { + var scanned = 0 + var offenders: [String] = [] + for tree in Self.scannedTrees { + let root = Self.repositoryRoot.appendingPathComponent(tree) + let enumerator = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + for case let url as URL in enumerator where url.pathExtension == "swift" { + scanned += 1 + let source = Self.code(of: try String(contentsOf: url, encoding: .utf8)) + for spelling in Self.bannedSpellings where source.contains(spelling) { + offenders.append("\(tree)/\(url.lastPathComponent): \(spelling)") + } + } + } + + /// Guards the scan itself: a path that stopped resolving reads as a clean run. + #expect(scanned > 30, "Expected to scan the pane's source trees, scanned \(scanned) files") + #expect(offenders.isEmpty, "Use an SF Symbol or spacing instead: \(offenders.sorted())") + } + + /// A scan that stops matching anything passes forever. This pins both halves. + @Test("The scan catches a real dot and ignores one named in a comment") + func scanCatchesCodeButNotComments() { + let drawn = Self.code(of: " Circle().fill(Color.accentColor)") + #expect(Self.bannedSpellings.contains { drawn.contains($0) }) + + let separator = Self.code(of: " Text(verbatim: \"\u{00B7}\")") + #expect(Self.bannedSpellings.contains { separator.contains($0) }) + + let symbol = Self.code(of: " Image(systemName: \"circle.fill\").font(.system(size: 6))") + #expect(Self.bannedSpellings.contains { symbol.contains($0) }) + + /// The other half of that spelling: a status glyph that happens to end in `circle.fill` is + /// the affordance the rule asks for, so banning it would ban the fix. + let compound = Self.code(of: " Image(systemName: \"checkmark.circle.fill\")") + #expect(Self.bannedSpellings.contains { compound.contains($0) } == false) + + let documented = Self.code(of: " /// Drawn as an SF Symbol, not a Circle() dot.") + #expect(Self.bannedSpellings.contains { documented.contains($0) } == false) + } +} diff --git a/TableProUITests/AgentModeMenuUITests.swift b/TableProUITests/AgentModeMenuUITests.swift index 1fd072d5c..d93b04c35 100644 --- a/TableProUITests/AgentModeMenuUITests.swift +++ b/TableProUITests/AgentModeMenuUITests.swift @@ -1,16 +1,11 @@ import XCTest -/// The mode is asserted through the menu bar, which is the only route that can be driven. +/// The mode is asserted through the menu bar, which reaches it at any window width. /// -/// Measured against a dumped accessibility tree: the toolbar control publishes as a radio group of -/// radio buttons, the Agent segment reports `exists` and `isHittable`, and `click()` leaves -/// `isSelected` false with the window unchanged. AppKit does not route a synthetic click to a -/// segment inside an `NSToolbarItemGroup`, and at the runner's window width the control sits in the -/// toolbar's overflow menu, where the segments do not exist at all. A suite built on clicking it -/// would pass by skipping itself. -/// -/// The menu command exists partly for that reason and mostly because the HIG asks that every -/// toolbar item also be a menu-bar command. +/// The toolbar has no mode control. Browse and Agent are chosen from View > Mode, from ⌥⇧⌘A, and +/// from the Mode submenu of the toolbar's Actions pull-down, which is filled from the same enum as +/// View > Mode so the two cannot offer different modes. The Actions pull-down can sit in the +/// toolbar's overflow menu on a narrow window, the runner's included, and the menu bar cannot. final class AgentModeMenuUITests: UITestCase { private func openViewModeMenu(in app: XCUIApplication) -> XCUIElement { let menuBar = app.menuBars.firstMatch @@ -36,8 +31,8 @@ final class AgentModeMenuUITests: UITestCase { ) } - /// Every toolbar item is also a menu-bar command, so the mode is reachable with the toolbar - /// hidden, customized, or too narrow to show the control. + /// With no mode control in the toolbar, the chord is the one-step way to switch, so the menu has + /// to carry it where a user looking for it will find it. func testToggleAgentModeCarriesItsShortcut() throws { let app = try launchApp() @@ -68,4 +63,62 @@ final class AgentModeMenuUITests: UITestCase { .count XCTAssertLessThanOrEqual(ticked, 1, "Two arms of a radio pair cannot both carry the tick") } + + /// The session lifecycle's only routes used to be the rail's two buttons and its context menu, so + /// a user with the rail collapsed had none at all, and none of the four could be found by search + /// or rebound. They are asserted on a fresh launch, where they are present and dim: the menu bar + /// is built once at launch and carries every command the app has, whatever window is in front. + func testFileSessionCarriesTheSessionLifecycle() throws { + let app = try launchApp() + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + + menuBar.menuBarItems["File"].click() + menuBar.menuItems["Session"].click() + + for title in ["New Session", "Open Session", "Close Session", "Delete Session…"] { + XCTAssertTrue( + menuBar.menuItems[title].waitToExist(timeout: 10), + "File > Session must offer \(title)" + ) + } + } + + /// The assistant's three conversation commands lived in the trailing pane's header menu, which + /// Agent mode replaces with the result column, so entering the mode took them away outright. + func testFileSessionCarriesTheConversationCommands() throws { + let app = try launchApp() + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + + menuBar.menuBarItems["File"].click() + menuBar.menuItems["Session"].click() + + for title in ["New Conversation", "Conversation History", "Clear Recents…"] { + XCTAssertTrue( + menuBar.menuItems[title].waitToExist(timeout: 10), + "File > Session must offer \(title)" + ) + } + } + + /// With no connection window in front there is no rail and no session, so every one of them is + /// present and dim. A command lit over a window that cannot run it is the defect these items + /// would otherwise introduce, since the menu bar is built once at launch for the whole app. + func testTheSessionCommandsAreDimWithNoConnectionWindow() throws { + let app = try launchApp() + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + + menuBar.menuBarItems["File"].click() + menuBar.menuItems["Session"].click() + XCTAssertTrue(menuBar.menuItems["New Session"].waitToExist(timeout: 10)) + + for title in ["New Session", "Open Session", "Close Session", "Delete Session…"] { + XCTAssertFalse( + menuBar.menuItems[title].isEnabled, + "\(title) acts on a rail that no window is drawing" + ) + } + } } diff --git a/TableProUITests/AgentModeRoundTripUITests.swift b/TableProUITests/AgentModeRoundTripUITests.swift new file mode 100644 index 000000000..3beebfa50 --- /dev/null +++ b/TableProUITests/AgentModeRoundTripUITests.swift @@ -0,0 +1,68 @@ +// +// AgentModeRoundTripUITests.swift +// TableProUITests +// +// Agent mode used to draw its conversation on the other arm of the conditional that drew the +// browse content, so every trip into the mode and back rebuilt the browse tree and dropped what +// only its views held. The query editor's undo stack is one of those: the text view owns it and the +// tab does not, so a rebuilt editor shows the same text with nothing behind it to undo. +// + +import XCTest + +final class AgentModeRoundTripUITests: UITestCase { + /// The menu titles are matched by their English text, so the app runs in a known language. + private let englishArguments = ["-AppleLanguages", "(en)"] + private let query = "SELECT 1" + + func testATripThroughAgentModeKeepsTheEditorsUndo() throws { + let app = try launchWithSampleDatabase(arguments: englishArguments) + app.typeKey("t", modifierFlags: .command) + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitToExist(timeout: 10), "A new query tab must hold an editor") + typeQuery(query, in: app) + + chooseMode("Agent", in: app) + assertAgentModeIsShowing(in: app) + + chooseMode("Browse", in: app) + XCTAssertTrue(waitUntilHittable(editor, timeout: 15), "Browsing must put the editor back") + editor.click() + XCTAssertEqual(editor.value as? String, query, "The tab must still hold what was typed") + + app.typeKey("z", modifierFlags: .command) + + XCTAssertTrue( + waitForPredicate(timeout: 5) { (editor.value as? String) != self.query }, + "Undo must still reach the typing done before the trip. A rebuilt editor has nothing to undo" + ) + } + + // MARK: - Helpers + + private func chooseMode(_ title: String, in app: XCUIApplication) { + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + menuBar.menuBarItems["View"].click() + menuBar.menuItems["Mode"].click() + let item = menuBar.menuItems[title] + XCTAssertTrue(item.waitToExist(timeout: 10), "View > Mode must offer \(title)") + item.click() + } + + /// A trip that never reached Agent mode would keep the editor trivially, so the case first + /// proves the mode is on. Its pane toggle names the result column in either state, where the + /// browse one names the inspector or the assistant; which state depends on what earlier launches + /// left the pane in. + private func assertAgentModeIsShowing(in app: XCUIApplication) { + let menuBar = app.menuBars.firstMatch + menuBar.menuBarItems["View"].click() + let hideResult = menuBar.menuItems["Hide Result"] + let showResult = menuBar.menuItems["Show Result"] + XCTAssertTrue( + waitForPredicate(timeout: 15) { hideResult.exists || showResult.exists }, + "Choosing Agent must put the window in Agent mode" + ) + app.typeKey(.escape, modifierFlags: []) + } +} diff --git a/TableProUITests/AgentSessionRailUITests.swift b/TableProUITests/AgentSessionRailUITests.swift new file mode 100644 index 000000000..865555653 --- /dev/null +++ b/TableProUITests/AgentSessionRailUITests.swift @@ -0,0 +1,97 @@ +// +// AgentSessionRailUITests.swift +// TableProUITests +// +// The rail could only ever add sessions: there was no command anywhere in the app that deleted one, +// so it grew without bound and its empty state was unreachable. Its bottom bar is a source list's +// own add and remove pair now, and removing asks first. +// + +import XCTest + +final class AgentSessionRailUITests: UITestCase { + /// The menu titles are matched by their English text, so the app runs in a known language. + private let englishArguments = ["-AppleLanguages", "(en)"] + + func testTheRailAddsAndDeletesSessions() throws { + let app = try launchWithSampleDatabase(arguments: englishArguments) + let window = app.windows.firstMatch + XCTAssertTrue(window.waitToExist(timeout: 20)) + enterAgentMode(in: app) + + let rail = sessionRail(in: window) + XCTAssertTrue(rail.waitToExist(timeout: 20), "Agent mode opens a session, so the rail has one to list") + let opened = rail.outlineRows.count + + let add = window.buttons["agent-session-add"].firstMatch + XCTAssertTrue(waitUntilHittable(add, timeout: 15), "The rail's bottom bar adds to the list it sits under") + add.click() + + XCTAssertTrue( + waitForPredicate(timeout: 15) { rail.outlineRows.count == opened + 1 }, + "New Session must add a row and select it" + ) + + let remove = window.buttons["agent-session-remove"].firstMatch + XCTAssertTrue(waitUntilHittable(remove, timeout: 15), "A session is highlighted, so remove is live") + remove.click() + + let confirm = app.sheets.buttons["Delete"].firstMatch + XCTAssertTrue(confirm.waitToExist(timeout: 15), "Deleting a session throws its conversation away, so it asks") + confirm.click() + + XCTAssertTrue( + waitForPredicate(timeout: 15) { rail.outlineRows.count == opened }, + "Answering the question takes the session out of the rail" + ) + } + + /// Cancelling leaves the session where it was, which is the half of a confirmation that is worth + /// as much as the other. + func testCancellingTheQuestionKeepsTheSession() throws { + let app = try launchWithSampleDatabase(arguments: englishArguments) + let window = app.windows.firstMatch + XCTAssertTrue(window.waitToExist(timeout: 20)) + enterAgentMode(in: app) + + let rail = sessionRail(in: window) + XCTAssertTrue(rail.waitToExist(timeout: 20)) + let opened = rail.outlineRows.count + + let remove = window.buttons["agent-session-remove"].firstMatch + XCTAssertTrue(waitUntilHittable(remove, timeout: 15)) + remove.click() + + let cancel = app.sheets.buttons["Cancel"].firstMatch + XCTAssertTrue(cancel.waitToExist(timeout: 15)) + cancel.click() + + XCTAssertTrue( + waitForPredicate(timeout: 10) { rail.outlineRows.count == opened }, + "A refused question changes nothing" + ) + } + + // MARK: - Helpers + + /// The mode is chosen from the menu bar, which reaches it at any window width; the toolbar has no + /// mode control. + private func enterAgentMode(in app: XCUIApplication) { + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + menuBar.menuBarItems["View"].click() + menuBar.menuItems["Mode"].click() + let agent = menuBar.menuItems["Agent"] + XCTAssertTrue(agent.waitToExist(timeout: 10), "View > Mode must offer Agent") + agent.click() + } + + /// The rail is shown into the same column the object browser uses, so it is found the same way: + /// stepping through the window's direct children rather than searching the whole window, which + /// would walk the data grid's thousands of elements on the way. + private func sessionRail(in window: XCUIElement) -> XCUIElement { + window.children(matching: .splitGroup).firstMatch + .children(matching: .group).firstMatch + .descendants(matching: .outline).firstMatch + } +} diff --git a/TableProUITests/ConnectionWindowChromeUITests.swift b/TableProUITests/ConnectionWindowChromeUITests.swift index 9f060acf3..8666a4867 100644 --- a/TableProUITests/ConnectionWindowChromeUITests.swift +++ b/TableProUITests/ConnectionWindowChromeUITests.swift @@ -1,3 +1,4 @@ +import AppKit import XCTest /// The window a user opens is the window they end up with. @@ -79,6 +80,169 @@ final class ConnectionWindowChromeUITests: UITestCase { ) } + // MARK: - The toolbar's controls + + /// The default set is eight controls, and the ones the revamp took out stay out: no Browse and + /// Agent segments, no Tables and Favorites segments, no Back and Forward pair. + /// + /// The sample is SQLite, which is file-based, so on macOS 15 and later the container capsule is + /// the eighth control and is hidden: SQLite has one database and it is the file the connection + /// capsule already names. Below 15 there is no `isHidden`, so the capsule stands and dims. + func testTheDefaultToolbarCarriesItsControlsAndNoModeControl() throws { + try skipUnlessTheScreenFitsThePinnedWindow() + let app = try launchWithSampleDatabase(environment: pinnedEnvironment, arguments: englishArguments) + let toolbar = try shownToolbar(of: connectionWindow(of: app), in: app) + + XCTAssertTrue( + toolbar.buttons.matching(NSPredicate(format: "label CONTAINS[c] %@", "Sidebar")).firstMatch + .waitToExist(timeout: 20), + "The sidebar toggle leads the default set" + ) + for label in ["Connection", "Refresh", "Save Changes", "Inspector"] { + XCTAssertTrue(toolbar.buttons[label].waitToExist(timeout: 10), "\(label) is in the default set") + } + for label in ["Actions", "Safe Mode"] { + XCTAssertTrue(toolbar.menuButtons[label].waitToExist(timeout: 10), "\(label) is a pull-down in the default set") + } + + let container = toolbar.buttons["Database"] + if #available(macOS 15.0, *) { + XCTAssertFalse(container.exists, "A file-based connection has no container to switch, so the capsule is hidden") + } else { + XCTAssertTrue(container.exists, "Below macOS 15 the container capsule stands and dims") + } + + for label in ["Browse", "Agent", "Tables", "Favorites"] { + XCTAssertFalse( + toolbar.descendants(matching: .any)[label].exists, + "\(label) moved out of the toolbar; it must not be drawn there" + ) + } + XCTAssertEqual(toolbar.radioGroups.count, 0, "The toolbar carries no segmented chooser at all") + XCTAssertFalse(toolbar.buttons["Back"].exists, "Back and Forward are offered by Customize Toolbar, not the default set") + XCTAssertFalse(toolbar.buttons["Forward"].exists) + } + + /// The two sidebar lists are chosen from a control at the top of the sidebar, over the list it + /// switches. The sample has no favorites, so the Favorites list settles on its empty state, and + /// that state going away is what shows Tables took the sidebar back. + func testTheSidebarScopeControlSwitchesTablesAndFavorites() throws { + let app = try launchWithSampleDatabase(arguments: englishArguments) + let window = try connectionWindow(of: app) + + let scope = window.radioGroups["sidebar-scope"] + XCTAssertTrue(scope.waitToExist(timeout: 30), "The sidebar carries its Tables and Favorites control") + let tables = scope.radioButtons["Tables"] + let favorites = scope.radioButtons["Favorites"] + XCTAssertTrue(tables.waitToExist(timeout: 10)) + XCTAssertTrue(favorites.exists) + + XCTAssertTrue(waitUntilHittable(favorites, timeout: 10)) + favorites.click() + let noFavorites = window.staticTexts["No Favorites"] + XCTAssertTrue(noFavorites.waitToExist(timeout: 15), "The Favorites segment must show the Favorites list") + + XCTAssertTrue(waitUntilHittable(tables, timeout: 10)) + tables.click() + XCTAssertTrue( + waitForPredicate(timeout: 15) { !noFavorites.exists }, + "The Tables segment must take the sidebar back from the Favorites list" + ) + XCTAssertTrue( + objectBrowser(in: window).descendants(matching: .staticText).firstMatch.waitToExist(timeout: 15), + "The object browser must list the sample's tables again" + ) + } + + /// A definition that is not on the server yet has nothing to reload, so Refresh leaves the + /// titlebar on a Create Table tab, and the commit control is labelled with that tab's verb. + /// `NSToolbarItem.isHidden` is macOS 15; below it the item stays and dims, which is a different + /// assertion and one the unit suites make. + func testRefreshLeavesTheToolbarOnACreateTableTab() throws { + guard #available(macOS 15.0, *) else { + throw XCTSkip("NSToolbarItem.isHidden is macOS 15 and later; below it Refresh stays and dims") + } + try skipUnlessTheScreenFitsThePinnedWindow() + let app = try launchWithSampleDatabase(environment: pinnedEnvironment, arguments: englishArguments) + let window = try connectionWindow(of: app) + let toolbar = try shownToolbar(of: window, in: app) + + let refresh = toolbar.buttons["Refresh"] + XCTAssertTrue( + refresh.waitToExist(timeout: 30), + "A table tab shows Refresh, or its absence below would prove nothing" + ) + XCTAssertTrue(toolbar.buttons["Save Changes"].exists) + + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 20)) + menuBar.menuBarItems["Database"].click() + menuBar.menuItems["New Table…"].click() + XCTAssertTrue( + window.buttons["create-table-commit"].firstMatch.waitToExist(timeout: 30), + "Database > New Table… must open a Create Table tab" + ) + + XCTAssertTrue( + waitForPredicate(timeout: 10) { !refresh.exists }, + "An unsaved definition has nothing to reload, so Refresh leaves the titlebar" + ) + XCTAssertTrue( + toolbar.buttons["Create Table"].waitToExist(timeout: 10), + "The commit control names the verb of the tab it commits" + ) + } + + // MARK: - Helpers + + /// The window the toolbar test measures is pinned, because a restored frame narrow enough to + /// overflow the toolbar would move items into the overflow menu for reasons that have nothing to + /// do with the context, and an item in the overflow menu is not in the toolbar to find. + private let pinnedWindowSize = CGSize(width: 1_512, height: 861) + + private var pinnedEnvironment: [String: String] { + ["TABLEPRO_SCREENSHOT_FRAME": "\(Int(pinnedWindowSize.width))x\(Int(pinnedWindowSize.height))"] + } + + /// An `NSToolbarItem` publishes no accessibility identifier. Measured on macOS 27, each item is + /// an `AXButton`, or an `AXMenuButton` for a pull-down, labelled with the item's label and with + /// an empty identifier, and nothing gives one without a custom view, which the toolbar does not + /// use. The labels are localized, so the app runs in English. `AppleLanguages` only takes effect + /// as a launch argument. + private let englishArguments = ["-AppleLanguages", "(en)"] + + /// The runner's screen is 1024pt wide, and a window pinned wider than its screen overflows the + /// toolbar's items into the overflow menu, where none of them is in the toolbar to find. That is + /// unmeasurable rather than wrong, so it skips, the way `InspectorToolbarPlacementUITests` does. + private func skipUnlessTheScreenFitsThePinnedWindow() throws { + let width = NSScreen.main?.frame.width ?? 0 + try XCTSkipUnless( + width >= pinnedWindowSize.width, + """ + Needs a screen at least \(Int(pinnedWindowSize.width))pt wide to hold the pinned window; \ + this one is \(Int(width))pt, and anything narrower overflows toolbar items into its menu. + """ + ) + } + + private func connectionWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch + XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") + return window + } + + /// Normalised rather than asserted, the way `SwitcherWithoutToolbarAnchorUITests` does it. + /// AppKit persists whether the toolbar is shown through its own defaults rather than the sandbox + /// `UITestCase` hands the app, so a run inherits whatever the last one left. + private func shownToolbar(of window: XCUIElement, in app: XCUIApplication) throws -> XCUIElement { + let toolbar = window.toolbars.firstMatch + if !toolbar.waitToExist(timeout: 10) { + app.typeKey("t", modifierFlags: [.command, .option]) + } + XCTAssertTrue(toolbar.waitToExist(timeout: 10), "Command Option T must show the toolbar") + return toolbar + } + /// Creates a connection that cannot answer and opens it. The form is driven the way a person /// drives it, because a hand-written `connections.json` would pin the storage format rather /// than the behaviour under test. diff --git a/TableProUITests/InspectorFieldAffordanceUITests.swift b/TableProUITests/InspectorFieldAffordanceUITests.swift index 118f6f0ed..ca54a93f9 100644 --- a/TableProUITests/InspectorFieldAffordanceUITests.swift +++ b/TableProUITests/InspectorFieldAffordanceUITests.swift @@ -35,9 +35,10 @@ final class InspectorFieldAffordanceUITests: UITestCase { ) } - /// The header names what is being inspected. The pane carried no title at all before, because - /// it multiplexed three unrelated surfaces behind a picker. - func testTheHeaderNamesTheTableAndTheRow() throws { + /// The inspector names what it is inspecting, above its fields and under the pane's shared + /// header. The pane carried no title at all before, because it multiplexed three unrelated + /// surfaces behind a picker. + func testTheInspectorNamesTheTableAndTheRow() throws { let app = try launchWithSampleDatabase() let window = try mainWindow(of: app) _ = try openFirstTableRow(in: app, window: window) @@ -45,7 +46,7 @@ final class InspectorFieldAffordanceUITests: UITestCase { let subtitle = window.staticTexts["inspector-subject-subtitle"] XCTAssertTrue( subtitle.waitToExist(timeout: 30), - "The inspector header reports which row of how many is selected." + "The inspector reports which row of how many is selected, above its fields." ) } diff --git a/TableProUITests/JSONRowInspectorUITests.swift b/TableProUITests/JSONRowInspectorUITests.swift index 3564e2071..1bda26ac5 100644 --- a/TableProUITests/JSONRowInspectorUITests.swift +++ b/TableProUITests/JSONRowInspectorUITests.swift @@ -103,10 +103,10 @@ final class JSONRowInspectorUITests: UITestCase { item.click() } - /// The inspector's view-mode control is a segmented control, which AppKit publishes as radio - /// buttons. It selects between two renderings of the same row; the assistant used to be a third - /// segment here and is its own surface now. + /// The JSON rendering's own filter field, which only that rendering draws. The Fields / JSON + /// choice moved into the pane header's menu, where it is not on screen to be found, so the test + /// asks for the rendering it selects rather than for the control that selected it. private func jsonTab(in window: XCUIElement) -> XCUIElement { - window.radioButtons["JSON"] + window.searchFields["json-row-filter"] } } diff --git a/TableProUITests/SidebarFavoritesFirstEntryUITests.swift b/TableProUITests/SidebarFavoritesFirstEntryUITests.swift index d26b2221c..affc17af4 100644 --- a/TableProUITests/SidebarFavoritesFirstEntryUITests.swift +++ b/TableProUITests/SidebarFavoritesFirstEntryUITests.swift @@ -24,9 +24,10 @@ final class SidebarFavoritesFirstEntryUITests: UITestCase { ) } - /// `View > Show Favorites` rather than the toolbar's star segment. The segment is a toggle, so - /// pressing it again closes the sidebar instead of reselecting the tab, and this test is about - /// the very first entry. The menu item selects the tab and leaves the sidebar open. + /// `View > Show Favorites` rather than the Favorites segment of the sidebar's scope control. The + /// menu item is the route that does not depend on the sidebar being laid out, and this test is + /// about what the tab shows on its very first entry, not about how it was reached; the scope + /// control has its own case in `ConnectionWindowChromeUITests`. private func showFavorites(in app: XCUIApplication) { let menuBar = app.menuBars.firstMatch XCTAssertTrue(menuBar.waitToExist(timeout: 10)) diff --git a/TableProUITests/TrailingPaneSurfaceUITests.swift b/TableProUITests/TrailingPaneSurfaceUITests.swift new file mode 100644 index 000000000..51a7995cb --- /dev/null +++ b/TableProUITests/TrailingPaneSurfaceUITests.swift @@ -0,0 +1,164 @@ +// +// TrailingPaneSurfaceUITests.swift +// TableProUITests +// +// The trailing pane has one header on every surface: a picker between the inspector and the +// assistant, or the surface's name where there is nothing to choose, and a menu of that surface's +// commands. The View menu's two commands take their titles from the surface the pane is drawing, so +// in Agent mode the pane toggle names the result column and Show Assistant is dimmed. +// + +import XCTest + +final class TrailingPaneSurfaceUITests: UITestCase { + /// The titles are matched by their English text, so the app runs in a known language. + private let englishArguments = ["-AppleLanguages", "(en)"] + + /// Agent mode reveals its result column on the way in, so the toggle starts on Hide Result. + func testAgentModeRetitlesThePaneToggleAndDimsShowAssistant() throws { + let app = try launchWithSampleDatabase(arguments: englishArguments) + _ = try mainWindow(of: app) + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + + menuBar.menuBarItems["View"].click() + menuBar.menuItems["Mode"].click() + let agent = menuBar.menuItems["Agent"] + XCTAssertTrue(agent.waitToExist(timeout: 10), "View > Mode must offer Agent") + agent.click() + + menuBar.menuBarItems["View"].click() + let hideResult = menuBar.menuItems["Hide Result"] + XCTAssertTrue( + hideResult.waitToExist(timeout: 15), + "In Agent mode the pane toggle names the result column it closes, not an inspector nobody sees" + ) + XCTAssertFalse( + menuBar.menuItems["Hide Inspector"].exists, + "The inspector is not on screen in Agent mode, so nothing may offer to hide it" + ) + let showAssistant = menuBar.menuItems["Show Assistant"] + XCTAssertTrue(showAssistant.exists) + XCTAssertFalse( + showAssistant.isEnabled, + "The conversation is the content column in Agent mode, so there is no assistant to show" + ) + + hideResult.click() + menuBar.menuBarItems["View"].click() + XCTAssertTrue( + menuBar.menuItems["Show Result"].waitToExist(timeout: 10), + "Closing the result column must leave a command that brings it back" + ) + app.typeKey(.escape, modifierFlags: []) + } + + /// The picker writes the connection's surface and the window swaps the pane on the next turn of + /// the run loop, after the picker's own action has returned. The inspector's field search leaving + /// the window is what shows the swap happened. + func testTheHeaderPickerMovesThePaneToTheAssistant() throws { + let app = try launchWithSampleDatabase(arguments: englishArguments) + let window = try mainWindow(of: app) + selectFirstRow(in: window) + showInspector(in: app) + XCTAssertTrue( + window.searchFields["inspector-field-search"].waitToExist(timeout: 20), + "The inspector must be showing the row's fields before the pane can move off it" + ) + + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.surfaceSegment("Assistant", in: window).exists }, + "The inspector's header offers the assistant while AI is on, named rather than by its glyph" + ) + let assistant = surfaceSegment("Assistant", in: window) + XCTAssertTrue(waitUntilHittable(assistant, timeout: 10)) + assistant.click() + + XCTAssertTrue( + waitForPredicate(timeout: 15) { !window.searchFields["inspector-field-search"].exists }, + "Picking the assistant must take the inspector off the pane" + ) + let menu = window.descendants(matching: .any)["trailing-pane-menu"].firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 10) { menu.exists && menu.label == "Assistant Options" }, + "The assistant draws the same header as the inspector, carrying its own commands" + ) + + let menuBar = app.menuBars.firstMatch + menuBar.menuBarItems["View"].click() + XCTAssertTrue( + menuBar.menuItems["Hide Assistant"].waitToExist(timeout: 10), + "The View menu reads the surface the pane is drawing" + ) + app.typeKey(.escape, modifierFlags: []) + } + + /// Fields and JSON are a choice in the inspector's menu now, not a second segmented control + /// beside the surface picker. + func testTheInspectorMenuSwitchesTheRowToJSON() throws { + let app = try launchWithSampleDatabase(arguments: englishArguments) + let window = try mainWindow(of: app) + selectFirstRow(in: window) + showInspector(in: app) + + let menu = window.descendants(matching: .any)["trailing-pane-menu"].firstMatch + XCTAssertTrue(menu.waitToExist(timeout: 20), "Every surface's header carries its commands menu") + XCTAssertEqual( + menu.label, + "Inspector Options", + "The ellipsis draws no text, so its label is the only name VoiceOver has for it" + ) + XCTAssertTrue(waitUntilHittable(menu, timeout: 10)) + menu.click() + + let json = window.menuItems["JSON"].firstMatch + XCTAssertTrue(json.waitToExist(timeout: 10), "The inspector's menu offers the JSON rendering") + json.click() + + XCTAssertTrue( + window.searchFields["json-row-filter"].waitToExist(timeout: 20), + "Choosing JSON must show the row as JSON" + ) + } + + // MARK: - Helpers + + private func mainWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch + XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") + return window + } + + /// A segmented control publishes its segments as radio buttons, and the CI runner has been seen + /// publishing the same controls as plain buttons, so both are asked, inside the picker first. + private func surfaceSegment(_ title: String, in window: XCUIElement) -> XCUIElement { + let radio = window.radioGroups["trailing-pane-surface"].radioButtons[title].firstMatch + return radio.exists ? radio : window.buttons.matching(identifier: title).firstMatch + } + + /// A point offset from the grid rather than a row: the grid publishes its columns as siblings of + /// its rows, so XCUITest reads every row as obscured and refuses to click one. `dy` clears the + /// 42pt header, and the rows have to be in first or the click lands on an empty grid. + private func selectFirstRow(in window: XCUIElement) { + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "The sample database produced no data grid") + XCTAssertTrue(waitForClickableRows(in: grid), "The sample table must load rows before one is selected") + gridPoint(in: grid, of: window, dy: 70).click() + } + + /// The pane remembers whether it was open and on which surface, so the starting state is + /// whatever the previous launch left. Show Inspector is offered in every state but one, the + /// inspector already on screen. + private func showInspector(in app: XCUIApplication) { + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + menuBar.menuBarItems["View"].click() + + let show = menuBar.menuItems["Show Inspector"] + if show.waitToExist(timeout: 5) { + show.click() + return + } + app.typeKey(.escape, modifierFlags: []) + } +} diff --git a/docs/connections/index.mdx b/docs/connections/index.mdx index a10adc590..0c3de9539 100644 --- a/docs/connections/index.mdx +++ b/docs/connections/index.mdx @@ -89,14 +89,10 @@ The sort order and the Recent section stay on this Mac. Press `Ctrl+Cmd+C` for **Switch Connection**. Whatever is already open sits at the top, then your favorites and the connections you opened last, and the rest are listed under the group they belong to, a nested group carrying its full path. Arrow keys move, `Return` switches the window to that connection, and `Cmd`-click opens a saved one in a window of its own. Typing searches every group at once and collapses the matches into a single list. -A footer under that list names the transport the window's connection runs on. On an SSH tunnel or a SOCKS proxy it also carries the bytes that have crossed since the transport opened, received and sent. +A footer under that list names the transport the window's connection runs on. On an SSH tunnel or a SOCKS proxy it also carries the bytes that have crossed since the transport opened, received and sent, with the current rate beside each direction while something is moving. The rest show their name alone. Cloudflare Tunnel, Cloud SQL Auth Proxy and Tunnel Command each run a separate binary that holds the connection end to end, a [remote database file](/connections/remote-database-files) is fetched once and then read from disk, and a direct connection's socket belongs to the database driver. -### Throughput in the toolbar - -The same two transports put a live figure beside the connection name in the middle of the toolbar, an arrow for whichever direction is busier and the rate: `↓145 kB/s`. It reads `0 kB/s` while nothing moves, which is most of the time on a database connection. A connection on any other transport has no figure there at all. - **Open Database** (`Cmd+K`) moves to another database on the same server. diff --git a/docs/connections/socks-proxy.mdx b/docs/connections/socks-proxy.mdx index b63fa1659..8a64afb2c 100644 --- a/docs/connections/socks-proxy.mdx +++ b/docs/connections/socks-proxy.mdx @@ -63,7 +63,7 @@ An SSH dynamic port forward is a SOCKS5 proxy. Run `ssh -D 1080 user@bastion`, t [SSL/TLS](/connections/ssl) still applies, with one unavoidable adjustment: the driver dials a loopback port that no server certificate names, so **Verify CA** and **Verify Identity** fall back to **Required** and certificate paths are dropped. -Bytes through the proxy are counted the same way an SSH tunnel's are: a live rate in the toolbar, and the totals under the connection switcher. See [transport activity](/connections#switch-connections-and-databases). +Bytes through the proxy are counted the same way an SSH tunnel's are, and the totals and the live rate sit under the connection switcher. See [transport activity](/connections#switch-connections-and-databases). ## Troubleshooting diff --git a/docs/connections/ssh-tunneling.mdx b/docs/connections/ssh-tunneling.mdx index de92fc6c9..8b525e6b8 100644 --- a/docs/connections/ssh-tunneling.mdx +++ b/docs/connections/ssh-tunneling.mdx @@ -129,7 +129,7 @@ If tunnels keep dropping on an idle network, the keep-alive is not the missing p ## What the tunnel is carrying -The rate sits beside the connection name in the middle of the toolbar. For the totals, press `Ctrl+Cmd+C` and read the footer under the connection list: bytes received and sent since the tunnel opened. A rebuilt tunnel starts again from zero. See [transport activity](/connections#switch-connections-and-databases). +Press `Ctrl+Cmd+C` and read the footer under the connection list: bytes received and sent since the tunnel opened, and the rate beside each direction while traffic is moving. A rebuilt tunnel starts again from zero. See [transport activity](/connections#switch-connections-and-databases). ## Troubleshooting diff --git a/docs/customization/data-settings.mdx b/docs/customization/data-settings.mdx index e4636fda7..0a22b62ab 100644 --- a/docs/customization/data-settings.mdx +++ b/docs/customization/data-settings.mdx @@ -21,7 +21,7 @@ The tab is app-wide, so these values apply to every connection you open; filters | NULL display | `NULL` | Text shown for NULL cells, up to 20 characters | | Show alternate row backgrounds | On | | | Show row numbers | On | | -| Auto-show inspector on row select | Off | Opens the inspector on selecting a row, in a query result as well as a table | +| Auto-show inspector on row select | Off | Opens the inspector on selecting a row, in a query result as well as a table. A pane you left on the assistant stays on it | | Smart value detection | On | Reads id-like binary columns as UUIDs, timestamp-named integer columns as dates, and binary columns holding UTF-8 as text. Override per column with Display As, see [Cell and Row Viewers](/features/json-viewer) | | Default row sort | No sorting (engine order) | Or Primary key, First column. Applied when a table first opens; clicking a column header overrides it | | Sort direction | Ascending | Or Descending. Sets the default row sort's direction and which way the first click on a column header sorts | diff --git a/docs/databases/cloudflare-r2-sql.mdx b/docs/databases/cloudflare-r2-sql.mdx index ac7011763..d7261d255 100644 --- a/docs/databases/cloudflare-r2-sql.mdx +++ b/docs/databases/cloudflare-r2-sql.mdx @@ -43,7 +43,7 @@ The token reaches every bucket its permissions cover, not only the one this conn ## Namespaces and tables -Iceberg groups tables into namespaces, and each namespace is a schema here: the sidebar lists the bucket's namespaces with their tables inside, and the toolbar switcher reads **Namespace**. The list comes from `SHOW NAMESPACES` and `SHOW TABLES IN`, and a table's columns from `DESCRIBE`. None of the three scans data. A column's Iceberg `doc` shows as its comment in the Structure tab. +Iceberg groups tables into namespaces, and each namespace is a schema here: the sidebar lists the bucket's namespaces with their tables inside, and the [toolbar's centred control](/features/connection-window#the-toolbar) moves between them. The list comes from `SHOW NAMESPACES` and `SHOW TABLES IN`, and a table's columns from `DESCRIBE`. None of the three scans data. A column's Iceberg `doc` shows as its comment in the Structure tab. Name the namespace in a query tab: diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 533ef51bd..e5a9bbb6c 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -52,7 +52,7 @@ Turn on **Use Password File** to read the password from `~/.pgpass` instead of t ## Databases and schemas -Every database on the server is listed, `postgres` included; `template0` and `template1` are not. The sidebar shows every schema you have access to, and the toolbar carries the active database and schema side by side: click either to switch, or press `Cmd+K` for the database list. +Every database on the server is listed, `postgres` included; `template0` and `template1` are not. The sidebar shows every schema you have access to. The toolbar's centred control names the active database: click it, or press `Cmd+K`, for the list. The active schema has a picker at the foot of the object list in the flat layout, and **Database > Schema** everywhere. Right-click a schema to drop it. The statement is `DROP SCHEMA … CASCADE`, so views and functions in *other* schemas that depend on it go too; the confirmation says so before it runs. diff --git a/docs/databases/snowflake.mdx b/docs/databases/snowflake.mdx index 8b8956d5b..a1e0d7349 100644 --- a/docs/databases/snowflake.mdx +++ b/docs/databases/snowflake.mdx @@ -64,7 +64,7 @@ Paste an access token issued for the account. Nothing refreshes it for you. The sidebar groups objects by database and schema. Object lists come from `SHOW` commands, which the metadata service answers with no warehouse running; column details come from `INFORMATION_SCHEMA`, which needs one. -Toolbar pickers move the session to another **Warehouse** or **Role** with `USE WAREHOUSE` and `USE ROLE`, no reconnect. A tab bound to a second database stays on that session too: `USE DATABASE` runs ahead of its statements. One TablePro connection is one Snowflake session, held open while idle by a heartbeat. +**Database > Session Context** moves the session to another **Warehouse** or **Role** with `USE WAREHOUSE` and `USE ROLE`, no reconnect. A tab bound to a second database stays on that session too: `USE DATABASE` runs ahead of its statements. One TablePro connection is one Snowflake session, held open while idle by a heartbeat. ## Editing diff --git a/docs/docs.json b/docs/docs.json index 6d164a5b7..e9b1be577 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -246,6 +246,7 @@ "icon": "window-maximize", "pages": [ "features/overview", + "features/connection-window", "features/tabs", "features/workspace-rail", "features/open-quickly", diff --git a/docs/features/agent-mode.mdx b/docs/features/agent-mode.mdx index b0e3ae306..ba4384532 100644 --- a/docs/features/agent-mode.mdx +++ b/docs/features/agent-mode.mdx @@ -3,25 +3,29 @@ title: Agent mode description: "Give one AI session the whole connection window: its sessions, its conversation, and the SQL it proposed" --- -The chat in the [inspector](/features/ai-assistant#chat) is for a question. Agent mode is for a job. Press `Cmd+Option+Shift+A`, pick **Agent** under **View > Mode**, or click it in the toolbar, and the window's three columns become the sessions on this connection, the conversation, and what that session proposed and ran. +The chat in the [inspector](/features/ai-assistant#chat) is for a question. Agent mode is for a job. Press `Cmd+Option+Shift+A`, or pick **Agent** under **View > Mode** or under **Mode** in the toolbar's **Actions** menu, and the window's three columns become the sessions on this connection, the conversation, and what that session proposed and ran. -Nothing closes. The object browser and the editor tabs are where you left them, and **Browse** in the same place puts them back. +Nothing closes. The object browser and the editor tabs are where you left them, and **Browse** in the same place puts them back: the same tabs, the same scroll position and selection in the grid, the same undo stack and find panel in the editor, and a half-typed Create Table still half-typed. ## The three columns | Column | Holds | |--------|-------| | Sessions | One row per session on this connection, each naming what it is doing | -| Conversation | The same chat as the inspector's, at the width of the window | +| Conversation | The same chat as the inspector's, at a reading measure in the middle of the window | | Result | What this session proposed, ran and changed | The choice is per connection, so one connection sits in Agent mode while another in the same window stays on a table. Drag either divider to resize; the widths are the ones the window already had. +While the mode is on, the window names the session it is drawing, or **Agent** until the session has a name, and carries no file icon in its titlebar. The editor tab strip goes with the tabs it lists, and the sidebar's **Tables** and **Favorites** chooser and filter field go with the object list: the session rail takes their height. + +A line above the transcript names the connection, the session, and what is holding [Safe Mode](/features/safe-mode) at the level it is on. Point at that last part for the full sentence. + ## Writes wait for you -Agent mode holds the connection at [Safe Mode](/features/safe-mode) **Alert** for as long as the mode is on, whatever level the connection itself is set to. Every `INSERT`, `UPDATE` and `DELETE` the assistant proposes waits on its card for **Run** or **Reject**. +Agent mode holds the connection at Safe Mode **Alert** for as long as the mode is on, whatever level the connection itself is set to. Every `INSERT`, `UPDATE` and `DELETE` the assistant proposes waits on its card for **Run** or **Reject**. -A connection already at Alert or stricter keeps its own level, and **Read-Only** stays read-only. Leaving Agent mode hands back the level you set, and nothing is written to the connection. +Nothing below **Alert** is offered while the mode is on: the padlock and **Database > Safe Mode Level** list the levels at or above it and print the reason underneath, and the padlock's tooltip carries the same sentence. A connection already at Alert or stricter keeps its own level, and **Read-Only** stays read-only. Leaving Agent mode hands back the level you set, and nothing is written to the connection. Each card names the statement and the connection it will run against, and shows the database when the assistant named one. The full request is one click away under the tool name. @@ -33,7 +37,7 @@ The floor is a habit, not a lock. It is one keystroke from off, so treat it as a ## Sessions -A session belongs to one connection and holds one conversation. Switching to Agent mode opens the first. Click **+** at the foot of the sessions column to start another on the same connection. +A session belongs to one connection and holds one conversation. Switching to Agent mode opens the first. The list runs newest first by when each session last did something, and the one the window is drawing carries a checkmark. | State | Meaning | |-------|---------| @@ -44,25 +48,57 @@ A session belongs to one connection and holds one conversation. Switching to Age | **Stopped** | Its window closed. The transcript is intact | | **Failed** | The provider returned an error, or the app quit mid-reply | -Clicking a row selects it; double-click or **Open Session** in its context menu switches the two columns to its right. **Close Session** ends one and keeps its transcript. +Clicking a row highlights it and nothing more. To open one, double-click it, press `Return` on it, or choose **Open Session** from its context menu. **+** and **-** at the foot of the column start a session and delete one. + +**Close Session** ends a session and keeps its transcript in the list, to be opened again later. It asks first only when the session is working or waiting on your answer, and says which. **Delete Session…** throws the session and its conversation away, always asks, and names what it is about to stop; the **-** button, the context menu and the `Delete` key all reach it. + +Close or delete the session the window is drawing and the most recent live session takes its place. With none left, the two columns read **No Session Open** and offer **New Session**. Closing a window stops that connection's sessions and keeps what they said. Open one again and it continues. Nothing is replayed: a statement that was waiting for an answer when the window closed was rejected by the stop, so the assistant is asked again rather than the call being re-issued. Sessions stay on this Mac. They are not part of [iCloud sync](/features/icloud-sync). +### From the menu bar + +**File > Session** holds the same commands, so a session is reachable with the rail collapsed: + +| Item | Acts on | +|---|---| +| **New Session** | This connection | +| **Open Session** | The session the rail has highlighted | +| **Recent Sessions** | Any session on this connection, the one on screen ticked | +| **Close Session**, **Delete Session…** | The session the rail has highlighted | +| **New Conversation**, **Conversation History**, **Clear Recents…** | The conversation, in either mode | + +New Session, Open Session, Close Session, Delete Session and New Conversation ship with no shortcut. Bind one in **Settings > Keyboard**, under **Navigation**. + ## The result pane -Four views of one session, and a view with nothing in it says what would appear there. +Two views of one session, chosen from the menu at the trailing end of the column's header. The choice belongs to the session, so switching sessions does not inherit the other one's view. | View | Shows | |------|-------| | **SQL** | Every statement the session proposed, in order, with what became of it | -| **Plan** | The steps it has taken and what waits on you | | **Results** | The rows it read, in the [data grid](/features/data-grid) | -| **Schema** | Columns, indexes and constraints a `CREATE` or `ALTER` would add or remove | **Results** is the data grid, so sorting, column widths, selection and `Cmd+C` work the way they do in a query tab, and values use the Data Grid font. A query the session ran more than once has a picker above the rows. +A run with no rows to draw says which of three things happened: **No Rows** for a query that matched nothing, **Statement Completed** for a write, with the number of rows it changed, and **Can't Show This Result** for a reply the grid cannot read, which the conversation still has in full. + +`Cmd+Option+I` opens and closes this column, and the View menu's item reads **Show Result** while the mode is on. The column itself reads **No Session Open** until a session starts, and **Not Connected** while the connection is down. + +## When the connection drops + +The conversation column is replaced by the same unavailable screen browsing shows: the error itself, **Manage Connections…**, and **Reconnect** for a connection that dropped or **Try Again** for one that failed. Reconnecting puts the same session and the same transcript back. + +## What Agent mode dims + +The commands that act on the browse content have nothing to act on while the conversation fills the detail column, so the menu bar dims them: **Refresh**, **Save**, **Add Row**, **Restore Previous Values…**, **Preview SQL**, **Show Results**, **Show Query History**, **New Tab**, **Open Quickly…**, **Export Tables…**, **Import Data…**, **Server Dashboard**, **Back** and **Forward**. + +The [toolbar](/features/connection-window#what-each-context-leaves-out) drops Refresh and the commit control outright, and dims any of the rest you added to it. + +Everything that acts on the window or the session stays live: **Switch Connection…**, **Close Connection**, **Safe Mode Level**, **Mode**, and every command under **File > Session**. + ## Starting from the welcome window Right-click a connection in the welcome window and choose **Open in Agent Mode**. The connection opens with its window already in Agent mode, and a connection that is already open switches where it stands rather than opening a second time. @@ -72,5 +108,6 @@ The composer stays live while a connection is still being made, so a question ty ## Related - [AI Assistant](/features/ai-assistant) for providers, tool calling and the inspector chat +- [Connection window](/features/connection-window) for the toolbar and the trailing pane in both modes - [Safe Mode](/features/safe-mode) for the levels and what each one gates - [Keyboard shortcuts](/features/keyboard-shortcuts) diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index a57083562..dcb3141f0 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -47,9 +47,11 @@ The `claude` tool runs headless here. Claude Code is built for you to use direct ## Chat -Press `Cmd+Option+A`, or choose **View > Show Assistant**, to open the assistant in the window's trailing pane. It shares that pane with the inspector: opening one puts the other away, and the same shortcut closes it again. The sparkles button in the toolbar does the same. +Press `Cmd+Option+A`, or choose **View > Show Assistant**, to open the assistant in the window's [trailing pane](/features/connection-window#the-trailing-pane). It shares that pane with the inspector, and the two-segment picker at the leading end of the pane's header moves between them. Over an open inspector the shortcut swaps rather than closes; over an open assistant it closes the pane. -With AI features off in **Settings > AI**, the command and the button are dimmed. +Whichever segment you pick is remembered for that connection, and only a segment you pick counts: opening the pane for a row you clicked, with **Auto-show inspector on row select** on, leaves a pane you left on the assistant alone. The assistant has no toolbar button of its own: the pane toggle opens the column and the picker chooses what it draws. + +With AI features off in **Settings > AI**, the command is dimmed and the picker is gone, so the pane holds the inspector alone. In [Agent mode](/features/agent-mode) the command is dimmed too, because the conversation is the window's content column there; **View > Focus > Focus Assistant** (`Ctrl+Cmd+Option+A`) puts the insertion point in its composer. Assistant pane holding a chat thread beside a data grid @@ -58,7 +60,7 @@ With AI features off in **Settings > AI**, the command and the button are dimmed Type a question and press Return. Code blocks carry **Copy** and **Insert**, and Insert fills the current query tab when it is empty and opens a new one otherwise. A token count sits under each response, a failed one offers **Retry**, a finished one **Regenerate**, and **Stop Generating** cancels a reply mid-stream. -Conversations save themselves and take their title from your first message. The clock icon in the inspector header opens recent ones; the pencil-and-square icon starts a new one. Editing a message you already sent puts its text and attachments back in the composer and drops that turn and everything after it. +Conversations save themselves and take their title from your first message. The ellipsis menu at the trailing end of the pane's header holds **New Conversation**, **Conversation History** with the current conversation ticked, and **Clear Recents**, which asks before it deletes every one of them. **File > Session** carries the same three, so they work with the pane closed and in [Agent mode](/features/agent-mode). Editing a message you already sent puts its text and attachments back in the composer and drops that turn and everything after it. Paste or drag images into the composer on any provider that takes them, which is all of them except GitHub Copilot, Cursor, ChatGPT, and Claude Agent. A drop that carries files the composer cannot read attaches the rest and says how many it kept. diff --git a/docs/features/change-tracking.mdx b/docs/features/change-tracking.mdx index 2515bf8f7..86f6472d6 100644 --- a/docs/features/change-tracking.mdx +++ b/docs/features/change-tracking.mdx @@ -98,7 +98,7 @@ There is no discard button. Undo the edits, or take the **Discard Unsaved Change ## Restoring a save -**Edit > Restore Previous Values…**, or the same item in the toolbar's Table Actions group, puts back what the rows held before the last save on the current table. Starter license. +**Edit > Restore Previous Values…**, or the same item under **Actions** in the toolbar, puts back what the rows held before the last save on the current table. It ships with no shortcut; bind one in **Settings > Keyboard**, under **Data Grid**. Starter license. Values are kept on this Mac for 7 days, encrypted, and never synced. Turn the capture off, or delete what is stored, in **Settings > Data & Results**. diff --git a/docs/features/connection-window.mdx b/docs/features/connection-window.mdx new file mode 100644 index 000000000..ab32c97ee --- /dev/null +++ b/docs/features/connection-window.mdx @@ -0,0 +1,109 @@ +--- +title: Connection window +description: The toolbar, the Actions menu, the sidebar's list chooser, and the trailing pane +--- + +Nothing in the titlebar is fixed furniture. The toolbar carries the commands for the tab you are on, and what it leaves out is one click away under **Actions** or in the menu bar. + +## The toolbar + +Eight controls, laid out by pane: the sidebar toggle over the sidebar, the connection and its database in the centre, the tab's own commands after them, and the trailing pane's toggle at the end. + +| Control | What it does | +|---|---| +| Sidebar | Shows and hides the sidebar (`Cmd+0`) | +| Connection | The engine's glyph and the connection's name. Click for the [connection switcher](/connections#switch-connections-and-databases) (`Ctrl+Cmd+C`) | +| Database | The database this window is browsing. Click for the list (`Cmd+K`) | +| Refresh | Reloads what the tab is showing (`Cmd+R`) | +| Save Changes | Commits the tab's staged changes (`Cmd+S`) | +| Actions | The rest of the commands for this tab and this connection | +| Safe Mode | A padlock carrying the current [level](/features/safe-mode), open for **Silent** and closed for the rest | +| Inspector | Opens and closes the [trailing pane](#the-trailing-pane) (`Cmd+Option+I`) | + +Two of them change their name. The database control follows the engine's own word: **Schema** on [Oracle](/databases/oracle), **Catalog** on [Trino](/databases/trino), **Keyspace** on [Cassandra](/databases/cassandra). The commit control follows the tab: **Create Table** on a new table's tab, **Apply Changes** on [Users & Roles](/features/users-roles), **Save Changes** everywhere else. + +### What each context leaves out + +A control the tab cannot use leaves the toolbar rather than standing there dimmed. + +| Where you are | Not there | +|---|---| +| A new table's tab | Refresh. The definition is not on the server yet | +| ER diagram, Server Dashboard, Query Insights, a DDL tab | The commit control. None of the four stages an edit of its own | +| [Agent mode](/features/agent-mode) | Refresh and the commit control. The grid and the object browser are behind the conversation | +| An engine with nothing to switch between | The database control. On [SQLite](/databases/sqlite) and [DuckDB](/databases/duckdb) the file beside it is the database; on an engine with a single namespace there is no second level to name | + +That needs macOS 15. On macOS 13 and 14 the same eight stand in every context and dim where they cannot act, except in two places. Refresh stays enabled on a new table's tab. So does the commit control on an ER diagram, Server Dashboard, Query Insights or DDL tab while a **Truncate** or a **Delete** is staged in the sidebar: that queue belongs to the connection, not to the tab in front of it. + +The shape settles on a tab switch, a mode switch and a connection switch, and at no other moment. A staged edit, a running query or a dropped connection dims a control; it never moves one. + +## Actions + +One pull-down, rebuilt from the tab you are on each time it opens. Every entry is also a menu-bar command, so nothing lives only here. + +| On | Actions carries | +|---|---| +| A table tab | **Add Row** while the Data view is showing, **Restore Previous Values**, **Back**, **Forward**, **Preview SQL**, **Export Results**, **Show DDL**, **Copy DDL** | +| A query tab | **Restore Previous Values**, **Preview SQL**, **Show Results**, **Export Results** | +| A new table's tab | **Preview SQL** | +| A DDL tab | **Show DDL**, **Copy DDL** | +| Every browse tab | **Export Tables**, **Import Data** and its format list where the driver imports, **Show Query History**, **Users & Roles**, **Query Insights**, **Server Dashboard** where the engine has one, **New Tab**, **Open Quickly** | +| Agent mode | **New Session**, **Open Session**, **Close Session**, **Delete Session**, **New Conversation** | + +Every context ends with **Mode**, **Switch Connection** and **Close Connection**, and **Reconnect** joins them while the connection is down. A window that has not connected carries those alone, and **Mode** is absent with AI features off in **Settings > AI**. + +**Import Data** runs the driver's first format and **Import Data From** beside it lists every format it has, which is the same pair the menu bar draws under **File > Import**. + +## Add a control back + +The toolbar is still yours to arrange. Choose **View > Customize Toolbar**, or right-click the toolbar, and drag in any of these. + +| Tile | Runs | +|---|---| +| **Navigation** | Back and Forward, as one pair (`Ctrl+Cmd+[` and `Ctrl+Cmd+]`) | +| **New Tab** | A query tab (`Cmd+T`) | +| **Open Quickly** | The object and query search (`Cmd+Shift+O`) | +| **Add Row** | A row at the end of the grid (`Cmd+Shift+N`) | +| **Restore Previous Values** | The [rewind](/features/change-tracking#restoring-a-save) of the last save | +| **Preview** | The SQL behind the staged changes (`Cmd+Shift+P`) | +| **Results** | The query editor's results pane (`Cmd+Option+R`) | +| **Export** | Export Tables (`Cmd+Shift+E`) | +| **Import** | The import format list (`Cmd+Shift+I`) | +| **Dashboard** | The [server dashboard](/features/server-dashboard) | +| **History** | The [query history](/features/query-history) drawer (`Cmd+Y`) | + +A control you add yourself stays put. It stands in every context and dims when it has nothing to do, rather than leaving the way the default eight do. Your arrangement, and the **Show** setting in the same sheet, survive a relaunch. + + +The palette offers a tile for a control the tab you are on has hidden. Drag **Refresh** in while a new table's tab is in front and nothing appears until you switch tabs. + + +## The sidebar's list chooser + +**Tables** and **Favorites** sit above the filter field, at the top of the sidebar. **Tables** is the object browser; **Favorites** is the [favorites](/features/favorites) list for this connection. + +**View > Show Tables** and **View > Show Favorites** pick the same two and reveal a collapsed sidebar on the way. Neither ships with a shortcut. Bind one in **Settings > Keyboard**, where they are listed under **Navigation**. + +In [Agent mode](/features/agent-mode) the chooser and the filter field go, and the session list takes their height. + +## The trailing pane + +One column on the trailing edge, holding one surface at a time. Its header keeps one height across all three, so switching surface moves nothing beneath it. + +| Surface | Holds | +|---|---| +| **Inspector** | The selected row's fields, or the table's own details when nothing is selected. See [Cell and Row Viewers](/features/json-viewer) | +| **Assistant** | The connection's [chat](/features/ai-assistant#chat) | +| **Result** | What an agent session proposed, ran and changed | + +The picker at the leading end of the header moves between **Inspector** and **Assistant**, and the segment you pick is remembered for that connection. Where there is nothing to pick the header draws the surface's name instead: with AI features off in **Settings > AI** the pane holds the inspector alone, and Agent mode fixes it on **Result**. + +The ellipsis at the trailing end holds that surface's own commands: **Fields** and **JSON** plus the JSON reader's options on the inspector, **New Conversation**, **Conversation History** and **Clear Recents** on the assistant, and **SQL** and **Results** on the result column. + +`Cmd+Option+I` opens and closes the pane, and `Cmd+Option+A` swaps an open inspector for the assistant rather than closing the column. **View > Show Inspector** and **View > Show Assistant** are the same two, each reading **Hide** once its surface is on screen. + +## Related + +- [Query Tabs](/features/tabs) for the strip under the toolbar and what a tab is bound to +- [Connections strip](/features/workspace-rail) for the narrow strip on the leading edge +- [Keyboard Shortcuts](/features/keyboard-shortcuts) for every default binding diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 46923335a..f132dd50c 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -95,7 +95,7 @@ A column of a foreign key that spans several columns keeps the text editor. The The inspector's **JSON** view follows a key without leaving the row: see [Row as JSON](/features/json-viewer#row-as-json). -Step back with the Back and Forward buttons at the leading edge of the toolbar, or **View > Back** (`Ctrl+Cmd+[`) and **View > Forward** (`Ctrl+Cmd+]`). Back restores the table you came from as you left it: same filters, sort, page, and selected row. Each tab keeps its own history and Back never closes a tab. With unsaved edits in the tab, Back asks to discard them first; with a staged structure edit it stands down, because that work cannot be discarded from here. +Step back with **View > Back** (`Ctrl+Cmd+[`) and **View > Forward** (`Ctrl+Cmd+]`), or with the same pair under **Actions** in the toolbar. Add the buttons to the toolbar with [Customize Toolbar](/features/connection-window#add-a-control-back) to keep them in reach. Back restores the table you came from as you left it: same filters, sort, page, and selected row. Each tab keeps its own history and Back never closes a tab. With unsaved edits in the tab, Back asks to discard them first; with a staged structure edit it stands down, because that work cannot be discarded from here. Foreign key lookup @@ -104,7 +104,7 @@ Step back with the Back and Forward buttons at the leading edge of the toolbar, ## Inspector -Press `Cmd+Option+I`, or click the button at the trailing end of the toolbar, to open the inspector beside the grid. Its header names the table and which row of how many is selected, and a **Fields** / **JSON** control switches between two renderings of that row. [Cell and Row Viewers](/features/json-viewer) covers both. Turn on **Auto-show inspector on row select** in [Settings > Data](/customization/data-settings) to open it whenever a row is picked, in a query result as well as a table. +Press `Cmd+Option+I`, or click the button at the trailing end of the toolbar, to open the inspector beside the grid. The table and which row of how many is selected sit under the [pane's header](/features/connection-window#the-trailing-pane), and **Fields** and **JSON** are the two renderings of that row, picked from the header's ellipsis menu. [Cell and Row Viewers](/features/json-viewer) covers both. Turn on **Auto-show inspector on row select** in [Settings > Data](/customization/data-settings) to open it whenever a row is picked, in a query result as well as a table. Short values sit on one line, label leading and value trailing. Long text, JSON, PHP, binary and images take the full width. Long text and JSON carry a resize handle; PHP, binary and images draw at a fixed height. `Tab` and `Shift+Tab` move between fields; the menu at the trailing edge of each field carries **Set NULL**, **Set DEFAULT**, **Set EMPTY** and **SQL Functions**, with `Ctrl+Option+N` and `Ctrl+Option+D` as shortcuts for the first two. Search filters the list by column name or stored value, and the pencil button narrows it to fields already edited. diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index b1da4f9a6..639ba90c5 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -7,6 +7,8 @@ The query you retype every Monday is worth saving once. Give it the keyword `dau The Favorites tab holds databases, tables, saved SQL, a **Team Library** on a Team license, and any linked folders of `.sql` files. +Reach it from the **Tables** / **Favorites** control at the top of the sidebar, or from **View > Show Favorites**, which reveals a collapsed sidebar on the way. Neither command ships with a shortcut; bind one in **Settings > Keyboard**, under **Navigation**. + ## Database favorites | From | How | diff --git a/docs/features/filtering.mdx b/docs/features/filtering.mdx index 01e0741ee..0f0bc4b98 100644 --- a/docs/features/filtering.mdx +++ b/docs/features/filtering.mdx @@ -61,7 +61,7 @@ On MySQL, MariaDB, SQL Server, SQLite, libSQL, and Cloudflare D1 the column's co ⋯ > **Preview Query** shows the WHERE clause the rows produce, with a copy button. -An applied filter reaches an export only through the grid. **File > Export > Export Results…** writes the rows the grid holds; the toolbar export reads the table itself and ignores the filter bar. See [Import & Export](/features/import-export). +An applied filter reaches an export only through the grid. **File > Export > Export Results…** writes the rows the grid holds; **Export Tables…** beside it reads the table itself and ignores the filter bar. See [Import & Export](/features/import-export). ## Nested fields diff --git a/docs/features/import-export.mdx b/docs/features/import-export.mdx index e68c46d48..051c51579 100644 --- a/docs/features/import-export.mdx +++ b/docs/features/import-export.mdx @@ -259,6 +259,8 @@ Select a row in the data grid and press `Cmd+V` to paste tabular data straight i **File > Import > Import Data…** (`Cmd+Shift+I`) takes `.sql` and `.sql.gz` files, whose statements execute directly against the database, and `.json`, `.jsonl`, `.ndjson`, `.csv`, and `.tsv` files, which load into a table you pick or one TablePro creates. +It opens the first format this connection's driver offers. **File > Import > Import Data From** beside it lists every format the driver has, so a second one is one click rather than a change of file extension. **Actions** in the toolbar carries the same pair. + Choose **File > Import > Import Data…** and select the file. The sheet that opens depends on what you picked. diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index ed7d2e178..1e2b0a650 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -102,7 +102,7 @@ A value that refuses to open in one mode still opens in another. ## Row details inspector -**View > Show Inspector** (`Cmd+Option+I`), or the toolbar button at the trailing end, opens it. Each field carries its column name and type on one line, with the value on the line beneath, so the value gets the row's full width however long the column name is. A field's editor follows its content, with no **Display As** step, and the first match wins. +**View > Show Inspector** (`Cmd+Option+I`), or the toolbar button at the trailing end, opens it. The table and which row of how many is selected sit under the [pane's header](/features/connection-window#the-trailing-pane), then the fields. Each field carries its column name and type on one line, with the value on the line beneath, so the value gets the row's full width however long the column name is. A field's editor follows its content, with no **Display As** step, and the first match wins. | Content | Editor | | --- | --- | @@ -133,7 +133,9 @@ With no row selected, the inspector describes the table the tab is bound to: dat ## Row as JSON -Right-click a row and choose **Show Row as JSON**, or open the inspector and switch it to **JSON**. The row prints as one JSON object, keys in the result's own column order: numbers and booleans unquoted, NULL as `null`, binary as hex. Like **Fields** beside it, it prints every column the result carries, including any hidden in the grid. A JSON column, and a text column holding a document, arrive as nested keys instead of as a string. +Right-click a row and choose **Show Row as JSON**, or open the inspector and pick **JSON** from the ellipsis menu at the trailing end of the pane's header. The row prints as one JSON object, keys in the result's own column order: numbers and booleans unquoted, NULL as `null`, binary as hex. Like **Fields** beside it, it prints every column the result carries, including any hidden in the grid. A JSON column, and a text column holding a document, arrive as nested keys instead of as a string. + +The pair is offered for a data-grid row and for nothing else. A Structure tab's column definition has no JSON form, and neither does the table information the inspector shows with no row selected, so the choice is not in the menu there. Row as JSON @@ -142,7 +144,7 @@ Right-click a row and choose **Show Row as JSON**, or open the inspector and swi A foreign key carries a disclosure control. Click it and the referenced row is fetched and printed underneath; a foreign key inside that row expands the same way, five levels down. A key pointing back at a row already open in the tree, and one past the fifth level, carry a warning icon that says which. A NULL foreign key has no control. -**Always Expand Foreign Keys**, in the options menu at the trailing end of the filter field, fetches the first level on every row selected from then on. It starts off and stays off until you turn it on, every session, since each key it follows is a query. The rest of that menu is **Copy Visible**, which puts the printed lines on the pasteboard as they stand, **Collapse All**, and **Expand All**. Long text wraps rather than being cut. +**Always Expand Foreign Keys**, in the same header menu, fetches the first level on every row selected from then on. It starts off and stays off until you turn it on, every session, since each key it follows is a query. **Copy Visible** puts the printed lines on the pasteboard as they stand, and **Collapse All** and **Expand All** sit beside it. All four appear only while the JSON rendering is on screen. Long text wraps rather than being cut. The filter field takes text, or a regular expression wrapped in slashes such as `/^rental/`. It matches keys and values, keeps the keys that lead to a match along with everything under a key that matches, and opens what was collapsed. `Escape` clears it. An invalid expression outlines the field in red and filters nothing. diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 679638d63..881675370 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -114,6 +114,7 @@ Every row except the five find rows is built into the editor and cannot be rebou | Cancel edit | `Escape` | | Add row | `Cmd+Shift+N` | | Duplicate row | `Cmd+Shift+D` | +| Restore Previous Values | none by default, rebindable | | Delete selected rows | `Delete` or `Cmd+Delete` | | Truncate table (tables selected in the sidebar) | `Option+Delete` | | Preview FK reference | `Space` | @@ -222,6 +223,8 @@ See [Filtering](/features/filtering) for the filter bar itself. | Toggle inspector | `Cmd+Option+I` | | Toggle assistant | `Cmd+Option+A` | | Toggle agent mode | `Cmd+Option+Shift+A` | +| Show Tables | none by default, rebindable | +| Show Favorites | none by default, rebindable | | Toggle results | `Cmd+Option+R` | | Toggle history | `Cmd+Y` | | Zoom in: the focused diagram, otherwise the editor font | `Cmd+=` | @@ -232,6 +235,22 @@ See [Filtering](/features/filtering) for the filter bar itself. With the sidebar focused, typing the first letters of an object's name jumps to it. +### In Agent mode + +`Cmd+Option+I` opens and closes the Result column instead of the inspector, and the View menu's item reads **Show Result**. Toggle assistant and Focus inspector both dim: the conversation is the window's content column there and the pane beside it holds the result, so neither command has a surface to reach. Focus assistant puts the insertion point in the conversation's composer. + +### Agent sessions + +| Action | Shortcut | +|--------|----------| +| New Session | none by default, rebindable | +| Open Session | none by default, rebindable | +| Close Session | none by default, rebindable | +| Delete Session | none by default, rebindable | +| New Conversation | none by default, rebindable | + +All five sit under **File > Session** in the menu bar and under **Navigation** in **Settings > Keyboard**. `Return` opens the session the rail has highlighted and `Delete` asks to delete it, and neither is rebindable. See [Agent mode](/features/agent-mode#sessions). + ### Results | Action | Shortcut | @@ -296,6 +315,8 @@ Turn on Vim mode in **Settings > Editor**. Vim keys apply only in the SQL editor Open **Settings > Keyboard** (`Cmd+,`). Each action has a recorder field: click it and press the new combination, and the menu bar updates immediately. Actions are listed under their own names there: the sidebar toggle is Toggle Table Browser. Filter with the search field, press `Delete` in a recorder field to clear a binding, click the curved-arrow button beside a changed shortcut to restore that one, or **Reset to Defaults** to restore all. +Some actions ship with no default and show as unassigned. That is a command waiting for a binding, not one you cleared, and **Reset to Defaults** leaves it unassigned. + Keyboard settings Keyboard settings diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 845e61b81..71e378ec6 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -171,6 +171,9 @@ First time here: [Quick Start](/quickstart) gets you from install to a query wit ## Around the app + + The toolbar, the Actions menu, and the trailing pane. + A tab strip per connection, in one window. diff --git a/docs/features/query-history.mdx b/docs/features/query-history.mdx index 9bb5052fa..558677cb5 100644 --- a/docs/features/query-history.mdx +++ b/docs/features/query-history.mdx @@ -5,7 +5,7 @@ description: Every executed query is saved to a local SQLite database with full- By default the drawer shows only the SQL you wrote yourself. The SELECTs behind table browsing, the UPDATEs behind grid edits, the DDL behind a structure change and everything an MCP client ran are all recorded too, waiting behind the **Source** menu. -Open the drawer with `Cmd+Y` or **View > Show Query History**. There is also a **History** toolbar button, added through **View > Customize Toolbar…**. +Open the drawer with `Cmd+Y`, **View > Show Query History**, or **Show Query History** under **Actions** in the toolbar. A **History** button is in the palette too, through [Customize Toolbar](/features/connection-window#add-a-control-back). Query history drawer diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index c9e57b4ed..cd01dad0c 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -28,7 +28,7 @@ Four things the table cannot carry. The confirmation dialog shows the whole stat ## Connections that are always read-only -A connection that cannot take a write runs at **Read-Only** whatever level it was given. Its edit form shows the level as fixed text, and every other level is dimmed in the toolbar padlock and in **Database > Safe Mode Level**. The level you chose stays saved and applies again once the condition no longer holds. +A connection that cannot take a write runs at **Read-Only** whatever level it was given. Its edit form shows the level as fixed text, and **Read-Only** is the only level the padlock and **Database > Safe Mode Level** offer. The level you chose stays saved and applies again once the condition no longer holds. | Connection | Condition | |------------|-----------| @@ -40,6 +40,8 @@ A connection that cannot take a write runs at **Read-Only** whatever level it wa A connection open in [Agent mode](/features/agent-mode) runs at **Alert** or stricter for as long as the mode is on, so every write the assistant proposes waits for an answer. The level you chose is untouched and applies again on the way out. A connection already at Alert or stricter keeps its own. +The reason for the floor shows in three places while the mode is on: under the level list, in the padlock's tooltip, and on the line above the agent's transcript. + ## What the level gates Safe Mode sits in front of query execution, saving cell edits, structure and table changes, sidebar operations, imports, and maintenance jobs, including the ones the [AI assistant](/features/ai-assistant) and the MCP tools ask for. @@ -58,7 +60,9 @@ Where a string, a quoted name or a comment ends depends on the engine. A backsla ## Changing the level while connected -A padlock in the toolbar carries the current level: open for **Silent**, closed for the rest. Click it for the six levels with the current one ticked. **Database > Safe Mode Level** is the same list. +A padlock in the toolbar carries the current level: open for **Silent**, closed for the rest. Click it for the levels this connection may run at, with the current one ticked. **Database > Safe Mode Level** is the same list. + +Four things can hold the level above the one you chose: a read-only engine, a [remote database file](/connections/remote-database-files), a [managed policy](#managed-by-an-organization), and [Agent mode](/features/agent-mode). The weaker levels are then left out of both lists, the reason sits under them, and the padlock's tooltip carries the same sentence. Picking the level already in force writes nothing. Toolbar padlock with the six Safe Mode levels listed beneath it @@ -104,7 +108,7 @@ An administrator can impose a minimum level through a macOS configuration profil |---|---|---| | `com.TablePro.policy.minimumSafeModeLevel` | String | `silent`, `alert`, `alertFull`, `safeMode`, `safeModeFull`, or `readOnly` | -A connection set below the floor runs at it, and a stricter choice is left alone: the policy is a floor, never a ceiling. A value TablePro does not recognize imposes no floor at all. While the policy is in force, the levels below the floor are dimmed in the toolbar padlock and in **Database > Safe Mode Level**, and the connection form lists only the levels at or above it. The level you chose stays saved and applies again once the profile is removed. +A connection set below the floor runs at it, and a stricter choice is left alone: the policy is a floor, never a ceiling. A value TablePro does not recognize imposes no floor at all. While the policy is in force, the padlock, **Database > Safe Mode Level** and the connection form each list only the levels at or above it. The level you chose stays saved and applies again once the profile is removed. This is a floor on TablePro's own behavior, not on the database. It stops the app issuing a write; it does not stop the same person connecting with `psql`. Pair it with server-side privileges for anything that has to hold. diff --git a/docs/features/server-dashboard.mdx b/docs/features/server-dashboard.mdx index 93b975a73..3ce7735e3 100644 --- a/docs/features/server-dashboard.mdx +++ b/docs/features/server-dashboard.mdx @@ -5,7 +5,7 @@ description: Monitor active sessions, server metrics, and slow queries in real t What this screen shows is what your account is allowed to see. A MySQL user without the `PROCESS` privilege gets only its own connections in the session list. A PostgreSQL role that is neither a superuser nor a member of `pg_monitor` gets everyone's backends with the Query column blank. An empty or half-blank dashboard is nearly always a privilege, not a fault. -Open it from **Database > Server Dashboard**. A Dashboard toolbar button is available too: right-click the toolbar, choose **Customize Toolbar**, and drag it in. +Open it from **Database > Server Dashboard**, or from **Server Dashboard** under **Actions** in the toolbar. A **Dashboard** button is in the palette too, through [Customize Toolbar](/features/connection-window#add-a-control-back).