From 1faca62d5010b06d110262cc731ef426182c4a31 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 00:18:25 +0700 Subject: [PATCH 1/3] fix(connections): safe mode level no longer resets when opening a table (#1351) --- CHANGELOG.md | 1 + .../Database/DatabaseManager+Sessions.swift | 5 ++++ .../Infrastructure/MainWindowToolbar.swift | 3 ++- .../Models/Connection/ConnectionSession.swift | 5 ++++ .../Connection/ConnectionToolbarState.swift | 7 ++++- .../Main/MainContentCommandActions.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 4 +++ .../Views/Toolbar/TableProToolbarView.swift | 6 ++++- .../Models/ConnectionSessionTests.swift | 18 +++++++++++++ .../Models/ConnectionToolbarStateTests.swift | 27 +++++++++++++++++++ docs/features/safe-mode.mdx | 2 ++ 11 files changed, 76 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f81a89c..013bd529e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Safe mode no longer turns off when you open another table; it stays set for the whole connection until you change it (#1351) - Reassigning the Execute Query, Execute All Statements, and Cancel Query shortcuts now takes effect, and the Query menu shows the new keys (#1357) - Custom shortcuts now require a modifier key, so a plain key like Space is no longer accepted and then silently ignored (#1357) - Cancelling a pending connection no longer lets the abandoned attempt overwrite or drop a later successful connection to the same database (#1358) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 3bb0ace24..8a79bf8a9 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -362,6 +362,11 @@ extension DatabaseManager { setSession(session, for: sessionId) } + func setSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { + guard activeSessions[connectionId]?.safeModeLevel != level else { return } + activeSessions[connectionId]?.safeModeLevel = level + } + internal func setSession(_ session: ConnectionSession, for connectionId: UUID) { activeSessions[connectionId] = session connectionStatusVersions[connectionId, default: 0] &+= 1 diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index b1fbd1f2a..0749c9bc9 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -130,7 +130,8 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { content: ToolbarPrincipalContent( state: coordinator.toolbarState, onSwitchDatabase: { [weak coordinator] in coordinator?.commandActions?.openDatabaseSwitcher() }, - onCancelQuery: { [weak coordinator] in coordinator?.cancelCurrentQuery() } + onCancelQuery: { [weak coordinator] in coordinator?.cancelCurrentQuery() }, + onSafeModeChange: { [weak coordinator] level in coordinator?.setSafeModeLevel(level) } ) ) item.visibilityPriority = .high diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index 734b2bc95..a867c6965 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -17,6 +17,10 @@ struct ConnectionSession: Identifiable { var status: ConnectionStatus = .disconnected var lastError: String? + /// Live write-protection level. Seeded from the saved default; the toolbar + /// updates this, so reading `connection.safeModeLevel` is wrong afterwards. + var safeModeLevel: SafeModeLevel + // Per-connection state var selectedTables: Set = [] var pendingTruncates: Set = [] @@ -45,6 +49,7 @@ struct ConnectionSession: Identifiable { self.id = connection.id self.connection = connection self.driver = driver + self.safeModeLevel = connection.safeModeLevel self.connectedAt = Date() self.lastActiveAt = Date() } diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 260294431..d30292278 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -273,7 +273,6 @@ final class ConnectionToolbarState { databaseType = connection.type displayColor = connection.displayColor tagId = connection.tagId - safeModeLevel = connection.safeModeLevel databaseGroupingStrategy = PluginManager.shared.databaseGroupingStrategy(for: connection.type) syncFromSession(for: connection) } @@ -299,6 +298,12 @@ final class ConnectionToolbarState { if currentSchema != resolvedSchema { currentSchema = resolvedSchema } + + let resolvedSafeMode = DatabaseManager.shared.session(for: connection.id)?.safeModeLevel + ?? connection.safeModeLevel + if safeModeLevel != resolvedSafeMode { + safeModeLevel = resolvedSafeMode + } } /// Update connection state from ConnectionStatus diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index c49d97c8a..458b5b885 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -240,7 +240,7 @@ final class MainContentCommandActions { var isConnected: Bool { coordinator != nil } var isQueryExecuting: Bool { coordinator?.toolbarState.isExecuting ?? false } - var safeModeLevel: SafeModeLevel { connection.safeModeLevel } + var safeModeLevel: SafeModeLevel { coordinator?.toolbarState.safeModeLevel ?? connection.safeModeLevel } var isReadOnly: Bool { safeModeLevel.blocksAllWrites } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index fc57b6706..91b2c42a5 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -91,6 +91,10 @@ final class MainContentCoordinator { services.databaseManager.activeDatabaseName(for: connection) } var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } + func setSafeModeLevel(_ level: SafeModeLevel) { + toolbarState.safeModeLevel = level + services.databaseManager.setSafeModeLevel(level, for: connectionId) + } let selectionState = GridSelectionState() let tabManager: QueryTabManager let changeManager: DataChangeManager diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 1003786f8..83429c19c 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -20,6 +20,7 @@ struct ToolbarPrincipalContent: View { var state: ConnectionToolbarState var onSwitchDatabase: (() -> Void)? var onCancelQuery: (() -> Void)? + var onSafeModeChange: ((SafeModeLevel) -> Void)? var body: some View { let tag = state.tagId.flatMap { TagStorage.shared.tag(for: $0) } @@ -40,7 +41,10 @@ struct ToolbarPrincipalContent: View { onSwitchDatabase: onSwitchDatabase ) - SafeModeBadgeView(safeModeLevel: Bindable(state).safeModeLevel) + SafeModeBadgeView(safeModeLevel: Binding( + get: { state.safeModeLevel }, + set: { onSafeModeChange?($0) } + )) ExecutionIndicatorView( isExecuting: state.isExecuting, diff --git a/TableProTests/Models/ConnectionSessionTests.swift b/TableProTests/Models/ConnectionSessionTests.swift index f5c51eef7..2755233b3 100644 --- a/TableProTests/Models/ConnectionSessionTests.swift +++ b/TableProTests/Models/ConnectionSessionTests.swift @@ -217,4 +217,22 @@ struct ConnectionSessionStateTests { let session = ConnectionSession(connection: connection) #expect(session.id == connection.id) } + + @Test("seeds safe mode from the connection's saved default") + func seedsSafeModeFromConnection() { + var connection = TestFixtures.makeConnection() + connection.safeModeLevel = .readOnly + let session = ConnectionSession(connection: connection) + #expect(session.safeModeLevel == .readOnly) + } + + @Test("clearCachedData preserves safe mode so reconnect keeps protection") + func clearCachedDataPreservesSafeMode() { + var connection = TestFixtures.makeConnection() + connection.safeModeLevel = .silent + var session = ConnectionSession(connection: connection) + session.safeModeLevel = .readOnly + session.clearCachedData() + #expect(session.safeModeLevel == .readOnly) + } } diff --git a/TableProTests/Models/ConnectionToolbarStateTests.swift b/TableProTests/Models/ConnectionToolbarStateTests.swift index e5a45d5fc..62faadc82 100644 --- a/TableProTests/Models/ConnectionToolbarStateTests.swift +++ b/TableProTests/Models/ConnectionToolbarStateTests.swift @@ -93,4 +93,31 @@ struct ConnectionToolbarStateTests { #expect(state.currentDatabase == "Production") } + + // MARK: - safe mode + + @Test("syncFromSession falls back to the connection's saved safe mode when no session exists") + func syncFromSessionFallsBackToConnectionSafeMode() { + var connection = TestFixtures.makeConnection() + connection.safeModeLevel = .readOnly + let state = ConnectionToolbarState() + + state.syncFromSession(for: connection) + + #expect(state.safeModeLevel == .readOnly) + } + + @Test("A new toolbar state adopts the live session safe mode, not the stale saved default") + func newToolbarStateAdoptsLiveSessionSafeMode() { + let id = UUID() + var connection = TestFixtures.makeConnection(id: id) + connection.safeModeLevel = .silent + DatabaseManager.shared.injectSession(ConnectionSession(connection: connection), for: id) + DatabaseManager.shared.setSafeModeLevel(.readOnly, for: id) + defer { DatabaseManager.shared.removeSession(for: id) } + + let state = ConnectionToolbarState(connection: connection) + + #expect(state.safeModeLevel == .readOnly) + } } diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index 82d1ed356..08e01c238 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -65,6 +65,8 @@ MongoDB, Redis, and other NoSQL databases can't be parsed for read vs. write ope The current Safe Mode level appears as a badge in the toolbar (orange for Alert levels, red for Safe Mode/Read Only). Click it to change levels. +Changing the level from the badge applies to the whole connection and stays set as you open other tables and tabs. The saved default in the Customization pane is the level a connection starts at. + Safe Mode gates apply to query execution, saving cell edits, table operations, and sidebar changes. ## External Clients From 7009e1bd78bfb802fc5b6dd76154c3ba714827cb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 00:28:02 +0700 Subject: [PATCH 2/3] fix(connections): propagate safe mode changes to other open tabs of the connection (#1351) --- TablePro/Core/Database/DatabaseManager+Sessions.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 8a79bf8a9..2b05f879e 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -363,8 +363,9 @@ extension DatabaseManager { } func setSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { - guard activeSessions[connectionId]?.safeModeLevel != level else { return } - activeSessions[connectionId]?.safeModeLevel = level + guard var session = activeSessions[connectionId], session.safeModeLevel != level else { return } + session.safeModeLevel = level + setSession(session, for: connectionId) } internal func setSession(_ session: ConnectionSession, for connectionId: UUID) { From 15a8193f89e2fe2969443be65a8cf33984472d45 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 00:35:52 +0700 Subject: [PATCH 3/3] fix(connections): gate AI Chat and MCP writes on the live safe mode level (#1351) --- TablePro/Core/MCP/MCPAuthPolicy.swift | 2 +- .../MCP/Protocol/Tools/ToolConnectionMetadata.swift | 2 +- .../ViewModels/AIChatViewModel+ToolApproval.swift | 12 +++++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/MCP/MCPAuthPolicy.swift b/TablePro/Core/MCP/MCPAuthPolicy.swift index f5dca7468..0daf14f70 100644 --- a/TablePro/Core/MCP/MCPAuthPolicy.swift +++ b/TablePro/Core/MCP/MCPAuthPolicy.swift @@ -298,7 +298,7 @@ public actor MCPAuthPolicy { externalAccess: conn.externalAccess, name: conn.name, databaseType: conn.type.rawValue, - safeModeLevel: conn.safeModeLevel + safeModeLevel: session.safeModeLevel ) case .stored(let conn): return ConnectionSnapshot( diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift b/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift index e14ad78ca..cbd91945e 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift @@ -11,7 +11,7 @@ struct ToolConnectionMetadata { case .live(_, let session): return ToolConnectionMetadata( databaseType: session.connection.type, - safeModeLevel: session.connection.safeModeLevel, + safeModeLevel: session.safeModeLevel, databaseName: session.activeDatabase ) case .stored(let conn): diff --git a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift index e7f1bc258..cef4d070b 100644 --- a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift +++ b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift @@ -89,7 +89,7 @@ extension AIChatViewModel { // Safe-mode level and "Always Allow" cannot bypass them — the AI must not // be able to drop tables, truncate, or alter-drop without an explicit click. if toolMode == .agentOnly { - if let connection, connection.safeModeLevel.blocksAllWrites { + if let connection, liveSafeModeLevel(for: connection).blocksAllWrites { return .denied(reason: String( localized: "Connection is read-only. Destructive operations are not permitted." )) @@ -101,18 +101,24 @@ extension AIChatViewModel { return .approved } if let connection { - if connection.safeModeLevel.blocksAllWrites { + let safeModeLevel = liveSafeModeLevel(for: connection) + if safeModeLevel.blocksAllWrites { return .denied(reason: String( localized: "Connection is read-only. Set safe mode to Confirm Writes or higher to allow this tool." )) } - if !connection.safeModeLevel.requiresConfirmation { + if !safeModeLevel.requiresConfirmation { return .approved } } return .pending } + @MainActor + private func liveSafeModeLevel(for connection: DatabaseConnection) -> SafeModeLevel { + DatabaseManager.shared.session(for: connection.id)?.safeModeLevel ?? connection.safeModeLevel + } + @MainActor func appendPendingToolUseBlocks(_ blocks: [ToolUseBlock], assistantID: UUID) { guard let idx = messages.firstIndex(where: { $0.id == assistantID }) else { return }