Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8406ba0
test(plugins): lock the client box the Trino cancel test hands to its…
datlechin Sep 17, 2026
7596a50
fix(plugin-sqlite): mark the remote agent heartbeat handler Sendable
datlechin Sep 17, 2026
b192683
refactor(connections): let the connection and profile stores take the…
datlechin Sep 17, 2026
e1fe94e
test: follow keyword case, remote session metering, menu context and …
datlechin Sep 17, 2026
e344322
fix(editor): run the query on Cmd+Return while the autocomplete list …
datlechin Sep 17, 2026
12c2544
fix(editor): redraw the history drawer and its detail when their stat…
datlechin Sep 17, 2026
b02d1dd
fix(datagrid): show a table's new highlight rule as soon as it is added
datlechin Sep 17, 2026
13599cf
fix(datagrid): keep the query plan zoom readout in step with the canvas
datlechin Sep 17, 2026
87f7902
fix(connections): update AWS discovery progress as each region reports
datlechin Sep 17, 2026
fd3dce7
fix(settings): refresh Last checked and Check for Updates after a check
datlechin Sep 17, 2026
92d0044
fix(datagrid): show staged structure edits and enable Save
datlechin Sep 17, 2026
2dfb031
test(ui): follow renamed controls, read focus from the snapshot and c…
datlechin Sep 17, 2026
969fd7f
refactor(coordinator): move the switcher commands into their own exte…
datlechin Sep 17, 2026
abb0e82
fix(plugins): publish plugin state so Settings and the rejected-plugi…
datlechin Sep 17, 2026
763827b
fix(settings): disable license checks while one is already running
datlechin Sep 17, 2026
833c478
fix(datagrid): refresh the inspector's JSON view when a value window …
datlechin Sep 17, 2026
d511acc
fix(hig): observe the shared theme, settings, license and session sta…
datlechin Sep 17, 2026
80b33df
test(plugins): keep the Trino stub's release DELETE off the statement…
datlechin Sep 17, 2026
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 @@ -87,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Oracle login timeout that never fired, leaving the connecting spinner up past its deadline. (#2919)
- Crash from an Oracle server sending a marker packet, or an accept packet under 32 bytes, during login. (#2919)
- Crash on launch when a pairing deep link opens the approval sheet. (#2930)
- `Cmd+Return` inserting the highlighted completion instead of running the query while the autocomplete list is open.
- Wrong table dropped, truncated or opened from a PostgreSQL partition that lives in another schema. (#2523)
- A PostgreSQL partition missing from the object list when its parent table is not readable. (#2523)
- A PostgreSQL foreign-table partition listed twice, under Foreign Tables and under its parent. (#2523)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ final class StubTransport: TrinoTransport, @unchecked Sendable {
return recorded.count - 1
}
onSend?(request, index)
/// A `DELETE` is the client releasing a statement it cancelled, fired from a detached task
/// with its response discarded. Served from the queue it raced the statement's own in-flight
/// `GET` for the next canned page: on CI it won, the `GET` got the empty default, read it
/// as the last page and returned, and `testCancelStopsPolling` failed with "Expected
/// cancellation" although the cancel had landed.
guard request.method != .delete else {
return TrinoHTTPResponse(statusCode: 204, headers: TrinoHeaderFields([:]), body: Data())
}
let canned = lock.withLock { () -> Canned in
guard !queue.isEmpty else {
return Canned(statusCode: 200, headers: [:], body: Data(#"{"id":"empty"}"#.utf8))
Expand All @@ -54,6 +62,14 @@ func canned(_ json: String, status: Int = 200, headers: [String: String] = [:])
StubTransport.Canned(statusCode: status, headers: headers, body: Data(json.utf8))
}

/// Hands the client to an `onSend` hook that runs on whatever thread the transport was resumed on,
/// so the store is locked: the write happens on the test's thread and the read inside `send`.
final class ClientBox: @unchecked Sendable {
var client: TrinoStatementClient?
private let lock = NSLock()
private var stored: TrinoStatementClient?

var client: TrinoStatementClient? {
get { lock.withLock { stored } }
set { lock.withLock { stored = newValue } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,18 @@ public final class SuggestionController: NSWindowController {

guard !activeTextView.textView.hasMarkedText() else { return event }

/// A chord carrying a real modifier is never this panel's. This runs from a local key
/// monitor, which AppKit consults **before** the main menu, so claiming one takes that
/// menu command away app-wide for as long as the panel is open: switching on the key code
/// alone made Command+Return apply a completion instead of running the query, with no
/// feedback and no way to tell from the outside. Caps Lock, Function and the numeric-pad
/// flag are not modifiers here, because a plain arrow key carries the last two.
guard event.modifierFlags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.capsLock, .function, .numericPad])
.isEmpty
else { return event }

switch Int(event.keyCode) {
case kVK_Escape:
close()
Expand Down
2 changes: 1 addition & 1 deletion Plugins/SQLiteDriverPlugin/SQLiteAgentBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ final class SQLiteAgentConnection: @unchecked Sendable {
private func startHeartbeat() {
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
timer.schedule(deadline: .now() + Self.heartbeatInterval, repeating: Self.heartbeatInterval)
timer.setEventHandler { [weak self] in
timer.setEventHandler { @Sendable [weak self] in
self?.sendHeartbeat()
}
heartbeatTimer = timer
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Coordinators/RowEditingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ final class RowEditingCoordinator: ObservableObject {
parent.mutateActiveTableRows(for: tabId) { rows in rows.editMany(edits) }
parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true }
repaintInspectorEdit(rowIDs: editedRowIDs, columnIndex: columnIndex, in: tableRows)
parent.inspectorRowContentRevision &+= 1
parent.inspectorRowContentChanged.send()
}

/// `editMany` reports the rows it changed by their position in storage, and the grid reads a
Expand Down
22 changes: 13 additions & 9 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@ final class PluginManager: ObservableObject {
private let builtInPluginsURL: URL?
internal let userPluginsDir: URL

internal(set) var plugins: [PluginEntry] = []
/// Every plugin collection here is published. The class was `@Observable` until #2874, which
/// tracked these without a word, and Settings > Plugins and the rejected-plugin banner are
/// written against that: without it an install, an update or a rejection changed nothing on
/// screen until the pane was reopened.
@Published internal(set) var plugins: [PluginEntry] = []

internal(set) var stagedUpdates: [String: StagedPluginUpdate] = [:]
@Published internal(set) var stagedUpdates: [String: StagedPluginUpdate] = [:]

internal(set) var pluginsWithRegistryUpdate: Set<String> = []
@Published internal(set) var pluginsWithRegistryUpdate: Set<String> = []

var isInstalling: Bool {
PluginInstallTracker.shared.activeInstalls.values.contains { progress in
Expand Down Expand Up @@ -135,19 +139,19 @@ final class PluginManager: ObservableObject {
waiter.continuation.resume()
}

internal(set) var rejectedPlugins: [RejectedPlugin] = []
@Published internal(set) var rejectedPlugins: [RejectedPlugin] = []

@Published var needsRestart: Bool = false

internal(set) var driverPlugins: [String: any DriverPlugin] = [:]
@Published internal(set) var driverPlugins: [String: any DriverPlugin] = [:]

internal(set) var exportPlugins: [String: any ExportFormatPlugin] = [:]
@Published internal(set) var exportPlugins: [String: any ExportFormatPlugin] = [:]

internal(set) var importPlugins: [String: any ImportFormatPlugin] = [:]
@Published internal(set) var importPlugins: [String: any ImportFormatPlugin] = [:]

internal(set) var inspectorPlugins: [String: any DocumentInspectorPlugin] = [:]
@Published internal(set) var inspectorPlugins: [String: any DocumentInspectorPlugin] = [:]

internal(set) var pluginInstances: [String: any TableProPlugin] = [:]
@Published internal(set) var pluginInstances: [String: any TableProPlugin] = [:]

var disabledPluginIds: Set<String> {
get { Set(defaults.stringArray(forKey: Self.disabledPluginsKey) ?? []) }
Expand Down
5 changes: 3 additions & 2 deletions TablePro/Core/Services/Licensing/LicenseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ final class LicenseManager: ObservableObject {
}
}

/// Whether a network operation is in progress
private(set) var isValidating: Bool = false
/// Whether a network operation is in progress. Published, because the License and Sync panes
/// disable their buttons on it while a check runs.
@Published private(set) var isValidating: Bool = false

/// Last error from an operation (cleared on success)
@Published private(set) var lastError: LicenseError?
Expand Down
6 changes: 4 additions & 2 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ final class ConnectionStorage {
syncTracker: SyncChangeTracker = .shared,
appSettings: @escaping @autoclosure () -> AppSettingsStorage = .shared,
keychain: any KeychainStoring = AppStorageEnvironment.shared.keychain,
appEvents: @escaping @autoclosure () -> AppEvents = .shared
appEvents: @escaping @autoclosure () -> AppEvents = .shared,
integrity: ConnectionStoreIntegrity = .shared
) {
self.file = IntegrityStampedFileStore(
fileURL: fileURL,
label: "connections.json",
logger: Self.logger,
userSaveEstablishesTrust: true
userSaveEstablishesTrust: true,
integrity: integrity
)
self.defaults = userDefaults
self.syncTracker = syncTracker
Expand Down
72 changes: 47 additions & 25 deletions TablePro/Core/Storage/ConnectionStoreIntegrity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ struct ConnectionStoreIntegrity: Sendable {
}
}

static func randomKeyBytes(count: Int) -> Data? {
var bytes = [UInt8](repeating: 0, count: count)
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
logger.error("Could not generate a connection store integrity key")
return nil
}
return Data(bytes)
}

static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool {
guard lhs.count == rhs.count else { return false }
var difference: UInt8 = 0
Expand All @@ -91,6 +100,10 @@ struct KeychainIntegrityKeySource: IntegrityKeySource {
nonisolated(unsafe) private static var cached: SymmetricKey?

func key() -> SymmetricKey? {
if let isolatedStore = Self.isolatedStore {
return StoredIntegrityKeySource(store: isolatedStore).key()
}

Self.lock.lock()
defer { Self.lock.unlock() }

Expand Down Expand Up @@ -120,17 +133,7 @@ struct KeychainIntegrityKeySource: IntegrityKeySource {
AppStorageEnvironment.shared.isIsolated ? AppStorageEnvironment.shared.keychain : nil
}

private static let isolatedKey = "connectionStoreIntegrity"

private static func read() -> SymmetricKey? {
if let isolatedStore {
guard case let .found(encoded) = isolatedStore.readStringResult(forKey: isolatedKey),
let data = Data(base64Encoded: encoded),
data.count == byteCount
else { return nil }
return SymmetricKey(data: data)
}

var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
Expand All @@ -145,22 +148,10 @@ struct KeychainIntegrityKeySource: IntegrityKeySource {
}

private static func create() -> SymmetricKey? {
var bytes = [UInt8](repeating: 0, count: byteCount)
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
ConnectionStoreIntegrity.logger.error("Could not generate a connection store integrity key")
return nil
}

if let isolatedStore {
guard isolatedStore.writeString(Data(bytes).base64EncodedString(), forKey: isolatedKey) else {
ConnectionStoreIntegrity.logger.error("Could not store the connection store integrity key")
return nil
}
return SymmetricKey(data: Data(bytes))
}
guard let bytes = ConnectionStoreIntegrity.randomKeyBytes(count: byteCount) else { return nil }

var addQuery = baseQuery()
addQuery[kSecValueData as String] = Data(bytes)
addQuery[kSecValueData as String] = bytes
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

let status = SecItemAdd(addQuery as CFDictionary, nil)
Expand All @@ -170,6 +161,37 @@ struct KeychainIntegrityKeySource: IntegrityKeySource {
)
return nil
}
return SymmetricKey(data: Data(bytes))
return SymmetricKey(data: bytes)
}
}

/// Holds the key in a `KeychainStoring` rather than in the system keychain.
///
/// A sandboxed run and a test host both need this: the sandbox keeps its key beside the storage it
/// isolates, and a test host reaches no system keychain item of its own, so `SecItemAdd` fails and
/// every store it writes reads back untrusted.
struct StoredIntegrityKeySource: IntegrityKeySource {
private static let storageKey = "connectionStoreIntegrity"
private static let byteCount = 32

private let store: any KeychainStoring

init(store: any KeychainStoring) {
self.store = store
}

func key() -> SymmetricKey? {
if case let .found(encoded) = store.readStringResult(forKey: Self.storageKey),
let data = Data(base64Encoded: encoded),
data.count == Self.byteCount {
return SymmetricKey(data: data)
}

guard let bytes = ConnectionStoreIntegrity.randomKeyBytes(count: Self.byteCount) else { return nil }
guard store.writeString(bytes.base64EncodedString(), forKey: Self.storageKey) else {
ConnectionStoreIntegrity.logger.error("Could not store the connection store integrity key")
return nil
}
return SymmetricKey(data: bytes)
}
}
6 changes: 4 additions & 2 deletions TablePro/Core/Storage/CredentialProfileStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ final class CredentialProfileStorage {
fileURL: URL = CredentialProfileStorage.defaultFileURL(),
keychain: any KeychainStoring = AppStorageEnvironment.shared.keychain,
syncTracker: SyncChangeTracker = .shared,
connectionStorage: @escaping @autoclosure () -> ConnectionStorage = .shared
connectionStorage: @escaping @autoclosure () -> ConnectionStorage = .shared,
integrity: ConnectionStoreIntegrity = .shared
) {
self.file = IntegrityStampedFileStore(
fileURL: fileURL,
label: "credentialProfiles.json",
logger: Self.logger,
userSaveEstablishesTrust: false
userSaveEstablishesTrust: false,
integrity: integrity
)
self.keychain = keychain
self.syncTracker = syncTracker
Expand Down
3 changes: 1 addition & 2 deletions TablePro/Core/Storage/HighlightRuleStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ final class HighlightRuleStorage: ObservableObject, TableScopedSettingsStore {
}

func rules(for scope: TableScope) -> [HighlightRule] {
_ = revision
return loadEntries(for: scope.connectionId)[scope.storageComponent] ?? []
loadEntries(for: scope.connectionId)[scope.storageComponent] ?? []
}

func setRules(_ rules: [HighlightRule], for scope: TableScope) {
Expand Down
17 changes: 11 additions & 6 deletions TablePro/Models/UI/HistoryPanelState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import Foundation
final class HistoryPanelState: ObservableObject {
let connectionId: UUID

var isVisible: Bool { didSet { persistIfChanged(oldValue != isVisible) } }
var showsAllConnections: Bool { didSet { persistIfChanged(oldValue != showsAllConnections) } }
var pinnedConnectionId: UUID? { didSet { persistIfChanged(oldValue != pinnedConnectionId) } }
var sources: Set<QueryHistorySource> { didSet { persistIfChanged(oldValue != sources) } }
var dateRange: HistoryDateRange { didSet { persistIfChanged(oldValue != dateRange) } }
var outcome: QueryHistoryOutcome { didSet { persistIfChanged(oldValue != outcome) } }
/// Every one of these is `@Published`, because each is read from a SwiftUI body and a plain
/// `var` on an `ObservableObject` announces nothing. `isVisible` shipped as the one that shows:
/// Cmd+Y wrote it, `MainEditorContentView` never re-evaluated, and the drawer stayed shut until
/// some unrelated change redrew the window. Driving the same command from the menu bar hid it,
/// because menu tracking itself forces that redraw.
@Published var isVisible: Bool { didSet { persistIfChanged(oldValue != isVisible) } }
@Published var showsAllConnections: Bool { didSet { persistIfChanged(oldValue != showsAllConnections) } }
@Published var pinnedConnectionId: UUID? { didSet { persistIfChanged(oldValue != pinnedConnectionId) } }
@Published var sources: Set<QueryHistorySource> { didSet { persistIfChanged(oldValue != sources) } }
@Published var dateRange: HistoryDateRange { didSet { persistIfChanged(oldValue != dateRange) } }
@Published var outcome: QueryHistoryOutcome { didSet { persistIfChanged(oldValue != outcome) } }

/// Search text is deliberately not persisted: a stale query on relaunch reads as an empty
/// history rather than as a filter the user forgot they left behind.
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/AIChat/AIChatCodeBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import TableProEditorKit
import TableProGrammars

struct AIChatCodeBlockView: View, Equatable {
@ObservedObject private var settingsManager = AppSettingsManager.shared
let code: String
let language: String?
var prefersLightweightRendering: Bool = false
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/AIChat/AIChatPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SwiftUI

/// AI chat panel displayed alongside the main editor content
struct AIChatPanelView: View {
@ObservedObject private var slashCommandStorage = CustomSlashCommandStorage.shared
private static let warningBackgroundOpacity: Double = 0.1

let connection: DatabaseConnection
Expand Down Expand Up @@ -466,7 +467,7 @@ struct AIChatPanelView: View {
}

private var slashCommandMenu: some View {
let customCommands = CustomSlashCommandStorage.shared.commands.filter(\.isValid)
let customCommands = slashCommandStorage.commands.filter(\.isValid)
return Menu {
ForEach(SlashCommand.allCommands) { command in
Button {
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/AIChat/AIChatToolUseBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import AppKit
import SwiftUI

struct AIChatToolUseBlockView: View {
@ObservedObject private var themeEngine = ThemeEngine.shared
let block: ToolUseBlock

@State private var isExpanded: Bool = false
Expand Down Expand Up @@ -58,7 +59,7 @@ struct AIChatToolUseBlockView: View {
if isPending, let proposedStatement {
ScrollView(.horizontal, showsIndicators: false) {
Text(proposedStatement)
.font(ThemeEngine.shared.valueFontSwiftUI)
.font(themeEngine.valueFontSwiftUI)
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down
7 changes: 6 additions & 1 deletion TablePro/Views/Components/DiagramZoomToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
import SwiftUI

struct DiagramZoomToolbar<Extras: View>: View {
let viewport: DiagramViewportController
/// Observed here rather than trusted to a parent. The percentage and both buttons' enabled
/// state are read from it in this body, and the plan diagram holds its viewport as a plain
/// property, so on a plan every zoom changed the canvas and left the readout on the old
/// percentage with Zoom In and Zoom Out dimmed as they were. The ER diagram hid it only because
/// its own toolbar happens to observe the same object and redraws this one along with it.
@ObservedObject var viewport: DiagramViewportController
@ViewBuilder let extras: () -> Extras

init(viewport: DiagramViewportController, @ViewBuilder extras: @escaping () -> Extras = { EmptyView() }) {
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/Components/PaginationControlsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import SwiftUI

struct PaginationControlsView: View {
@ObservedObject private var settingsManager = AppSettingsManager.shared
let pagination: PaginationState
let loadedRowCount: Int
/// Identity of the tab these controls describe. Not used for display: a change to it is what
Expand Down Expand Up @@ -188,7 +189,7 @@ struct PaginationControlsView: View {
}

private func helpText(_ label: String, for shortcut: ShortcutAction) -> String {
AppSettingsManager.shared.keyboard.shortcutHint(label, for: shortcut)
settingsManager.keyboard.shortcutHint(label, for: shortcut)
}

/// A button straight to the jump popover while rows-per-page has its own control, and a menu
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Components/SQLStatementPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import TableProPluginKit
/// a detail of the review sheet: a second surface that picked differently would show the same
/// `CREATE SCHEMA` two ways.
struct SQLStatementPreview: View {
@ObservedObject private var settingsManager = AppSettingsManager.shared
let prepared: SQLReviewSheet.Prepared
let databaseType: DatabaseType

Expand Down
Loading
Loading