diff --git a/CHANGELOG.md b/CHANGELOG.md index 0917da3f1..98134c534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Recent section at the top of the sidebar tracks the last 10 tables you opened per connection and database, in every sidebar layout, and remembers them between launches. It records a table when you open it, not while you arrow through previews. Click a recent table to reopen it, or right-click to remove one or clear the list. Off by default, turn it on in Settings > General > Sidebar. (#1352) + ### Fixed - Switching schemas on an Oracle connection no longer hangs on an infinite loading spinner. Oracle now switches by schema like BigQuery, the sidebar lists every schema with its tables loading on expand, Oracle queries respect the query timeout setting and reconnect automatically after a timeout, and a schema load that fails shows an error with a Retry button instead of spinning forever. Works with an already-installed Oracle plugin; updating the plugin adds the query timeout enforcement. (#1807) diff --git a/CLAUDE.md b/CLAUDE.md index 6a4a5c905..90a203630 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Per-table filters | JSON files | `FilterSettingsStorage` (one file per connection + database + schema + table; saves the valid working set, each row's enabled flag included) | | Favorite tables | UserDefaults | `FavoriteTablesStorage` (per connection + database + schema; iCloud-synced) | | Tree database filter | UserDefaults | `DatabaseTreeFilterStorage` (per connection; selected database set, empty = show all; device-local). Live value held in `SharedSidebarState`. | +| Recent tables | UserDefaults | `RecentTablesStore` (per connection, keyed by database, last 10 each; device-local). Live value held in `SharedSidebarState`, recorded at the `QueryTabManager` open chokepoint. | ### Logging & Debugging diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index b3265564b..e88d6abf6 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -62,6 +62,11 @@ enum SessionStateFactory { }, tabSessionRegistry: tabSessionRegistry ) + tabMgr.onTableOpened = { tableName, schemaName, databaseName, isView, isPreview in + SharedSidebarState.forConnection(connectionId).recordTableOpen( + database: databaseName, schema: schemaName, name: tableName, isView: isView, isPreview: isPreview + ) + } let changeMgr = DataChangeManager() changeMgr.databaseType = connection.type let toolbarSt = ConnectionToolbarState(connection: connection) @@ -100,14 +105,16 @@ enum SessionStateFactory { tableName: tableName, databaseType: connection.type, databaseName: payload.databaseName ?? activeDatabaseName, - schemaName: resolvedSchemaName + schemaName: resolvedSchemaName, + isView: payload.isView ) } else { try tabMgr.addTableTab( tableName: tableName, databaseType: connection.type, databaseName: payload.databaseName ?? activeDatabaseName, - schemaName: resolvedSchemaName + schemaName: resolvedSchemaName, + isView: payload.isView ) } } catch { diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift new file mode 100644 index 000000000..d8b220b22 --- /dev/null +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -0,0 +1,95 @@ +import Foundation +import TableProPluginKit + +struct RecentTableEntry: Codable, Equatable, Identifiable { + let database: String? + let schema: String? + let name: String + let isView: Bool + let openedAt: Date + + static func identityKey(schema: String?, name: String) -> String { + "\(schema ?? "")\u{1}\(name)" + } + + var scopeKey: String { database ?? "" } + + var identityKey: String { Self.identityKey(schema: schema, name: name) } + + var id: String { "\(scopeKey)\u{1}\(identityKey)" } + + var tableInfo: TableInfo { + TableInfo(name: name, type: isView ? .view : .table, rowCount: nil, schema: schema) + } +} + +struct RecentTableRow: Identifiable { + let table: TableInfo + + var id: String { "recent\u{1}\(table.id)" } +} + +@MainActor +final class RecentTablesStore { + static let shared = RecentTablesStore() + + static let perDatabaseCap = 10 + + private let defaults: UserDefaults + private let keyPrefix = "RecentTables.v1." + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func entries(connectionId: UUID) -> [RecentTableEntry] { + guard let data = defaults.data(forKey: storageKey(connectionId)) else { return [] } + return (try? JSONDecoder().decode([RecentTableEntry].self, from: data)) ?? [] + } + + @discardableResult + func record( + connectionId: UUID, database: String?, schema: String?, name: String, isView: Bool, at date: Date = Date() + ) -> [RecentTableEntry] { + let entry = RecentTableEntry(database: database, schema: schema, name: name, isView: isView, openedAt: date) + let updated = Self.merged(entry, into: entries(connectionId: connectionId)) + persist(updated, connectionId: connectionId) + return updated + } + + @discardableResult + func remove(connectionId: UUID, entry: RecentTableEntry) -> [RecentTableEntry] { + let updated = entries(connectionId: connectionId).filter { $0.id != entry.id } + persist(updated, connectionId: connectionId) + return updated + } + + @discardableResult + func clear(connectionId: UUID, database: String?) -> [RecentTableEntry] { + let scope = database ?? "" + let updated = entries(connectionId: connectionId).filter { $0.scopeKey != scope } + persist(updated, connectionId: connectionId) + return updated + } + + static func merged(_ entry: RecentTableEntry, into existing: [RecentTableEntry]) -> [RecentTableEntry] { + var result = existing.filter { $0.id != entry.id } + result.insert(entry, at: 0) + var perScopeCount: [String: Int] = [:] + return result.filter { candidate in + let count = perScopeCount[candidate.scopeKey, default: 0] + guard count < perDatabaseCap else { return false } + perScopeCount[candidate.scopeKey] = count + 1 + return true + } + } + + private func persist(_ entries: [RecentTableEntry], connectionId: UUID) { + guard let data = try? JSONEncoder().encode(entries) else { return } + defaults.set(data, forKey: storageKey(connectionId)) + } + + private func storageKey(_ connectionId: UUID) -> String { + keyPrefix + connectionId.uuidString + } +} diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index edde7a549..492cc7b04 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -143,11 +143,20 @@ final class QueryTabManager { } } + var onTableOpened: ((_ tableName: String, _ schemaName: String?, _ databaseName: String, _ isView: Bool, _ isPreview: Bool) -> Void)? + + private func notifyTableOpened( + tableName: String, schemaName: String?, databaseName: String, isView: Bool, isPreview: Bool + ) { + onTableOpened?(tableName, schemaName, databaseName, isView, isPreview) + } + func addTableTab( tableName: String, databaseType: DatabaseType = .mysql, databaseName: String = "", schemaName: String? = nil, + isView: Bool = false, quoteIdentifier: ((String) -> String)? = nil ) throws { if let existingTab = tabs.first(where: { @@ -157,6 +166,10 @@ final class QueryTabManager { && $0.tableContext.schemaName == schemaName }) { selectedTabId = existingTab.id + notifyTableOpened( + tableName: tableName, schemaName: schemaName, databaseName: databaseName, + isView: isView, isPreview: false + ) return } @@ -178,6 +191,10 @@ final class QueryTabManager { newTab.tableContext.schemaName = schemaName tabs.append(newTab) selectedTabId = newTab.id + notifyTableOpened( + tableName: tableName, schemaName: schemaName, databaseName: databaseName, + isView: isView, isPreview: false + ) } static func tabTitle(name: String, schema: String?, databaseType: DatabaseType) -> String { @@ -227,6 +244,7 @@ final class QueryTabManager { databaseType: DatabaseType = .mysql, databaseName: String = "", schemaName: String? = nil, + isView: Bool = false, quoteIdentifier: ((String) -> String)? = nil ) throws { if let existing = tabs.first(where: { @@ -236,6 +254,10 @@ final class QueryTabManager { && $0.tableContext.schemaName == schemaName }) { selectedTabId = existing.id + notifyTableOpened( + tableName: tableName, schemaName: schemaName, databaseName: databaseName, + isView: isView, isPreview: true + ) return } @@ -258,6 +280,10 @@ final class QueryTabManager { newTab.isPreview = true tabs.append(newTab) selectedTabId = newTab.id + notifyTableOpened( + tableName: tableName, schemaName: schemaName, databaseName: databaseName, + isView: isView, isPreview: true + ) } /// Replace the currently selected tab's content with a new table. @@ -309,6 +335,10 @@ final class QueryTabManager { tab.isPreview = isPreview tabs[selectedIndex] = tab tabStructureVersion += 1 + notifyTableOpened( + tableName: tableName, schemaName: schemaName, databaseName: databaseName, + isView: isView, isPreview: isPreview + ) return true } diff --git a/TablePro/Models/Settings/GeneralSettings.swift b/TablePro/Models/Settings/GeneralSettings.swift index 999f2b74d..b64911414 100644 --- a/TablePro/Models/Settings/GeneralSettings.swift +++ b/TablePro/Models/Settings/GeneralSettings.swift @@ -63,6 +63,9 @@ 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 + /// Whether to show database object comments in the sidebar and data grid headers var showObjectComments: Bool @@ -72,6 +75,7 @@ struct GeneralSettings: Codable, Equatable { automaticallyCheckForUpdates: true, queryTimeoutSeconds: 60, shareAnalytics: true, + showRecentTables: false, showObjectComments: true ) @@ -81,6 +85,7 @@ struct GeneralSettings: Codable, Equatable { automaticallyCheckForUpdates: Bool = true, queryTimeoutSeconds: Int = 60, shareAnalytics: Bool = true, + showRecentTables: Bool = false, showObjectComments: Bool = true ) { self.startupBehavior = startupBehavior @@ -88,6 +93,7 @@ struct GeneralSettings: Codable, Equatable { self.automaticallyCheckForUpdates = automaticallyCheckForUpdates self.queryTimeoutSeconds = queryTimeoutSeconds self.shareAnalytics = shareAnalytics + self.showRecentTables = showRecentTables self.showObjectComments = showObjectComments } @@ -98,6 +104,7 @@ 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 showObjectComments = try container.decodeIfPresent(Bool.self, forKey: .showObjectComments) ?? true } } diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 8a045973b..4a5c3f2be 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -72,6 +72,10 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { var payload: String? var isOpenInTab: Bool = false + static func tableItemId(name: String, isView: Bool) -> String { + "table_\(name)_\(isView ? "VIEW" : "TABLE")" + } + /// SF Symbol name for this item's icon var iconName: String { switch kind { diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index bc4143f47..b1e5299be 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -27,6 +27,64 @@ final class SharedSidebarState { var searchText: String = "" var favoritesSearchText: String = "" + var recentTables: [RecentTableEntry] = [] + + @ObservationIgnored private var pendingRecordTask: Task? + + func recentEntries(inDatabase database: String?) -> [RecentTableEntry] { + recentTables.filter { $0.database == normalizedDatabase(database) } + } + + func recordTableOpen(database: String?, schema: String?, name: String, isView: Bool, isPreview: Bool) { + guard isPreview else { + pendingRecordTask?.cancel() + pendingRecordTask = nil + commitTableOpen(database: database, schema: schema, name: name, isView: isView) + return + } + pendingRecordTask?.cancel() + pendingRecordTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard let self, !Task.isCancelled else { return } + self.commitTableOpen(database: database, schema: schema, name: name, isView: isView) + } + } + + private func commitTableOpen(database: String?, schema: String?, name: String, isView: Bool) { + QuickSwitcherFrecencyStore(connectionId: connectionId).recordAccess( + itemId: QuickSwitcherItem.tableItemId(name: name, isView: isView) + ) + guard AppSettingsManager.shared.general.showRecentTables else { return } + recentTables = RecentTablesStore.shared.record( + connectionId: connectionId, database: normalizedDatabase(database), + schema: schema, name: name, isView: isView + ) + } + + func removeRecentTable(database: String?, schema: String?, name: String) { + let entry = RecentTableEntry( + database: normalizedDatabase(database), schema: schema, name: name, isView: false, openedAt: Date() + ) + recentTables = RecentTablesStore.shared.remove(connectionId: connectionId, entry: entry) + } + + func clearRecentTables(inDatabase database: String?) { + recentTables = RecentTablesStore.shared.clear( + connectionId: connectionId, database: normalizedDatabase(database) + ) + } + + func reloadRecentTablesFromStore() { + recentTables = AppSettingsManager.shared.general.showRecentTables + ? RecentTablesStore.shared.entries(connectionId: connectionId) + : [] + } + + private func normalizedDatabase(_ database: String?) -> String? { + guard let database, !database.isEmpty else { return nil } + return database + } + var selectedSidebarTab: SidebarTab { didSet { UserDefaults.standard.set( @@ -101,6 +159,9 @@ final class SharedSidebarState { self.selectedFavorite = UserDefaults.standard.string( forKey: SidebarPersistenceKey.selectedFavorite(connectionId: connectionId) ).flatMap(FavoriteSelection.init(rawValue:)) + if AppSettingsManager.shared.general.showRecentTables { + self.recentTables = RecentTablesStore.shared.entries(connectionId: connectionId) + } } /// Default init for previews and tests @@ -112,6 +173,10 @@ final class SharedSidebarState { self.selectedFavorite = nil } + deinit { + pendingRecordTask?.cancel() + } + private static var registry: [UUID: SharedSidebarState] = [:] static func forConnection(_ id: UUID) -> SharedSidebarState { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 8ed8db9fc..bb16ce378 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7315,6 +7315,9 @@ } } } + }, + "Adds a Recent section at the top of the Tables sidebar with the last tables you opened per connection and database." : { + }, "admin" : { "localizations" : { @@ -15204,6 +15207,9 @@ } } } + }, + "Clear Recent Tables" : { + }, "Clear Recents" : { "localizations" : { @@ -63388,6 +63394,9 @@ } } } + }, + "Remove from Recent" : { + }, "Remove from Sidebar" : { "localizations" : { @@ -70820,6 +70829,9 @@ } } } + }, + "Show recent tables" : { + }, "Show row numbers" : { "localizations" : { @@ -75878,6 +75890,9 @@ } } } + }, + "Switching to schema '%@' timed out." : { + }, "Sync" : { "extractionState" : "stale", @@ -78130,6 +78145,9 @@ } } } + }, + "The connection is not available. Reconnect and try again." : { + }, "The Cursor CLI is not installed." : { "localizations" : { diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index c7ea29393..017b7dfc5 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -99,6 +99,14 @@ final class SidebarViewModel { ) } } + var isRecentsExpanded: Bool { + didSet { + UserDefaults.standard.set( + isRecentsExpanded, + forKey: SidebarPersistenceKey.recentsExpanded(connectionId: connectionId) + ) + } + } var redisKeyTreeViewModel: RedisKeyTreeViewModel? var showOperationDialog = false var pendingOperationType: TableOperationType? @@ -165,6 +173,10 @@ final class SidebarViewModel { legacyKey: SidebarPersistenceKey.legacyRedisKeysExpanded, defaultValue: true ) + self.isRecentsExpanded = Self.loadExpansion( + perConnectionKey: SidebarPersistenceKey.recentsExpanded(connectionId: connectionId), + defaultValue: true + ) } private static func loadInitialExpansion(connectionId: UUID) -> ExpansionState { @@ -199,14 +211,14 @@ final class SidebarViewModel { private static func loadExpansion( perConnectionKey: String, - legacyKey: String, + legacyKey: String? = nil, defaultValue: Bool ) -> Bool { let defaults = UserDefaults.standard if defaults.object(forKey: perConnectionKey) != nil { return defaults.bool(forKey: perConnectionKey) } - if defaults.object(forKey: legacyKey) != nil { + if let legacyKey, defaults.object(forKey: legacyKey) != nil { let seeded = defaults.bool(forKey: legacyKey) defaults.set(seeded, forKey: perConnectionKey) return seeded @@ -385,6 +397,10 @@ final class SidebarViewModel { return cachedFilteredByKind[kind] ?? [] } + func filteredRecentTables(_ tables: [TableInfo]) -> [TableInfo] { + applyQuery(filterQuery, to: tables) + } + func filteredRoutines(of kind: SidebarObjectKind, from routines: [RoutineInfo]) -> [RoutineInfo] { let query = filterQuery let fingerprint = (count: routines.count, generation: schemaGeneration, query: query) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 9047e2989..b9c5a2751 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -84,7 +84,8 @@ extension MainContentCoordinator { tableName: tableName, databaseType: connection.type, databaseName: currentDatabase, - schemaName: resolvedSchema + schemaName: resolvedSchema, + isView: isView ) } catch { navigationLogger.error("openTableTab addTableTab failed: \(error.localizedDescription, privacy: .public)") @@ -223,14 +224,16 @@ extension MainContentCoordinator { tableName: tableName, databaseType: connection.type, databaseName: currentDatabase, - schemaName: resolvedSchema + schemaName: resolvedSchema, + isView: isView ) } else { try tabManager.addTableTab( tableName: tableName, databaseType: connection.type, databaseName: currentDatabase, - schemaName: resolvedSchema + schemaName: resolvedSchema, + isView: isView ) } } catch { 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/DatabaseTreeNode.swift b/TablePro/Views/Sidebar/DatabaseTreeNode.swift index bd56e7d24..1d7516bda 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeNode.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -14,6 +14,8 @@ final class DatabaseTreeNode { } enum Kind { + case recentSection + case recentTable(DatabaseTreeTableRef) case database(DatabaseMetadata) case schema(database: String, schema: String) case table(DatabaseTreeTableRef) @@ -31,8 +33,8 @@ final class DatabaseTreeNode { var isExpandable: Bool { switch kind { - case .database, .schema: return true - case .table, .routine, .status: return false + case .recentSection, .database, .schema: return true + case .recentTable, .table, .routine, .status: return false } } @@ -41,9 +43,16 @@ final class DatabaseTreeNode { return nil } + var recentTableRef: DatabaseTreeTableRef? { + if case .recentTable(let ref) = kind { return ref } + return nil + } + + static let recentSectionId = "recent-section" static func databaseId(_ database: String) -> String { "db\u{1}\(database)" } static func schemaId(database: String, schema: String) -> String { "schema\u{1}\(database)\u{1}\(schema)" } static func tableId(_ ref: DatabaseTreeTableRef) -> String { "table\u{1}\(ref.id)" } + static func recentTableId(_ ref: DatabaseTreeTableRef) -> String { "recent\u{1}table\u{1}\(ref.id)" } static func routineId(_ ref: DatabaseTreeRoutineRef) -> String { "routine\u{1}\(ref.id)" } static func statusId(parentId: String, status: Status) -> String { switch status { diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 7c16a4df9..f205a7e47 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -124,6 +124,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func snapshotDependencies() { _ = service.databaseListState(for: connectionId) + _ = sidebarState?.recentTables for node in nodeCache.values { switch node.kind { case .database(let metadata): @@ -133,7 +134,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { case .schema(let database, let schema): _ = service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) _ = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) - case .table, .routine, .status: + case .recentSection, .recentTable, .table, .routine, .status: break } } @@ -173,27 +174,45 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func buildChildren(of node: DatabaseTreeNode?) -> [DatabaseTreeNode] { guard let node else { return rootNodes() } switch node.kind { + case .recentSection: + return recentTableRefs().map { + self.node(id: DatabaseTreeNode.recentTableId($0), kind: .recentTable($0)) + } case .database(let metadata): return supportsSchemaLevel ? schemaNodes(database: metadata.name) : objectNodes(database: metadata.name, schema: nil) case .schema(let database, let schema): return objectNodes(database: database, schema: schema) - case .table, .routine, .status: + case .recentTable, .table, .routine, .status: return [] } } private func rootNodes() -> [DatabaseTreeNode] { + var nodes: [DatabaseTreeNode] = [] + if !recentTableRefs().isEmpty { + nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) + } let visible = DatabaseTreeVisibility.visible( databases: service.databases(for: connectionId), selected: sidebarState?.databaseFilterSelected ?? [] ) let matched = searchText.isEmpty ? visible : visible.filter { databaseMatchesSearch($0) } var seen = Set() - return matched + nodes += matched .filter { seen.insert($0.id).inserted } .map { node(id: DatabaseTreeNode.databaseId($0.name), kind: .database($0)) } + return nodes + } + + private func recentTableRefs() -> [DatabaseTreeTableRef] { + guard let sidebarState, AppSettingsManager.shared.general.showRecentTables else { return [] } + let database = mainCoordinator?.activeDatabaseName ?? activeDatabase ?? "" + return sidebarState.recentEntries(inDatabase: database).compactMap { entry -> DatabaseTreeTableRef? in + if !searchText.isEmpty, !DatabaseTreeFilter.matches(searchText, entry.name) { return nil } + return DatabaseTreeTableRef(database: database, schema: entry.schema, table: entry.tableInfo) + } } private func schemaNodes(database: String) -> [DatabaseTreeNode] { @@ -293,6 +312,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject { isApplyingExpansion = true defer { isApplyingExpansion = false } let searching = !searchText.isEmpty + for rootNode in resolvedChildren(of: nil) where rootNode.id == DatabaseTreeNode.recentSectionId { + setExpanded(rootNode, searching || (viewModel?.isRecentsExpanded ?? true)) + } for databaseNode in resolvedChildren(of: nil) { guard case .database(let metadata) = databaseNode.kind else { continue } let want = searching @@ -326,6 +348,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func recordExpansion(_ node: DatabaseTreeNode, expanded: Bool) { switch node.kind { + case .recentSection: + viewModel?.isRecentsExpanded = expanded case .database(let metadata): if expanded { windowState?.expandedTreeDatabases.insert(metadata.name) @@ -339,7 +363,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } else { windowState?.expandedTreeDatabaseSchemas.remove(key) } - case .table, .routine, .status: + case .recentTable, .table, .routine, .status: break } } @@ -356,7 +380,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } case .schema(let database, let schema): loadObjects(database: database, schema: schema) - case .table, .routine, .status: + case .recentSection, .recentTable, .table, .routine, .status: break } } @@ -464,15 +488,29 @@ final class DatabaseTreeOutlineCoordinator: NSObject { refreshObjects: { [weak self] database, schema in self?.refreshObjects(database: database, schema: schema) }, showRoutineDDL: { [weak self] routine in self?.mainCoordinator?.showRoutineDDL(routine) }, batchToggleTruncate: { [weak self] in self?.viewModel?.batchToggleTruncate(tableNames: $0) }, - batchToggleDelete: { [weak self] in self?.viewModel?.batchToggleDelete(tableNames: $0) } + batchToggleDelete: { [weak self] in self?.viewModel?.batchToggleDelete(tableNames: $0) }, + removeRecent: { [weak self] ref in + self?.sidebarState?.removeRecentTable(database: ref.database, schema: ref.schema, name: ref.table.name) + }, + clearRecents: { [weak self] in + self?.sidebarState?.clearRecentTables(inDatabase: self?.mainCoordinator?.activeDatabaseName) + } ) } + @objc + func handleSingleClick() { + guard let outlineView, outlineView.clickedRow >= 0, + let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode, + let ref = node.recentTableRef else { return } + scheduleSingleClickOpen(ref) + } + @objc func handleDoubleClick() { guard let outlineView, outlineView.clickedRow >= 0, let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode else { return } - if let ref = node.tableRef { + if let ref = node.tableRef ?? node.recentTableRef { pendingSingleClickWork?.cancel() pendingSingleClickWork = nil open(ref, activateGridFocus: true, forceNewWindowTab: true) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift index 10a52020f..e675b8abe 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift @@ -46,6 +46,7 @@ struct DatabaseTreeOutlineView: NSViewRepresentable { outlineView.dataSource = context.coordinator outlineView.delegate = context.coordinator outlineView.target = context.coordinator + outlineView.action = #selector(DatabaseTreeOutlineCoordinator.handleSingleClick) outlineView.doubleAction = #selector(DatabaseTreeOutlineCoordinator.handleDoubleClick) context.coordinator.attach(outlineView: outlineView) diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 0899f115d..f079ea97c 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -18,6 +18,8 @@ struct DatabaseTreeRowActions { let showRoutineDDL: (RoutineInfo) -> Void let batchToggleTruncate: ([String]) -> Void let batchToggleDelete: ([String]) -> Void + let removeRecent: (DatabaseTreeTableRef) -> Void + let clearRecents: () -> Void } struct DatabaseTreeRowContext { @@ -51,6 +53,20 @@ struct DatabaseTreeRowView: View { @ViewBuilder private var rowContent: some View { switch node.kind { + case .recentSection: + header( + text: String(localized: "Recent"), + systemImage: "clock.arrow.circlepath", + isActive: false, + isSystem: false + ) + case .recentTable(let ref): + TableRow( + table: ref.table, + isPendingTruncate: context.pendingTruncates.contains(ref.table.name), + isPendingDelete: context.pendingDeletes.contains(ref.table.name) + ) + .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) case .database(let metadata): header( text: metadata.name, @@ -114,13 +130,34 @@ struct DatabaseTreeRowView: View { } private var hasContextMenu: Bool { - if case .status = node.kind { return false } - return true + switch node.kind { + case .status, .recentSection: return false + default: return true + } } @ViewBuilder private var menuItems: some View { switch node.kind { + case .recentSection: + EmptyView() + case .recentTable(let ref): + SidebarContextMenu( + clickedTable: ref.table, + selectedTables: [ref.table], + isReadOnly: actions.isReadOnly, + onBatchToggleTruncate: actions.batchToggleTruncate, + onBatchToggleDelete: actions.batchToggleDelete, + coordinator: actions.coordinator, + activateBeforeAction: { await actions.activate(ref) } + ) + Divider() + Button(String(localized: "Remove from Recent")) { + actions.removeRecent(ref) + } + Button(String(localized: "Clear Recent Tables")) { + actions.clearRecents() + } case .database(let metadata): Button(String(localized: "Use as Active Database")) { actions.setActiveDatabase(metadata.name) diff --git a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift index 405c69611..feec084d6 100644 --- a/TablePro/Views/Sidebar/SidebarPersistenceKey.swift +++ b/TablePro/Views/Sidebar/SidebarPersistenceKey.swift @@ -12,6 +12,10 @@ enum SidebarPersistenceKey { "sidebar.\(connectionId.uuidString).redisKeys.expanded" } + static func recentsExpanded(connectionId: UUID) -> String { + "sidebar.\(connectionId.uuidString).recents.expanded" + } + static func selectedTab(connectionId: UUID) -> String { "sidebar.selectedTab.\(connectionId.uuidString)" } diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 8655bacf3..afd7687b0 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -7,13 +7,26 @@ struct SidebarTreeView: View { let connectionId: UUID let viewModel: SidebarViewModel let windowState: WindowSidebarState + var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set var onDoubleClick: ((TableInfo) -> Void)? weak var coordinator: MainContentCoordinator? + @State private var settingsManager = AppSettingsManager.shared @State private var searchLoadTask: Task? + private var activeDatabase: String? { + let name = coordinator?.activeDatabaseName ?? "" + return name.isEmpty ? nil : name + } + + private var recentRows: [RecentTableRow] { + guard settingsManager.general.showRecentTables else { return [] } + let infos = sidebarState.recentEntries(inDatabase: activeDatabase).map(\.tableInfo) + return viewModel.filteredRecentTables(infos).map(RecentTableRow.init) + } + private var systemSchemas: Set { Set(PluginManager.shared.systemSchemaNames(for: viewModel.databaseType)) } @@ -55,6 +68,7 @@ struct SidebarTreeView: View { private var treeList: some View { List(selection: selectedTablesBinding) { + recentSection ForEach(visibleSchemas, id: \.self) { schema in Section(isExpanded: expansionBinding(for: schema)) { datasetContent(for: schema) @@ -116,17 +130,65 @@ struct SidebarTreeView: View { ) .tag(table) .contextMenu { - SidebarContextMenu( - clickedTable: table, - selectedTables: windowState.selectedTables, - isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, - onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, - onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, - coordinator: coordinator - ) + tableContextMenu(table) + } + } + + @ViewBuilder + private func tableContextMenu(_ table: TableInfo) -> some View { + SidebarContextMenu( + clickedTable: table, + selectedTables: windowState.selectedTables, + isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, + onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, + onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, + coordinator: coordinator + ) + } + + @ViewBuilder + private var recentSection: some View { + let rows = recentRows + if !rows.isEmpty { + Section(isExpanded: recentsExpansionBinding) { + ForEach(rows) { row in + let table = row.table + TableRow( + table: table, + isPendingTruncate: pendingTruncates.contains(table.name), + isPendingDelete: pendingDeletes.contains(table.name) + ) + .selectionDisabled() + .contentShape(Rectangle()) + .onTapGesture { + onDoubleClick?(table) + } + .contextMenu { + tableContextMenu(table) + Divider() + Button(String(localized: "Remove from Recent")) { + sidebarState.removeRecentTable( + database: activeDatabase, schema: table.schema, name: table.name + ) + } + Button(String(localized: "Clear Recent Tables")) { + sidebarState.clearRecentTables(inDatabase: activeDatabase) + } + } + } + } header: { + Text(String(localized: "Recent")) + } } } + private var recentsExpansionBinding: Binding { + Binding( + get: { viewModel.isRecentsExpanded }, + set: { viewModel.isRecentsExpanded = $0 } + ) + } + private func datasetHeader(_ schema: String) -> some View { Text(schema) .contextMenu { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index f174349f3..393d0c2e7 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,6 +11,7 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel @State private var favoriteTables: Set = [] + @State private var settingsManager = AppSettingsManager.shared @State private var showDatabaseFilter: Bool = false private var schemaService: SchemaService { SchemaService.shared } @@ -260,6 +261,7 @@ struct SidebarView: View { connectionId: connectionId, viewModel: viewModel, windowState: windowState, + sidebarState: sidebarState, pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, onDoubleClick: onDoubleClick, @@ -333,6 +335,12 @@ struct SidebarView: View { // MARK: - Table List + private var recentRows: [RecentTableRow] { + guard settingsManager.general.showRecentTables else { return [] } + let infos = sidebarState.recentEntries(inDatabase: activeDatabase).map(\.tableInfo) + return viewModel.filteredRecentTables(infos).map(RecentTableRow.init) + } + private var activeDatabase: String? { let name = coordinator?.activeDatabaseName ?? "" return name.isEmpty ? nil : name @@ -356,8 +364,60 @@ struct SidebarView: View { ) } + @ViewBuilder + private func tableSelectionMenu(clicked: TableInfo?, selected: Set) -> some View { + SidebarContextMenu( + clickedTable: clicked, + selectedTables: selected, + isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, + onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, + onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, + coordinator: coordinator + ) + } + + @ViewBuilder + private var recentSection: some View { + let rows = recentRows + if !rows.isEmpty { + Section(isExpanded: $viewModel.isRecentsExpanded) { + ForEach(rows) { row in + let table = row.table + TableRow( + table: table, + isPendingTruncate: pendingTruncates.contains(table.name), + isPendingDelete: pendingDeletes.contains(table.name), + isFavorite: isFavorite(table), + onToggleFavorite: { toggleFavorite(table) } + ) + .selectionDisabled() + .contentShape(Rectangle()) + .onTapGesture { + onDoubleClick?(table) + } + .contextMenu { + tableSelectionMenu(clicked: table, selected: [table]) + Divider() + Button(String(localized: "Remove from Recent")) { + sidebarState.removeRecentTable( + database: activeDatabase, schema: table.schema, name: table.name + ) + } + Button(String(localized: "Clear Recent Tables")) { + sidebarState.clearRecentTables(inDatabase: activeDatabase) + } + } + } + } header: { + Text(String(localized: "Recent")) + } + } + } + private var tableList: some View { List(selection: selectedTablesBinding) { + recentSection + ForEach(SidebarObjectKind.allCases, id: \.self) { kind in sectionView(for: kind) } @@ -400,6 +460,9 @@ struct SidebarView: View { .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) } + .onChange(of: settingsManager.general.showRecentTables) { _, _ in + sidebarState.reloadRecentTablesFromStore() + } .onAppear { favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) } 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/Models/Query/QueryTabManagerRecordingTests.swift b/TableProTests/Models/Query/QueryTabManagerRecordingTests.swift new file mode 100644 index 000000000..6a2369c00 --- /dev/null +++ b/TableProTests/Models/Query/QueryTabManagerRecordingTests.swift @@ -0,0 +1,68 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("QueryTabManager.onTableOpened") +@MainActor +struct QueryTabManagerRecordingTests { + private struct Opened: Equatable { + let name: String + let schema: String? + let database: String + let isView: Bool + let isPreview: Bool + } + + private func recorder() -> (QueryTabManager, () -> [Opened]) { + let manager = QueryTabManager() + var opened: [Opened] = [] + manager.onTableOpened = { name, schema, database, isView, isPreview in + opened.append(Opened(name: name, schema: schema, database: database, isView: isView, isPreview: isPreview)) + } + return (manager, { opened }) + } + + @Test("addTableTab reports a committed open") + func addTableTabReportsCommitted() throws { + let (manager, opened) = recorder() + try manager.addTableTab(tableName: "orders", databaseName: "shop", schemaName: "sales") + #expect(opened() == [Opened(name: "orders", schema: "sales", database: "shop", isView: false, isPreview: false)]) + } + + @Test("addTableTab carries the view flag") + func addTableTabCarriesViewFlag() throws { + let (manager, opened) = recorder() + try manager.addTableTab(tableName: "orders_view", databaseName: "shop", schemaName: "sales", isView: true) + #expect(opened().first?.isView == true) + } + + @Test("addPreviewTableTab reports the open as a preview") + func previewReportsAsPreview() throws { + let (manager, opened) = recorder() + try manager.addPreviewTableTab(tableName: "orders", databaseName: "shop", schemaName: "sales") + #expect(opened() == [Opened(name: "orders", schema: "sales", database: "shop", isView: false, isPreview: true)]) + } + + @Test("Re-opening an already-open table reports it again") + func reopenReportsAgain() throws { + let (manager, opened) = recorder() + try manager.addTableTab(tableName: "orders", databaseName: "shop", schemaName: "sales") + try manager.addTableTab(tableName: "orders", databaseName: "shop", schemaName: "sales") + #expect(opened().count == 2) + } + + @Test("replaceTabContent reports its preview flag") + func replaceReportsPreviewFlag() throws { + let manager = QueryTabManager() + try manager.addTableTab(tableName: "seed", databaseName: "shop") + var opened: [Opened] = [] + manager.onTableOpened = { name, schema, database, isView, isPreview in + opened.append(Opened(name: name, schema: schema, database: database, isView: isView, isPreview: isPreview)) + } + _ = try manager.replaceTabContent( + tableName: "orders", isView: true, databaseName: "shop", schemaName: "sales", isPreview: true + ) + #expect(opened == [Opened(name: "orders", schema: "sales", database: "shop", isView: true, isPreview: true)]) + } +} diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift new file mode 100644 index 000000000..66204294f --- /dev/null +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("RecentTablesStore") +@MainActor +struct RecentTablesStoreTests { + private func makeStore() throws -> RecentTablesStore { + let defaults = try #require(UserDefaults(suiteName: "RecentTablesTests.\(UUID().uuidString)")) + return RecentTablesStore(defaults: defaults) + } + + @Test("Record inserts entry at the front") + func recordInsertsAtFront() throws { + let store = try makeStore() + let conn = UUID() + store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false) + store.record(connectionId: conn, database: "db", schema: nil, name: "b", isView: false) + #expect(store.entries(connectionId: conn).map(\.name) == ["b", "a"]) + } + + @Test("Record dedupes by identity and bumps to front") + func recordDedupes() throws { + let store = try makeStore() + let conn = UUID() + store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false) + store.record(connectionId: conn, database: "db", schema: nil, name: "b", isView: false) + store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false) + #expect(store.entries(connectionId: conn).map(\.name) == ["a", "b"]) + } + + @Test("Record preserves the view flag") + func recordPreservesViewFlag() throws { + let store = try makeStore() + let conn = UUID() + store.record(connectionId: conn, database: "db", schema: nil, name: "orders_view", isView: true) + #expect(store.entries(connectionId: conn).first?.isView == true) + } + + @Test("Per-database list caps at 10 entries") + func capsPerDatabase() throws { + let store = try makeStore() + let conn = UUID() + for index in 0..<15 { + store.record(connectionId: conn, database: "db", schema: nil, name: "t\(index)", isView: false) + } + let entries = store.entries(connectionId: conn).filter { $0.database == "db" } + #expect(entries.count == 10) + #expect(entries.first?.name == "t14") + #expect(entries.last?.name == "t5") + } + + @Test("Cap applies per database, not per connection") + func capIsPerDatabase() throws { + let store = try makeStore() + let conn = UUID() + for index in 0..<10 { store.record(connectionId: conn, database: "db", schema: nil, name: "d\(index)", isView: false) } + for index in 0..<10 { store.record(connectionId: conn, database: "other", schema: nil, name: "o\(index)", isView: false) } + #expect(store.entries(connectionId: conn).filter { $0.database == "db" }.count == 10) + #expect(store.entries(connectionId: conn).filter { $0.database == "other" }.count == 10) + } + + @Test("Entries isolated per connection") + func isolatedPerConnection() throws { + let store = try makeStore() + let connA = UUID() + let connB = UUID() + store.record(connectionId: connA, database: "db", schema: nil, name: "alpha", isView: false) + store.record(connectionId: connB, database: "db", schema: nil, name: "beta", isView: false) + #expect(store.entries(connectionId: connA).map(\.name) == ["alpha"]) + #expect(store.entries(connectionId: connB).map(\.name) == ["beta"]) + } + + @Test("Schema-qualified table is distinct from same-name unqualified") + func schemaDistinct() throws { + let store = try makeStore() + let conn = UUID() + store.record(connectionId: conn, database: "db", schema: "public", name: "users", isView: false) + store.record(connectionId: conn, database: "db", schema: nil, name: "users", isView: false) + #expect(store.entries(connectionId: conn).count == 2) + } + + @Test("Same name in different databases stays distinct") + func databaseDistinct() throws { + let store = try makeStore() + let conn = UUID() + store.record(connectionId: conn, database: "a", schema: nil, name: "orders", isView: false) + store.record(connectionId: conn, database: "b", schema: nil, name: "orders", isView: false) + #expect(store.entries(connectionId: conn).count == 2) + } + + @Test("Dotted identifiers do not collide") + func dottedIdentifiersDistinct() throws { + let store = try makeStore() + let conn = UUID() + store.record(connectionId: conn, database: "db", schema: "a", name: "b.c", isView: false) + store.record(connectionId: conn, database: "db", schema: "a.b", name: "c", isView: false) + #expect(store.entries(connectionId: conn).count == 2) + } + + @Test("Remove drops the matching entry") + func removeDrops() throws { + let store = try makeStore() + let conn = UUID() + store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false) + let remaining = store.record(connectionId: conn, database: "db", schema: nil, name: "b", isView: false) + let target = try #require(remaining.first { $0.name == "a" }) + store.remove(connectionId: conn, entry: target) + #expect(store.entries(connectionId: conn).map(\.name) == ["b"]) + } + + @Test("Clear removes only the given database") + func clearScopesToDatabase() throws { + let store = try makeStore() + let conn = UUID() + store.record(connectionId: conn, database: "db", schema: nil, name: "a", isView: false) + store.record(connectionId: conn, database: "other", schema: nil, name: "b", isView: false) + store.clear(connectionId: conn, database: "db") + #expect(store.entries(connectionId: conn).map(\.name) == ["b"]) + } + + @Test("Entries persist across store instances on the same defaults") + func persistsAcrossInstances() throws { + let defaults = try #require(UserDefaults(suiteName: "RecentTablesTests.\(UUID().uuidString)")) + let conn = UUID() + RecentTablesStore(defaults: defaults).record( + connectionId: conn, database: "db", schema: nil, name: "a", isView: false + ) + let reopened = RecentTablesStore(defaults: defaults) + #expect(reopened.entries(connectionId: conn).map(\.name) == ["a"]) + } +} diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index 784b9e9ca..d9289a60e 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -487,6 +487,25 @@ struct SidebarViewModelMultiSectionTests { UserDefaults.standard.removeObject(forKey: key) } + @Test("recents expansion defaults to expanded and persists across init") + @MainActor + func recentsExpansionPersists() { + let connectionId = UUID() + let key = SidebarPersistenceKey.recentsExpanded(connectionId: connectionId) + UserDefaults.standard.removeObject(forKey: key) + + let first = makeViewModel(connectionId: connectionId) + #expect(first.isRecentsExpanded == true) + + first.isRecentsExpanded = false + #expect(UserDefaults.standard.bool(forKey: key) == false) + + let second = makeViewModel(connectionId: connectionId) + #expect(second.isRecentsExpanded == false) + + UserDefaults.standard.removeObject(forKey: key) + } + @Test("expansion seeds .table from legacy per-connection key on first init") @MainActor func legacyMigrationFromPerConnectionKey() { diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 0945bd011..857f75de1 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -76,6 +76,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 sidebar with the last 10 tables opened per connection and database. Works in every sidebar layout and persists between launches | + ## 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 22a56967c..8266b9a24 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 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 sidebar. It appears in every sidebar layout: the flat list, the schema tree, and the database tree. 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. Opening a table records it; arrowing through preview tabs does not. Click a row to reopen the table, or right-click to remove one entry or clear the list. Recents persist between launches. + ## SQL Favorites Save queries you run often. Organize them in folders, assign keyword shortcuts, and expand them inline via autocomplete.