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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Breakdown of a query's time into server, first row and transfer, behind the toolbar's duration readout. (#2503)
- Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516)
- Jump to Column in the grid, a fuzzy search over the result's columns with their type and position. (#2495)
- Connection groups in Switch Connection, with `Cmd`-click to open a saved connection in a new window. (#1311)

### Changed

Expand Down
32 changes: 32 additions & 0 deletions TablePro/Core/Services/Infrastructure/TabRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,38 @@ internal final class TabRouter {
try await openConnection(id: connection.id, transientConnection: connection)
}

/// Open a saved connection in a window of its own. One some window already hosts takes the
/// ordinary route instead, which selects it where it already is.
///
/// The host is checked here rather than by the caller. A caller decides on a modifier key and
/// the work runs a main-actor job later, and anything else that opens a connection in between,
/// the MCP tool among them, would leave that answer stale and two workspaces restoring the same
/// tabs. Nothing is awaited between the question and the window.
///
/// No pre-connect script prompt here, matching the window-opening half of `openConnection`. A
/// window whose connection carries a script does not auto-connect at all: it waits in its
/// not-connected state, where Connect asks. Asking first would put the same question twice and
/// the first answer would change nothing.
internal func openConnectionPreferringNewWindow(id: UUID) async throws {
guard WindowManager.shared.window(for: id) == nil else {
try await openConnection(id: id)
return
}
guard let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == id }) else {
throw TabRouterError.connectionNotFound(id)
}

let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)
WindowManager.shared.openInNewWindow(
payload: payload,
activate: true,
autoConnect: true,
joinsTabGroup: false
)
AppActivationPolicyController.shared.activate(ignoringOtherApps: true)
WindowOpener.shared.closeWelcome()
}

