diff --git a/.gitignore b/.gitignore index 00fa0894c..a4fa28725 100644 --- a/.gitignore +++ b/.gitignore @@ -156,5 +156,6 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ .docs/ -Local.xcconfig + +# Plans (local only) /plans/reports diff --git a/CHANGELOG.md b/CHANGELOG.md index a03713b8a..c2766153d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Each filter row has a checkbox to turn it on or off and an Apply button to filter by just that row. The main Apply runs every active filter, and disabled filters stay in the panel for later. (#1561) +- Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import. +- Oracle connections negotiate Native Network Encryption when the server asks for it, so servers with `SQLNET.ENCRYPTION_SERVER` or `SQLNET.CRYPTO_CHECKSUM_SERVER` set to REQUIRED now connect (AES with a SHA crypto-checksum), matching what SQL Developer and DBeaver do. (#483) +- Oracle connections follow listener redirects, so RAC SCAN listeners, shared server, and load-balanced setups now connect instead of failing during the handshake. (#483) +- Recent section at the top of the Tables sidebar tracks the last 10 tables you opened per connection and database, in-memory for the session. Off by default, turn it on in Settings > General > Sidebar. (#1352) + - Cursor as an AI provider: use a Cursor API key or sign in with the Cursor CLI. (#1624) - Sign in with ChatGPT to run AI chat and inline suggestions without an API key. Existing Codex CLI logins can be imported. (#1617) - libSQL / Turso connections can open a local database file offline, transactions included. (#1607) +### Changed + +- Custom keyboard shortcuts now work on non-US keyboard layouts, and shifted symbols like Cmd+[ record correctly. +- The Keyboard settings list is grouped by where shortcuts act (Editor, Data Grid, Navigation, Connections), and each changed shortcut has its own reset button. +- Conflict detection now checks live macOS system shortcuts and the editor's built-in commands, and lets the same key serve the editor and the data grid because focus decides which one runs. +- Show Tables and Show Favorites sidebars moved off Control+1 and Control+2, which switch macOS Spaces, to Cmd+Option+1 and Cmd+Option+2. +- Cmd+N now opens a new connection; Manage Connections keeps its File menu item. +- First Page and Last Page now default to Cmd+Option+Up and Cmd+Option+Down. +- Shortcuts can be bound to function keys (F1 through F12), with or without a modifier. +- The Maintenance submenu in the sidebar context menu is hidden when no maintenance operations are available or the target is read-only, instead of showing an empty disabled menu. +- The window minimum width now adjusts to the visible panes, so opening the inspector on a small window no longer pushes content off-screen. + +### Removed + +- "Create New Table…" from the sidebar right-click menu. Use the plus button in the Tables sidebar footer instead. + ### Fixed - Default row sort now applies to the first table opened after launch. (#1603) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 1d69cb2f5..14358f747 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -237,6 +237,13 @@ remoteGlobalIDString = 5A1091C62EF17EDC0055EA7C; remoteInfo = TablePro; }; + 5AF00A112FB9000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A1091C62EF17EDC0055EA7C; + remoteInfo = TablePro; + }; 5ABQR00000000000000000C0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -352,6 +359,7 @@ 5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVInspectorPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5AF00A102FB9000000000001 /* TableProUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABQR00200000000000000A1 /* BigQueryAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryAuth.swift; sourceTree = ""; }; 5ABQR00200000000000000A2 /* BigQueryConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryConnection.swift; sourceTree = ""; }; 5ABQR00200000000000000A3 /* BigQueryPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryPlugin.swift; sourceTree = ""; }; @@ -788,6 +796,11 @@ path = TableProTests; sourceTree = ""; }; + 5AF00A122FB9000000000001 /* TableProUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TableProUITests; + sourceTree = ""; + }; 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -824,6 +837,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AF00A132FB9000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A3BE6F52F97DA8100611C1F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1745,6 +1765,27 @@ productReference = 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 5AF00A142FB9000000000001 /* TableProUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AF00A192FB9000000000001 /* Build configuration list for PBXNativeTarget "TableProUITests" */; + buildPhases = ( + 5AF00A152FB9000000000001 /* Sources */, + 5AF00A132FB9000000000001 /* Frameworks */, + 5AF00A162FB9000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5AF00A172FB9000000000001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 5AF00A122FB9000000000001 /* TableProUITests */, + ); + name = TableProUITests; + productName = TableProUITests; + productReference = 5AF00A102FB9000000000001 /* TableProUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 5ABQR00600000000000000B0 /* BigQueryDriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 5ABQR00800000000000000B0 /* Build configuration list for PBXNativeTarget "BigQueryDriverPlugin" */; @@ -1930,6 +1971,10 @@ CreatedOnToolsVersion = 26.2; TestTargetID = 5A1091C62EF17EDC0055EA7C; }; + 5AF00A142FB9000000000001 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 5A1091C62EF17EDC0055EA7C; + }; 5AE4F4732F6BC0640097AC5B = { CreatedOnToolsVersion = 26.3; LastSwiftMigration = 2630; @@ -2018,6 +2063,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AF00A162FB9000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A3BE6F62F97DA8100611C1F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2248,6 +2300,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AF00A152FB9000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A3BE6F42F97DA8100611C1F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2561,6 +2620,11 @@ target = 5A1091C62EF17EDC0055EA7C /* TablePro */; targetProxy = 5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */; }; + 5AF00A172FB9000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A1091C62EF17EDC0055EA7C /* TablePro */; + targetProxy = 5AF00A112FB9000000000001 /* PBXContainerItemProxy */; + }; 5ABQR00000000000000000C1 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5ABQR00600000000000000B0 /* BigQueryDriverPlugin */; @@ -4212,6 +4276,48 @@ }; name = Release; }; + 5AF00A182FB9000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.9; + TEST_TARGET_NAME = TablePro; + }; + name = Debug; + }; + 5AF00A1A2FB9000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.9; + TEST_TARGET_NAME = TablePro; + }; + name = Release; + }; 5ABQR00700000000000000B1 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4684,6 +4790,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5AF00A192FB9000000000001 /* Build configuration list for PBXNativeTarget "TableProUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AF00A182FB9000000000001 /* Debug */, + 5AF00A1A2FB9000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5ABQR00800000000000000B0 /* Build configuration list for PBXNativeTarget "BigQueryDriverPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift new file mode 100644 index 000000000..c20fb0c9d --- /dev/null +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -0,0 +1,57 @@ +import Foundation + +extension Notification.Name { + static let recentTablesDidChange = Notification.Name("RecentTablesDidChange") +} + +@MainActor +final class RecentTablesStore { + static let shared = RecentTablesStore() + + struct Key: Hashable { + let connectionID: UUID + let database: String? + } + + struct Entry: Hashable, Identifiable { + let name: String + let schema: String? + let type: TableInfo.TableType + + var id: String { schema.map { "\($0).\(name)" } ?? name } + } + + private var entriesByKey: [Key: [Entry]] = [:] + private let cap = 10 + + init() {} + + func push(connectionID: UUID, database: String?, table: TableInfo) { + let key = Key(connectionID: connectionID, database: database) + let entry = Entry(name: table.name, schema: table.schema, type: table.type) + var list = entriesByKey[key] ?? [] + list.removeAll { $0.id == entry.id } + list.insert(entry, at: 0) + if list.count > cap { + list = Array(list.prefix(cap)) + } + entriesByKey[key] = list + NotificationCenter.default.post(name: .recentTablesDidChange, object: nil) + } + + func entries(connectionID: UUID, database: String?) -> [Entry] { + entriesByKey[Key(connectionID: connectionID, database: database)] ?? [] + } + + func clear(connectionID: UUID, database: String?) { + entriesByKey.removeValue(forKey: Key(connectionID: connectionID, database: database)) + NotificationCenter.default.post(name: .recentTablesDidChange, object: nil) + } + + func clearAll() { + entriesByKey.removeAll() + NotificationCenter.default.post(name: .recentTablesDidChange, object: nil) + } + + var cappedSize: Int { cap } +} diff --git a/TablePro/Models/Settings/GeneralSettings.swift b/TablePro/Models/Settings/GeneralSettings.swift index ffa240795..fdbc7475c 100644 --- a/TablePro/Models/Settings/GeneralSettings.swift +++ b/TablePro/Models/Settings/GeneralSettings.swift @@ -61,12 +61,16 @@ struct GeneralSettings: Codable, Equatable { /// Whether to share anonymous usage analytics var shareAnalytics: Bool + /// Whether the sidebar shows a Recent section with recently opened tables + var showRecentTables: Bool + static let `default` = GeneralSettings( startupBehavior: .showWelcome, language: .system, automaticallyCheckForUpdates: true, queryTimeoutSeconds: 60, - shareAnalytics: true + shareAnalytics: true, + showRecentTables: false ) init( @@ -74,13 +78,15 @@ struct GeneralSettings: Codable, Equatable { language: AppLanguage = .system, automaticallyCheckForUpdates: Bool = true, queryTimeoutSeconds: Int = 60, - shareAnalytics: Bool = true + shareAnalytics: Bool = true, + showRecentTables: Bool = false ) { self.startupBehavior = startupBehavior self.language = language self.automaticallyCheckForUpdates = automaticallyCheckForUpdates self.queryTimeoutSeconds = queryTimeoutSeconds self.shareAnalytics = shareAnalytics + self.showRecentTables = showRecentTables } init(from decoder: Decoder) throws { @@ -90,5 +96,6 @@ struct GeneralSettings: Codable, Equatable { automaticallyCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .automaticallyCheckForUpdates) ?? true queryTimeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .queryTimeoutSeconds) ?? 60 shareAnalytics = try container.decodeIfPresent(Bool.self, forKey: .shareAnalytics) ?? true + showRecentTables = try container.decodeIfPresent(Bool.self, forKey: .showRecentTables) ?? false } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index c3006c2ab..9587af914 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2429,7 +2429,6 @@ } }, "%lld of %lld" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -16915,6 +16914,10 @@ } } }, + "Create New Table" : { + "comment" : "Tooltip and accessibility label for the button that allows the user to create a new table.", + "isCommentAutoGenerated" : true + }, "Create New Table..." : { "extractionState" : "stale", "localizations" : { @@ -28188,7 +28191,6 @@ } }, "Format JSON" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -34025,7 +34027,6 @@ } }, "Limit" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -38309,7 +38310,6 @@ } }, "Next Page (⌘])" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -41131,7 +41131,6 @@ } }, "Offset" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -42905,7 +42904,6 @@ } }, "Pagination Settings" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -45400,7 +45398,6 @@ } }, "Previous Page (⌘[)" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 5c2e21e5e..d75fe9a00 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -91,6 +91,7 @@ final class SidebarViewModel { ) } } + var isRecentsExpanded: Bool = true var redisKeyTreeViewModel: RedisKeyTreeViewModel? var showOperationDialog = false var pendingOperationType: TableOperationType? diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 81880558d..4dedd203a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -21,6 +21,13 @@ extension MainContentCoordinator { forceNonPreview: Bool = false, activateGridFocus: Bool = false ) { + if AppSettingsManager.shared.general.showRecentTables { + RecentTablesStore.shared.push( + connectionID: connection.id, + database: activeDatabaseName.isEmpty ? nil : activeDatabaseName, + table: table + ) + } openTableTab( table.name, schema: table.schema, diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index ca91c6c82..7e81ed8d1 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -56,6 +56,9 @@ struct GeneralSettingsView: View { } Section("Sidebar") { + Toggle("Show recent tables", isOn: $settings.showRecentTables) + .help("Adds a Recent section at the top of the Tables sidebar with the last tables you opened per connection and database.") + Picker("Default layout for new connections:", selection: $defaultSidebarLayout) { Text("List").tag(SidebarLayout.flat) Text("Tree").tag(SidebarLayout.tree) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 9772b16c7..9b6ec0571 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,6 +11,8 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel @State private var favoriteTables: Set = [] + @State private var recentTables: [RecentTablesStore.Entry] = [] + @State private var settingsManager = AppSettingsManager.shared private var schemaService: SchemaService { SchemaService.shared } @@ -288,11 +290,24 @@ struct SidebarView: View { // MARK: - Table List + private var filteredRecents: [RecentTablesStore.Entry] { + let search = viewModel.searchText + guard !search.isEmpty else { return recentTables } + return recentTables.filter { $0.name.localizedCaseInsensitiveContains(search) } + } + private var activeDatabase: String? { let name = coordinator?.activeDatabaseName ?? "" return name.isEmpty ? nil : name } + private func tableInfo(forRecent entry: RecentTablesStore.Entry) -> TableInfo { + if let match = tables.first(where: { $0.name == entry.name && $0.schema == entry.schema }) { + return match + } + return TableInfo(name: entry.name, type: entry.type, rowCount: nil, schema: entry.schema) + } + private func isFavorite(_ table: TableInfo) -> Bool { favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry( connectionId: connectionId, @@ -311,8 +326,57 @@ struct SidebarView: View { ) } + private func reloadRecentTables() { + guard settingsManager.general.showRecentTables else { + recentTables = [] + return + } + recentTables = RecentTablesStore.shared.entries( + connectionID: connectionId, + database: activeDatabase + ) + } + + @ViewBuilder + private var recentSection: some View { + let recents = filteredRecents + if settingsManager.general.showRecentTables, !recents.isEmpty { + Section(isExpanded: $viewModel.isRecentsExpanded) { + ForEach(recents) { entry in + let info = tableInfo(forRecent: entry) + TableRow( + table: info, + isPendingTruncate: pendingTruncates.contains(info.name), + isPendingDelete: pendingDeletes.contains(info.name), + isFavorite: isFavorite(info), + onToggleFavorite: { toggleFavorite(info) } + ) + .selectionDisabled() + .contentShape(Rectangle()) + .onTapGesture { + onDoubleClick?(info) + } + .contextMenu { + SidebarContextMenu( + clickedTable: info, + selectedTables: windowState.selectedTables, + isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, + onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, + onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, + coordinator: coordinator + ) + } + } + } header: { + Text(String(localized: "Recent")) + } + } + } + private var tableList: some View { List(selection: selectedTablesBinding) { + recentSection + ForEach(SidebarObjectKind.allCases, id: \.self) { kind in sectionView(for: kind) } @@ -356,8 +420,15 @@ struct SidebarView: View { .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) } + .onReceive(NotificationCenter.default.publisher(for: .recentTablesDidChange)) { _ in + reloadRecentTables() + } + .onChange(of: settingsManager.general.showRecentTables) { _, _ in + reloadRecentTables() + } .onAppear { favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + reloadRecentTables() } } diff --git a/TableProTests/Models/GeneralSettingsTests.swift b/TableProTests/Models/GeneralSettingsTests.swift new file mode 100644 index 000000000..6c34af50d --- /dev/null +++ b/TableProTests/Models/GeneralSettingsTests.swift @@ -0,0 +1,28 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("GeneralSettings.showRecentTables") +struct GeneralSettingsTests { + @Test("Defaults to off") + func defaultsOff() { + #expect(GeneralSettings.default.showRecentTables == false) + #expect(GeneralSettings().showRecentTables == false) + } + + @Test("Decoding settings without the key keeps recent tables off") + func decodesMissingKeyAsOff() throws { + let json = Data(#"{"startupBehavior":"showWelcome"}"#.utf8) + let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) + #expect(decoded.showRecentTables == false) + } + + @Test("Round-trips when enabled") + func roundTripsEnabled() throws { + var settings = GeneralSettings() + settings.showRecentTables = true + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) + #expect(decoded.showRecentTables == true) + } +} diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift new file mode 100644 index 000000000..a1bb9f3ff --- /dev/null +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("RecentTablesStore") +@MainActor +struct RecentTablesStoreTests { + private func makeStore() -> RecentTablesStore { + RecentTablesStore() + } + + private func makeTable(_ name: String, schema: String? = nil) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + } + + @Test("Push inserts entry at the front") + func pushInsertsAtFront() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: "db", table: makeTable("a")) + store.push(connectionID: conn, database: "db", table: makeTable("b")) + let entries = store.entries(connectionID: conn, database: "db") + #expect(entries.map(\.name) == ["b", "a"]) + } + + @Test("Push dedupes by table id and bumps to front") + func pushDedupes() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: "db", table: makeTable("a")) + store.push(connectionID: conn, database: "db", table: makeTable("b")) + store.push(connectionID: conn, database: "db", table: makeTable("a")) + let entries = store.entries(connectionID: conn, database: "db") + #expect(entries.map(\.name) == ["a", "b"]) + } + + @Test("Push caps list at 10 entries") + func pushCaps() { + let store = makeStore() + let conn = UUID() + for index in 0..<15 { + store.push(connectionID: conn, database: "db", table: makeTable("t\(index)")) + } + let entries = store.entries(connectionID: conn, database: "db") + #expect(entries.count == store.cappedSize) + #expect(entries.first?.name == "t14") + #expect(entries.last?.name == "t5") + } + + @Test("Entries isolated per (connection, database) key") + func entriesIsolated() { + let store = makeStore() + let connA = UUID() + let connB = UUID() + store.push(connectionID: connA, database: "db", table: makeTable("alpha")) + store.push(connectionID: connB, database: "db", table: makeTable("beta")) + store.push(connectionID: connA, database: "other", table: makeTable("gamma")) + + #expect(store.entries(connectionID: connA, database: "db").map(\.name) == ["alpha"]) + #expect(store.entries(connectionID: connB, database: "db").map(\.name) == ["beta"]) + #expect(store.entries(connectionID: connA, database: "other").map(\.name) == ["gamma"]) + } + + @Test("Schema-qualified table is distinct from same-name unqualified") + func schemaDistinct() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: "db", table: makeTable("users", schema: "public")) + store.push(connectionID: conn, database: "db", table: makeTable("users", schema: nil)) + let entries = store.entries(connectionID: conn, database: "db") + #expect(entries.count == 2) + } + + @Test("Clear removes all entries for a key") + func clearKey() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: "db", table: makeTable("a")) + store.push(connectionID: conn, database: "other", table: makeTable("b")) + store.clear(connectionID: conn, database: "db") + #expect(store.entries(connectionID: conn, database: "db").isEmpty) + #expect(store.entries(connectionID: conn, database: "other").map(\.name) == ["b"]) + } + + @Test("Nil database key is distinct from empty-string database") + func nilDatabaseDistinctFromEmpty() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: nil, table: makeTable("sqlite_table")) + store.push(connectionID: conn, database: "postgres", table: makeTable("pg_table")) + #expect(store.entries(connectionID: conn, database: nil).map(\.name) == ["sqlite_table"]) + #expect(store.entries(connectionID: conn, database: "postgres").map(\.name) == ["pg_table"]) + } + + @Test("ClearAll empties every key") + func clearAllEmptiesEveryKey() { + let store = makeStore() + let connA = UUID() + let connB = UUID() + store.push(connectionID: connA, database: "db", table: makeTable("a")) + store.push(connectionID: connB, database: nil, table: makeTable("b")) + store.clearAll() + #expect(store.entries(connectionID: connA, database: "db").isEmpty) + #expect(store.entries(connectionID: connB, database: nil).isEmpty) + } +} diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 7c4913f12..25203c8dd 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -74,6 +74,12 @@ No queries or database content is transmitted. A tab is "clean" when it's a table tab (not query/create), unpinned, no unsaved changes, and no interactions (sort, filter, selection). +### Sidebar + +| Setting | Default | Description | +|---------|---------|-------------| +| **Show recent tables** | Off | Adds a Recent section at the top of the Tables sidebar with the last 10 tables opened per connection and database | + ## AI The **AI** tab configures providers and chat behavior. See [AI Assistant](/features/ai-assistant) for usage. The tab has these sections. diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index 256113a5a..bb7cf1f0b 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -5,7 +5,7 @@ description: Mark tables as favorites and save frequently used queries with opti # Favorites -The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. +The Tables sidebar can show a **Recent** section at the top with the last 10 tables you opened in the current connection and database (off by default). The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. ## Table Favorites @@ -18,6 +18,10 @@ Double-click a table in the Favorites tab to open it. Right-click it to open the Favorites are scoped to the connection, database, and schema, and sync through iCloud. A favorite is hidden when its table doesn't exist in the database you're viewing. +## Recent Tables + +Turn on **Show recent tables** in Settings > General > Sidebar to add a **Recent** section at the top of the Tables sidebar. While it's on, each table you open is added to the list, which keeps the 10 most recent tables per connection and database, with the most recent at the top. Click a row to reopen the table. Recents are kept in memory for the session and clear when you quit. + ## SQL Favorites Save queries you run often. Organize them in folders, assign keyword shortcuts, and expand them inline via autocomplete.