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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Row grid for data Compare & Sync with every column shown and each differing value marked. (#2537)
- Acknowledgements entries for the four tree-sitter grammars the SQL editor ships.
- **Edit > Find > Find and Replace…** (`Cmd+Option+F`) and **Use Selection for Find** (`Cmd+E`) in the SQL editor.
- **Run** split button in the query editor, with Run All Statements, Run Without Limit, Clear Query and Clear Results on its menu.
- **Stop** in the query editor while a query is running.
- **Query > Clear Query** and **Query > Clear Results**.
- Result chooser in the status bar, naming the result on screen and offering Pin, Unpin, Close and Close Others.
- A reason on a dimmed Run, Explain, Format or Favorite saying why it cannot run.

### Changed

Expand All @@ -50,14 +55,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Connection rows without colored dots, on the Mac and on iOS.
- SQL Server sessions open with the ANSI SET profile the server requires, matching every other client.
- Compared columns in data Compare & Sync chosen per table, and saved with each table's key, filter and row limit. (#2537)
- Query history opens in the trailing pane beside the inspector and the assistant, with its entry list above its detail.
- Query editor command bar with one control size, the container picker leading and the commands trailing.

### Removed

- CodeEditSymbols, a dependency the editor linked and never called, from the app and from Acknowledgements.
- `Ctrl+Cmd+J` from the editor's reserved shortcuts, so it can be bound in Settings > Keyboard.
- Result tab strip above the query results, and the "Query" heading above the editor.
- Trash button that cleared the query and the results under one name.
- Query history drawer under the editor and results.

### Fixed

- Stale error banner over a pinned result after clearing the results of a failed query.
- Imported connections pointing at an SSH profile that is not on the importing Mac.
- Syntax highlighting falling a second or two behind while typing quickly in the SQL editor.
- Beep and a question-mark badge when pressing `Ctrl+Cmd+J` in the SQL editor.
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Core/Menu/QueryMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ enum QueryMenuBuilder {
keyboard: keyboard
),
MenuItemFactory.separator,
/// Two commands because they are two effects. The editor's trash button did both under
/// the single name "Clear Query", so neither was announced for what it was and neither
/// had a menu-bar route, which is what the HIG requires before a toolbar item may carry
/// it. They take no default shortcut: both are destructive and infrequent.
MenuItemFactory.item(
String(localized: "Clear Query"),
action: #selector(MainSplitViewController.clearQuery(_:))
),
MenuItemFactory.item(
String(localized: "Clear Results"),
action: #selector(MainSplitViewController.clearResults(_:))
),
MenuItemFactory.separator,
MenuItemFactory.item(
String(localized: "Explain Query"),
action: #selector(MainSplitViewController.explainQuery(_:)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ extension MainSplitViewController {
commandActions?.saveAsFavorite()
}

@objc func clearQuery(_ sender: Any?) {
commandActions?.clearQuery()
}

@objc func clearResults(_ sender: Any?) {
commandActions?.clearResults()
}

@objc func previewFKReference(_ sender: Any?) {
commandActions?.previewFKReference()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,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.history.rootView = AnyView(buildHistoryView(for: workspace))
refreshTabStripPane(of: workspace)
workspace.panes.markRendered(workspace.paneRenderKey)
guard isShowing(workspace) else { return }
Expand Down Expand Up @@ -897,6 +898,16 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan
}
}

@ViewBuilder
private func buildHistoryView(for workspace: ConnectionWorkspace) -> some View {
if workspace.resolvedPane == .content,
let coordinator = workspace.sessionState?.coordinator {
HistoryPanelView(coordinator: coordinator)
} else {
TrailingPaneUnavailableView(surface: .history)
}
}

/// Rebuilds the trailing surfaces alone. `commandActions` is read eagerly by both, and it only
/// exists once the detail pane has appeared, which is after `rebuildPanes()` has already built
/// them against a nil value. Rebuilding the detail pane too would remount the very view that
Expand All @@ -905,6 +916,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan
guard let selected = workspaces.selected else { return }
selected.panes.inspector.rootView = AnyView(buildInspectorView(for: selected))
selected.panes.assistant.rootView = AnyView(buildAssistantView(for: selected))
selected.panes.history.rootView = AnyView(buildHistoryView(for: selected))
}