private func openConnection(id: UUID, transientConnection: DatabaseConnection? = nil) async throws {
let connection: DatabaseConnection
if let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == id }) {
Expand Down
26 changes: 24 additions & 2 deletions TablePro/Core/Services/Infrastructure/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,18 @@ internal final class WindowManager {
return true
}

private func openInNewWindow(payload: EditorTabPayload, activate: Bool, autoConnect: Bool) {
/// Forced, rather than the adoption `openTab` prefers.
///
/// `joinsTabGroup` is the difference between "not in the window it would have been adopted
/// into" and "in a window of its own". Left alone, this joins the existing group, so a caller
/// that wants two connections side by side has to say so: without it the new window arrives as
/// a native tab of the very window it was meant to sit beside.
internal func openInNewWindow(
payload: EditorTabPayload,
activate: Bool,
autoConnect: Bool,
joinsTabGroup: Bool = true
) {
let t0 = Date()
Self.lifecycleLogger.info(
"[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute) activate=\(activate)"
Expand Down Expand Up @@ -282,8 +293,9 @@ internal final class WindowManager {
// orderFront before addTabbedWindow avoids a synchronous full-tree
// SwiftUI layout pass that adds 700-900ms per open.
let tabbingId = window.tabbingIdentifier
let sibling = joinsTabGroup ? findSibling(tabbingIdentifier: tabbingId, excluding: window) : nil

if let sibling = findSibling(tabbingIdentifier: tabbingId, excluding: window) {
if let sibling {
let target = sibling.tabbedWindows?.last ?? sibling
target.addTabbedWindow(window, ordered: .above)
if activate {
Expand All @@ -293,12 +305,22 @@ internal final class WindowManager {
"[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)"
)
} else {
/// The system preference can tab a window on its own, without anyone asking AppKit to,
/// so a window asked to stand apart refuses for the moment it is placed and allows it
/// again straight after: standing apart now does not cost it the right to be merged by
/// hand later.
if !joinsTabGroup {
window.tabbingMode = .disallowed
}
if activate {
window.makeKeyAndOrderFront(nil)
AppActivationPolicyController.shared.activate(ignoringOtherApps: true)
} else {
window.orderFront(nil)
}
if !joinsTabGroup {
window.tabbingMode = .automatic
}
Self.lifecycleLogger.info(
"[open] WindowManager standalone window payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)"
)
Expand Down
34 changes: 34 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4528,6 +4528,40 @@
}
}
},
"UNGROUPED" : {
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "그룹 없음"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "GRUPSUZ"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "KHÔNG THUỘC NHÓM"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "未分组"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "未分組"
}
}
}
},
"%@x" : {
"localizations" : {
"ko" : {
Expand Down
52 changes: 47 additions & 5 deletions TablePro/Views/Shared/FieldDrivenList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ import SwiftUI
internal struct FieldDrivenListSection<Item: Identifiable>: Identifiable {
internal let id: String
internal let title: String?
/// Drawn as a dot beside the title, for a section that stands for something the user gave a
/// colour to. Nil leaves the header as a plain label.
internal let accentColor: NSColor?
internal let items: [Item]

internal init(id: String, title: String? = nil, items: [Item]) {
internal init(id: String, title: String? = nil, accentColor: NSColor? = nil, items: [Item]) {
self.id = id
self.title = title
self.accentColor = accentColor
self.items = items
}
}
Expand Down Expand Up @@ -213,8 +217,8 @@ internal struct FieldDrivenList<Item: Identifiable, Row: View>: NSViewRepresenta
internal func tableView(_ tableView: NSTableView, viewFor column: NSTableColumn?, row: Int) -> NSView? {
guard row < entries.count else { return nil }
switch entries[row] {
case .header(_, let title):
return FieldDrivenHeaderView.make(title: title)
case .header(_, let title, let accentColor):
return FieldDrivenHeaderView.make(title: title, accentColor: accentColor)
case .item(let item):
let cell = tableView.makeView(
withIdentifier: FieldDrivenCellView<Row>.reuseIdentifier,
Expand Down Expand Up @@ -445,7 +449,9 @@ internal final class FieldDrivenCellView<Row: View>: NSTableCellView {
}

internal enum FieldDrivenHeaderView {
internal static func make(title: String) -> NSView {
private static let dotSize: CGFloat = 6

internal static func make(title: String, accentColor: NSColor? = nil) -> NSView {
let label = NSTextField(labelWithString: title)
label.font = .preferredFont(forTextStyle: .caption1)
label.textColor = .secondaryLabelColor
Expand All @@ -454,10 +460,46 @@ internal enum FieldDrivenHeaderView {
let container = NSView()
container.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
label.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor),
label.centerYAnchor.constraint(equalTo: container.centerYAnchor),
])

guard let accentColor else {
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4).isActive = true
return container
}

let dot = ColorDotView(color: accentColor)
dot.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(dot)
NSLayoutConstraint.activate([
dot.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
dot.centerYAnchor.constraint(equalTo: label.centerYAnchor),
dot.widthAnchor.constraint(equalToConstant: dotSize),
dot.heightAnchor.constraint(equalToConstant: dotSize),
label.leadingAnchor.constraint(equalTo: dot.trailingAnchor, constant: 5),
])
return container
}
}

/// A dot that repaints itself when the appearance changes, because a dynamic system colour
/// resolved once into a layer stays at the appearance it was resolved in.
private final class ColorDotView: NSView {
private let color: NSColor

init(color: NSColor) {
self.color = color
super.init(frame: .zero)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("ColorDotView does not support NSCoder init")
}

override func draw(_ dirtyRect: NSRect) {
color.setFill()
NSBezierPath(ovalIn: bounds).fill()
}
}
22 changes: 17 additions & 5 deletions TablePro/Views/Shared/FieldDrivenListEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
// TablePro
//

import Foundation
import AppKit

/// One row of a `FieldDrivenList`, after sections have been flattened into the single index space
/// an `NSTableView` works in.
internal enum FieldDrivenListEntry<Item: Identifiable> where Item.ID: Hashable {
case header(id: String, title: String)
case header(id: String, title: String, accentColor: NSColor?)
case item(Item)

internal var isHeader: Bool {
Expand All @@ -23,10 +23,16 @@ internal enum FieldDrivenListEntry<Item: Identifiable> where Item.ID: Hashable {

/// Identity, not content. A refilter that produces the same rows in the same order reloads
/// nothing, which keeps the hosted SwiftUI views and their state alive.
///
/// A header is the exception: only item rows are refreshed in place, so a header identified by
/// its section id alone would keep a group's old name and colour on screen after a rename
/// arrives from another device. Its drawn content is part of what identifies it.
internal var identity: AnyHashable {
switch self {
case .header(let id, _): return AnyHashable("header:" + id)
case .item(let item): return AnyHashable(item.id)
case .header(let id, let title, let accentColor):
return AnyHashable(HeaderIdentity(id: id, title: title, accentColor: accentColor))
case .item(let item):
return AnyHashable(item.id)
}
}

Expand All @@ -37,7 +43,13 @@ internal enum FieldDrivenListEntry<Item: Identifiable> where Item.ID: Hashable {
guard !section.items.isEmpty else { return [] }
let rows = section.items.map { FieldDrivenListEntry.item($0) }
guard let title = section.title else { return rows }
return [.header(id: section.id, title: title)] + rows
return [.header(id: section.id, title: title, accentColor: section.accentColor)] + rows
}
}
}

private struct HeaderIdentity: Hashable {
let id: String
let title: String
let accentColor: NSColor?
}
Loading
Loading