diff --git a/CHANGELOG.md b/CHANGELOG.md index 553ae5ded..4244cfbc1 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 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) + ### Changed - Data grid now serves the row count from its existing cache instead of recomputing it on every layout pass, reducing CPU churn while scrolling large result sets. diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift new file mode 100644 index 000000000..454df2078 --- /dev/null +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -0,0 +1,45 @@ +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)] ?? [] + } +} diff --git a/TablePro/Models/Settings/GeneralSettings.swift b/TablePro/Models/Settings/GeneralSettings.swift index babe1fd53..ab081ae50 100644 --- a/TablePro/Models/Settings/GeneralSettings.swift +++ b/TablePro/Models/Settings/GeneralSettings.swift @@ -63,12 +63,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: .reopenLast, language: .system, automaticallyCheckForUpdates: true, queryTimeoutSeconds: 60, - shareAnalytics: true + shareAnalytics: true, + showRecentTables: false ) init( @@ -76,13 +80,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 { @@ -92,5 +98,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/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index c7ea29393..3c2a13107 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 ) + let recentsKey = SidebarPersistenceKey.recentsExpanded(connectionId: connectionId) + self.isRecentsExpanded = UserDefaults.standard.object(forKey: recentsKey) != nil + ? UserDefaults.standard.bool(forKey: recentsKey) + : true } private static func loadInitialExpansion(connectionId: UUID) -> ExpansionState { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index a95df3aca..bc8dd0217 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/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/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index a85359039..97e19ae09 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 @State private var showDatabaseFilter: Bool = false private var schemaService: SchemaService { SchemaService.shared } @@ -329,6 +331,19 @@ struct SidebarView: View { // MARK: - Table List + private var recentTableInfos: [TableInfo] { + let search = viewModel.searchText + return recentTables.compactMap { entry in + guard let match = tables.first(where: { $0.name == entry.name && $0.schema == entry.schema }) else { + return nil + } + if !search.isEmpty, !match.name.localizedCaseInsensitiveContains(search) { + return nil + } + return match + } + } + private var activeDatabase: String? { let name = coordinator?.activeDatabaseName ?? "" return name.isEmpty ? nil : name @@ -352,8 +367,56 @@ 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 = recentTableInfos + if settingsManager.general.showRecentTables, !recents.isEmpty { + Section(isExpanded: $viewModel.isRecentsExpanded) { + ForEach(recents) { info in + TableRow( + table: info, + isPendingTruncate: pendingTruncates.contains(info.name), + isPendingDelete: pendingDeletes.contains(info.name), + isFavorite: isFavorite(info), + onToggleFavorite: { toggleFavorite(info) } + ) + .selectionDisabled() + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + 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) } @@ -397,8 +460,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..947c3f917 --- /dev/null +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -0,0 +1,84 @@ +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 == 10) + #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("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"]) + } +} 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 f6624f3a6..3dba72a8e 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 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 2e89a9d9b..964d1b069 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.