/// Parents whichever surface the selected workspace is showing.
Expand Down Expand Up @@ -1006,6 +1018,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan
isTrailingPaneOpen && resolvedTrailingSurface == .assistant
}

var isHistoryVisible: Bool {
isTrailingPaneOpen && resolvedTrailingSurface == .history
}

func showInspector() {
reveal(.inspector)
}
Expand All @@ -1015,6 +1031,10 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan
reveal(.assistant)
}

func showHistory() {
reveal(.history)
}

/// 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.
Expand All @@ -1025,9 +1045,25 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan

func hideTrailingPane() {
inspectorSplitItem?.animator().isCollapsed = true
syncHistoryPanelVisibility()
recomputeWindowMinSize()
}

/// Keeps `HistoryPanelState.isVisible` saying what the window is actually showing.
///
/// The flag is not redundant with the surface. It is what `HistoryPanelView`'s `.task(id:)`
/// keys its activation on, so the view model only builds and starts querying while history is
/// on screen, and it is what the Find Past Queries tip reads to know it has been answered.
/// Leaving it behind when history moved into the trailing pane would have left the panel
/// mounted and inert, which is the shape of a pane that renders nothing forever.
private func syncHistoryPanelVisibility() {
guard let connectionId = workspaces.selected?.connectionId else { return }
let showing = isTrailingPaneOpen && resolvedTrailingSurface == .history
let state = HistoryPanelState.forConnection(connectionId)
guard state.isVisible != showing else { return }
state.isVisible = showing
}

