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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <index> <command>` 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

Expand All @@ -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.
Expand Down
11 changes: 6 additions & 5 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
64 changes: 64 additions & 0 deletions TablePro/Core/Menu/AgentSessionMenuDelegate.swift
Original file line number Diff line number Diff line change
@@ -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<AnyObject?>,
action: UnsafeMutablePointer<Selector?>
) -> Bool {
false
}
}
44 changes: 44 additions & 0 deletions TablePro/Core/Menu/ContentModeMenuDelegate.swift
Original file line number Diff line number Diff line change
@@ -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<AnyObject?>,
action: UnsafeMutablePointer<Selector?>
) -> Bool {
false
}
}
58 changes: 58 additions & 0 deletions TablePro/Core/Menu/ConversationHistoryMenuDelegate.swift
Original file line number Diff line number Diff line change
@@ -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<AnyObject?>,
action: UnsafeMutablePointer<Selector?>
) -> Bool {
false
}
}
4 changes: 3 additions & 1 deletion TablePro/Core/Menu/EditMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
87 changes: 87 additions & 0 deletions TablePro/Core/Menu/FileMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -28,6 +31,7 @@ enum FileMenuBuilder {
shortcut: .newTab,
keyboard: keyboard
),
sessionSubmenu(keyboard: keyboard),
MenuItemFactory.item(
String(localized: "Manage Connections"),
action: #selector(AppDelegate.manageConnections(_:)),
Expand Down Expand Up @@ -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(
Expand All @@ -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…"),
Expand All @@ -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(
Expand Down
Loading
Loading