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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Per-connection MongoDB shell state, so a variable or function survives from one statement to the next.
- Cursor method autocomplete after `find()` and `aggregate()`.
- Copy To and Duplicate Database in the sidebar and the Database menu, carrying structure, data or both to any connection. (#2487)
- Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438)
- Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438)

### Changed

- MongoDB statements split as JavaScript rather than at every semicolon.
- MongoDB editor diagnostics report JavaScript syntax errors rather than unsupported method names.
- Editor tab presses handled by AppKit rather than SwiftUI gestures. (#2438)
- Connection-first labels with the database or schema on a second line in the connections strip. (#2550)

### Fixed

- Parse error on any MongoDB filter written in shell syntax, such as `db.orders.find({status: 1})`.
- MongoDB `.sort()` and `.projection()` silently ignored when written with unquoted keys.
- Tab drag doing nothing, about one drag in seven. (#2438)
- Tab drag released on a neighbour's exact centre leaving the order unchanged. (#2438)
- Compare & Sync unable to drop an overloaded PostgreSQL routine, or any trigger.
- PostgreSQL sequence DDL naming the schema it was read from, in SQL export and the structure editor.

Expand Down
13 changes: 12 additions & 1 deletion TablePro/Core/Services/Infrastructure/EditorTabReorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ internal enum EditorTabReorderResolver {
/// 2, whose midpoint has not been crossed, while tab 1's midpoint at 150 has been. Answering
/// for the tab under the pointer alone returns nothing there, and a quick drag then commits a
/// shorter move than the user made, or none at all.
/// How far past a midpoint counts as having crossed it.
///
/// Releasing on a neighbour's exact centre is the most ordinary one-place drag there is, and it
/// lands the pointer on the midpoint itself. Comparing for equality there made the move a coin
/// flip: the location arrives from a view geometry conversion, so it is 609.99998 as often as
/// it is 610. Half a point is below anything the eye or the hand can aim at, and it makes the
/// commonest drag deterministic.
internal static let crossingTolerance: CGFloat = 0.5

internal static func settledDestination(
forLocation location: CGFloat,
tabWidth: CGFloat,
Expand All @@ -102,7 +111,9 @@ internal enum EditorTabReorderResolver {

func hasCrossed(_ index: Int) -> Bool {
let centre = (CGFloat(index) + 0.5) * tabWidth
return candidate > currentIndex ? location >= centre : location <= centre
return candidate > currentIndex
? location >= centre - crossingTolerance
: location <= centre + crossingTolerance
}

let crossable = candidate > currentIndex
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// EditorTabStripPaneController.swift
// TablePro
//

import AppKit
import SwiftUI

/// One connection's tab strip pane: an AppKit view that owns the pointer, wrapped around the
/// SwiftUI view that draws.
///
/// It is a pane like the other three, built and kept alive per connection, so it carries the same
/// `sizingOptions = []` firewall that stops tab content publishing a minimum width the window's
/// split dividers cannot beat (#1872).
///
/// The height is published rather than fixed. A wrapped strip is taller than a scrolling one, and
/// `preferredContentSize` is the documented way for a child to tell its parent so; the titlebar
/// accessory grows the band from it, which is what keeps the content below laid out around the
/// strip instead of behind it.
@MainActor
internal final class EditorTabStripPaneController: NSViewController {
internal let interaction = EditorTabStripInteraction()

private let hosting = NSHostingController(rootView: AnyView(Color.clear))

internal var rootView: AnyView {
get { hosting.rootView }
set { hosting.rootView = newValue }
}

override internal func loadView() {
let surface = EditorTabInteractionView(interaction: interaction)
surface.onRowCountChanged = { [weak self] rows in
self?.publishHeight(forRowCount: rows)
}
view = surface

hosting.sizingOptions = []
addChild(hosting)
let pane = hosting.view
pane.translatesAutoresizingMaskIntoConstraints = false
surface.addSubview(pane)
NSLayoutConstraint.activate([
pane.leadingAnchor.constraint(equalTo: surface.leadingAnchor),
pane.trailingAnchor.constraint(equalTo: surface.trailingAnchor),
pane.topAnchor.constraint(equalTo: surface.topAnchor),
pane.bottomAnchor.constraint(equalTo: surface.bottomAnchor),
])
publishHeight(forRowCount: 1)
}

/// Empties the pane the way every other one is emptied, including the explicit layout pass a
/// detached hosting controller needs before SwiftUI will dismantle its tree.
internal func teardown() {
interaction.commands = nil
rootView = AnyView(Color.clear)
hosting.view.layoutSubtreeIfNeeded()
view.removeFromSuperview()
removeFromParent()
}

private func publishHeight(forRowCount rows: Int) {
let height = EditorTabStripLayout.bandHeight(forRowCount: rows)
guard preferredContentSize.height != height else { return }
preferredContentSize = CGSize(width: 0, height: height)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,23 +80,21 @@ internal extension MainSplitViewController {
/// `commandActions` is read at click time rather than captured, because it only exists once
/// the detail pane has appeared and this strip is built alongside that pane, not after it.
/// The workspace is held weakly: it owns the hosting controller these closures live in.
///
/// The command set is handed to the pane's interaction object rather than to the SwiftUI view,
/// because the view is no longer what receives a press. AppKit owns the pointer over the
/// strip and reaches the app through exactly these closures.
@ViewBuilder
private func buildTabStripView(for workspace: ConnectionWorkspace) -> some View {
if let sessionState = workspace.sessionState {
let interaction = workspace.panes.tabStrip.interaction
let _ = configure(interaction, for: workspace, sessionState: sessionState)
EditorTabStrip(
tabManager: sessionState.tabManager,
interaction: interaction,
containerTarget: workspace.connection.flatMap {
PluginManager.shared.containerSwitchTarget(for: $0.type)
},
onClose: { [weak workspace] id in
workspace?.sessionState?.coordinator.commandActions?.closeTab(id: id)
},
onCloseOthers: { [weak workspace] id in
workspace?.sessionState?.coordinator.commandActions?.closeOtherTabs(anchoredOn: id)
},
onCloseAll: { [weak workspace] in
workspace?.sessionState?.coordinator.commandActions?.closeAllTabs()
},
onNewTab: { [weak workspace] in
workspace?.sessionState?.coordinator.commandActions?.newTab()
}
Expand All @@ -105,4 +103,45 @@ internal extension MainSplitViewController {
Color.clear
}
}

private func configure(
_ interaction: EditorTabStripInteraction,
for workspace: ConnectionWorkspace,
sessionState: SessionStateFactory.SessionState
) {
let manager = sessionState.tabManager
let target = workspace.connection.flatMap { PluginManager.shared.containerSwitchTarget(for: $0.type) }
interaction.commands = EditorTabCommands(
activate: { [weak manager] id in manager?.selectedTabId = id },
keepOpen: { [weak manager] id in manager?.promotePreviewTab(id: id) },
canKeepOpen: { [weak manager] id in manager?.canPromotePreviewTab(id: id) ?? false },
close: { [weak workspace] id in
workspace?.sessionState?.coordinator.commandActions?.closeTab(id: id)
},
closeOthers: { [weak workspace] id in
workspace?.sessionState?.coordinator.commandActions?.closeOtherTabs(anchoredOn: id)
},
closeAll: { [weak workspace] in
workspace?.sessionState?.coordinator.commandActions?.closeAllTabs()
},
moveTab: { [weak manager] id, destination in manager?.moveTab(id: id, to: destination) },
canMove: { [weak manager] id, offset in manager?.canMoveTab(id: id, by: offset) ?? false },
moveBy: { [weak manager] id, offset in manager?.moveTab(id: id, by: offset) },
tearOff: { _ in },
canTearOff: { _ in false },
/// The resolver's description, not the drawn title: a table tab carries its database
/// and schema there even when the short title is unique, and the tooltip is where a
/// truncated or duplicated name is told apart.
tooltip: { [weak manager] id in
guard let manager, let tab = manager.tabs.first(where: { $0.id == id }) else { return "" }
let description = EditorTabLabelResolver.resolve(tabs: manager.tabs, target: target)[id]?
.description ?? tab.title
guard tab.isPreview else { return description }
return String(
format: String(localized: "%@\nPreview tab. Double-click to keep it open."),
description
)
}
)
}
}
15 changes: 14 additions & 1 deletion TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ internal final class WorkspacePaneHost: NSViewController {
view = NSView()
}

/// A pane can publish a height, which the editor tab strip does when it wraps onto more rows.
/// `NSViewController` only tells the immediate parent, so this container passes it on to
/// whatever is hosting it.
override internal func preferredContentSizeDidChange(for viewController: NSViewController) {
super.preferredContentSizeDidChange(for: viewController)
guard viewController === shown else { return }
preferredContentSize = viewController.preferredContentSize
}

internal func show(_ controller: NSViewController?) {
guard shown !== controller else { return }

Expand All @@ -35,8 +44,12 @@ internal final class WorkspacePaneHost: NSViewController {
}
shown = controller

guard let controller else { return }
guard let controller else {
preferredContentSize = .zero
return
}
addChild(controller)
preferredContentSize = controller.preferredContentSize
let pane = controller.view
pane.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pane)
Expand Down
11 changes: 8 additions & 3 deletions TablePro/Core/Services/Infrastructure/WorkspacePanes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ internal final class WorkspacePanes {
/// connection, even though the window shows it in the titlebar accessory rather than in a
/// split item. Holding it here is what gives it the same `sizingOptions` firewall and the
/// same teardown as everything else the connection owns.
internal let tabStrip: NSHostingController<AnyView>
///
/// It is the one pane that is not a bare hosting controller. AppKit owns the pointer over the
/// strip, so the SwiftUI view is wrapped in the view that owns it; see
/// `EditorTabStripPaneController`.
internal let tabStrip: EditorTabStripPaneController

/// Written by the one function that produces pane content, and read by the one that decides
/// whether it has to run. `nil` means the panes hold nothing anybody has vouched for.
Expand All @@ -59,14 +63,14 @@ internal final class WorkspacePanes {
detail = NSHostingController(rootView: AnyView(Color.clear))
inspector = NSHostingController(rootView: AnyView(Color.clear))
sidebar = NSHostingController(rootView: AnyView(Color.clear))
tabStrip = NSHostingController(rootView: AnyView(Color.clear))
tabStrip = EditorTabStripPaneController()
for pane in panes {
pane.sizingOptions = []
}
}

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

internal func markRendered(_ key: WorkspacePaneRenderKey) {
Expand Down Expand Up @@ -101,5 +105,6 @@ internal final class WorkspacePanes {
pane.view.removeFromSuperview()
pane.removeFromParent()
}
tabStrip.teardown()
}
}
7 changes: 6 additions & 1 deletion TablePro/Models/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -350,14 +350,19 @@ struct HistorySettings: Codable, Equatable {
/// Tab behavior settings
struct TabSettings: Codable, Equatable {
var enablePreviewTabs: Bool = true
/// What the strip does once the tabs stop fitting. `scroll` is the system's answer and the
/// default: every tab bar Apple ships keeps one row and scrolls it.
var overflow: EditorTabStripOverflow = .scroll
static let `default` = TabSettings()

init(enablePreviewTabs: Bool = true) {
init(enablePreviewTabs: Bool = true, overflow: EditorTabStripOverflow = .scroll) {
self.enablePreviewTabs = enablePreviewTabs
self.overflow = overflow
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
enablePreviewTabs = try container.decodeIfPresent(Bool.self, forKey: .enablePreviewTabs) ?? true
overflow = try container.decodeIfPresent(EditorTabStripOverflow.self, forKey: .overflow) ?? .scroll
}
}
84 changes: 84 additions & 0 deletions TablePro/Views/Main/EditorTabContextMenuBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// EditorTabContextMenuBuilder.swift
// TablePro
//

import AppKit

/// The tab's contextual menu, built in AppKit because AppKit now owns the press.
///
/// The strip keeps its SwiftUI `.contextMenu` as well, and the two never both fire: a right-click
/// resolves through `NSView.menu(for:)` on the view that owns the press, and the SwiftUI menu is
/// left as the route VoiceOver and Full Keyboard Access already take to the same commands.
@MainActor
internal enum EditorTabContextMenuBuilder {
internal static func menu(for tabId: UUID, commands: EditorTabCommands) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false

append(
menu,
title: String(localized: "Keep Open"),
isEnabled: commands.canKeepOpen(tabId)
) { commands.keepOpen(tabId) }

menu.addItem(.separator())

append(menu, title: String(localized: "Close Tab")) { commands.close(tabId) }
append(menu, title: String(localized: "Close Other Tabs")) { commands.closeOthers(tabId) }
append(menu, title: String(localized: "Close All Tabs")) { commands.closeAll() }

menu.addItem(.separator())

append(
menu,
title: String(localized: "Move Tab Left"),
isEnabled: commands.canMove(tabId, -1)
) { commands.moveBy(tabId, -1) }
append(
menu,
title: String(localized: "Move Tab Right"),
isEnabled: commands.canMove(tabId, 1)
) { commands.moveBy(tabId, 1) }

menu.addItem(.separator())

append(
menu,
title: String(localized: "Move Tab to New Window"),
isEnabled: commands.canTearOff(tabId)
) { commands.tearOff(tabId) }

return menu
}

private static func append(
_ menu: NSMenu,
title: String,
isEnabled: Bool = true,
action: @escaping () -> Void
) {
let item = NSMenuItem(title: title, action: #selector(ClosureMenuTarget.fire), keyEquivalent: "")
let target = ClosureMenuTarget(action: action)
item.target = target
item.representedObject = target
item.isEnabled = isEnabled
menu.addItem(item)
}
}

/// `NSMenuItem` holds its target weakly, so the closure needs an owner that outlives the menu.
/// `representedObject` is that owner: it is strong, it belongs to the item, and it goes when the
/// item does.
@MainActor
private final class ClosureMenuTarget: NSObject {
private let action: () -> Void

internal init(action: @escaping () -> Void) {
self.action = action
}

@objc internal func fire() {
action()
}
}
Loading
Loading