/// 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
Expand All @@ -1044,6 +1080,7 @@ internal final class MainSplitViewController: NSSplitViewController, TrailingPan
rebuildTrailingPanes()
showSelectedTrailingPane()
inspectorSplitItem?.animator().isCollapsed = false
syncHistoryPanelVisibility()
recomputeWindowMinSize()
}

Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate {
_ = self?.coordinator?.toolbarState.hasDataPendingChanges
_ = self?.coordinator?.toolbarState.safeModeLevel
_ = self?.coordinator?.toolbarState.currentDatabase
_ = self?.coordinator?.toolbarState.isQueryTab
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self,
Expand Down Expand Up @@ -365,6 +366,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate {
/// `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.
///
internal static let allowedItemIdentifiers: [NSToolbarItem.Identifier] = defaultItemIdentifiers + [
previewSQL,
results,
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Services/Infrastructure/TrailingPaneProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import Foundation
internal protocol TrailingPaneProxy: AnyObject {
var isInspectorVisible: Bool { get }
var isAssistantVisible: Bool { get }
var isHistoryVisible: Bool { get }
func showInspector()
func showAssistant()
func showHistory()
func hideTrailingPane()

/// Reveals the inspector for a selection the user made somewhere else, and only if that does
Expand All @@ -37,4 +39,8 @@ internal extension TrailingPaneProxy {
func toggleAssistant() {
if isAssistantVisible { hideTrailingPane() } else { showAssistant() }
}

func toggleHistory() {
if isHistoryVisible { hideTrailingPane() } else { showHistory() }
}
}
8 changes: 7 additions & 1 deletion TablePro/Core/Services/Infrastructure/WorkspacePanes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ internal final class WorkspacePanes {
/// only way it gets the `sizingOptions` firewall below, which is applied here and nowhere else.
internal let assistant: NSHostingController<AnyView>

/// Query history, on the same terms as the assistant: its own controller so the list's scroll
/// position, the selected entry and a half-typed search survive the reader looking at a row.
internal let history: NSHostingController<AnyView>

internal let sidebar: NSHostingController<AnyView>
/// 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
Expand All @@ -72,6 +76,7 @@ internal final class WorkspacePanes {
detail = NSHostingController(rootView: AnyView(Color.clear))
inspector = NSHostingController(rootView: AnyView(Color.clear))
assistant = NSHostingController(rootView: AnyView(Color.clear))
history = NSHostingController(rootView: AnyView(Color.clear))
sidebar = NSHostingController(rootView: AnyView(Color.clear))
tabStrip = EditorTabStripPaneController()
for pane in panes {
Expand All @@ -80,7 +85,7 @@ internal final class WorkspacePanes {
}

private var panes: [NSHostingController<AnyView>] {
[detail, inspector, assistant, sidebar]
[detail, inspector, assistant, history, sidebar]
}

/// The controller a trailing surface is drawn by. One split item hosts whichever of these the
Expand All @@ -89,6 +94,7 @@ internal final class WorkspacePanes {
switch surface {
case .inspector: inspector
case .assistant: assistant
case .history: history
}
}

Expand Down
4 changes: 4 additions & 0 deletions TablePro/Models/Connection/ConnectionToolbarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ final class ConnectionToolbarState {
/// Whether the current editor has non-empty query text
var hasQueryText: Bool = false

/// Whether the selected tab is a query tab. `isTableTab` cannot answer this: a structure,
/// dashboard or diagram tab is neither, and the Run item has to be disabled on all of them.
var isQueryTab: Bool = false

/// SQL statements rendered in the SQL preview sheet
var previewStatements: [String] = []

Expand Down
92 changes: 92 additions & 0 deletions TablePro/Models/Query/QueryCommandAvailability.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// QueryCommandAvailability.swift
// TablePro
//

import Foundation
import TableProPluginKit

/// What the query tab's command bar can do right now.
///
/// Pure, the way `ResultStatusModel` and `QueryResultPresentation` are, so the whole enable matrix
/// is decidable without mounting a view. The bar used to answer this inline: `disabled(!hasQuery)`
/// written out at four call sites, each with its own idea of what "has a query" meant, and Format
/// with no gate at all.
struct QueryCommandAvailability {
let canRun: Bool
let canStop: Bool
let canExplain: Bool
let canFormat: Bool
let canSaveAsFavorite: Bool
let canClearQuery: Bool
let canClearResults: Bool
let explainVariants: [ExplainVariant]

/// Every hint the bar shows, resolved here so a disabled control can say why rather than just
/// dimming. A control that dims without explaining is the one thing a reader cannot act on.
let runHint: String
let stopHint: String
let explainHint: String
let formatHint: String
let favoriteHint: String

init(
isConnected: Bool,
hasQueryText: Bool,
isExecuting: Bool,
hasResults: Bool,
explainVariants: [ExplainVariant],
shortcutHint: (String, ShortcutAction) -> String
) {
self.explainVariants = explainVariants
canRun = isConnected && hasQueryText && !isExecuting
canStop = isExecuting
canExplain = isConnected && hasQueryText && !isExecuting && !explainVariants.isEmpty
/// Formatting rewrites text the reader already has, so it does not wait for a server.
canFormat = hasQueryText
canSaveAsFavorite = hasQueryText
canClearQuery = hasQueryText
canClearResults = hasResults

runHint = Self.hint(
base: shortcutHint(String(localized: "Run"), .executeQuery),
reason: Self.blockedReason(isConnected: isConnected, hasQueryText: hasQueryText, isExecuting: isExecuting)
)
stopHint = shortcutHint(String(localized: "Stop"), .cancelQuery)
explainHint = Self.hint(
base: shortcutHint(String(localized: "Explain"), .explainQuery),
reason: explainVariants.isEmpty
? String(localized: "This database does not explain statements.")
: Self.blockedReason(isConnected: isConnected, hasQueryText: hasQueryText, isExecuting: isExecuting)
)
formatHint = Self.hint(
base: shortcutHint(String(localized: "Format"), .formatQuery),
reason: hasQueryText ? nil : String(localized: "There is nothing to format yet.")
)
favoriteHint = Self.hint(
base: shortcutHint(String(localized: "Save as Favorite"), .saveAsFavorite),
reason: hasQueryText ? nil : String(localized: "There is nothing to save yet.")
)
}

private static func blockedReason(isConnected: Bool, hasQueryText: Bool, isExecuting: Bool) -> String? {
if isExecuting { return String(localized: "A query is already running.") }
if !hasQueryText { return String(localized: "There is nothing to run yet.") }
if !isConnected { return String(localized: "This connection is not available.") }
return nil
}

private static func hint(base: String, reason: String?) -> String {
guard let reason else { return base }
return "\(base)\n\(reason)"
}
}

/// What the editor bar's leading control names: the container this tab's SQL runs in.
struct QueryScopeBarModel {
let containers: [DatabaseMetadata]
let selectedName: String
let entityName: String
let isReadOnly: Bool
let schemaName: String?
}
Loading
Loading