Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a764ef6
Update .gitignore
J2TeamNNL May 25, 2026
bc530fd
Update CLAUDE.md
J2TeamNNL May 25, 2026
97c7f11
Update .gitignore
J2TeamNNL May 27, 2026
b1933e9
Merge branch 'TableProApp:main' into main
J2TeamNNL May 28, 2026
2314859
feat(sidebar): add favorite tables
J2TeamNNL May 25, 2026
7c796d2
feat(sidebar): add recent tables, star toggle, create-table button, o…
J2TeamNNL May 25, 2026
81c5000
feat(sidebar): handle tableFavorite in conflict resolution, fix showE…
J2TeamNNL May 26, 2026
7692a1d
Update .gitignore
J2TeamNNL May 26, 2026
124203d
refactor(sidebar): remove ER diagram context menu item, drop SidebarT…
J2TeamNNL May 26, 2026
ea4aae6
fix(sidebar): address PR review blockers and design concerns
J2TeamNNL May 28, 2026
470739c
fix(sidebar): address review — node-id schema, list selection, lock i…
J2TeamNNL May 29, 2026
8fcc894
fix(sidebar): scope table favorites by database
datlechin May 29, 2026
8bd677b
fix(sidebar): show create-table button only on the Tables tab
datlechin May 29, 2026
8213f68
fix(launch): skip live iCloud sync under TABLEPRO_UI_TESTING
datlechin May 29, 2026
86c4a53
refactor(sidebar): drop unused RecentTablesStore lastAccessedAt
datlechin May 29, 2026
fd7378d
Merge remote-tracking branch 'origin/main' into sidebar
datlechin May 29, 2026
ebb78dd
fix(sidebar): use system colors in TableRowLogic to match color tests
datlechin May 29, 2026
f789ac0
Merge remote-tracking branch 'origin/main' into sidebar
datlechin May 29, 2026
9412d92
Merge branch 'main' into sidebar
datlechin May 29, 2026
6bc3da1
refactor(sidebar): move create-table action into the sidebar bottom bar
datlechin May 29, 2026
be936af
refactor(sidebar): reveal favorite star on hover and refine favorites…
datlechin May 29, 2026
e2aa2a7
fix(sidebar): drop hardcoded footer divider, use native bottom bar (s…
datlechin May 29, 2026
21b4d7d
fix(sidebar): footer inherits sidebar vibrancy instead of opaque mate…
datlechin May 29, 2026
22ea00f
fix(sidebar): use hard scroll-edge style so footer divider appears on…
datlechin May 29, 2026
a454140
refactor(sidebar): use a static footer divider, drop scroll-edge expe…
datlechin May 29, 2026
f35b32d
refactor(sidebar): remove the Recent tables section
datlechin May 29, 2026
2f400fe
fix(sidebar): drop duplicate context menu and add accessibility label…
datlechin May 29, 2026
c07ce00
refactor(sidebar): type-safe favorite selection and drop AnyView from…
datlechin May 29, 2026
b2a96b4
fix(test): use explicit self for connectionStorage in GroupStorageTes…
datlechin May 29, 2026
9f5fea2
test(sidebar): cover FavoriteSelection round-trip and scope table sel…
datlechin May 29, 2026
d6bba87
feat(sidebar): restore recent tables section
J2TeamNNL May 29, 2026
d03a6da
feat(settings): add toggle for the sidebar recent tables section
J2TeamNNL May 29, 2026
19b4549
refactor(sidebar): derive recent entry id from Entry.id, cover clearAll
J2TeamNNL May 29, 2026
061ac89
Merge remote-tracking branch 'upstream/main' into recent-tables
J2TeamNNL Jun 2, 2026
2835b29
Merge remote-tracking branch 'upstream/main' into recent-tables
J2TeamNNL Jun 3, 2026
9d69ab6
chore(changelog): merge recent-tables into main, resolve CHANGELOG co…
J2TeamNNL Jun 9, 2026
fae04b7
Merge remote-tracking branch 'origin/main' into recent-tables
datlechin Jun 20, 2026
b2529f9
refactor(sidebar): open recents on double-click, drop stale recents, …
datlechin Jun 20, 2026
108414b
feat(sidebar): persist recents section expanded state per connection
datlechin Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions TablePro/Core/Storage/RecentTablesStore.swift
Original file line number Diff line number Diff line change
@@ -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)] ?? []
}
}
11 changes: 9 additions & 2 deletions TablePro/Models/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,32 @@ 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(
startupBehavior: StartupBehavior = .reopenLast,
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 {
Expand All @@ -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
}
}
12 changes: 12 additions & 0 deletions TablePro/ViewModels/SidebarViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Sidebar/SidebarPersistenceKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down
70 changes: 70 additions & 0 deletions TablePro/Views/Sidebar/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import TableProPluginKit
struct SidebarView: View {
@State private var viewModel: SidebarViewModel
@State private var favoriteTables: Set<FavoriteTablesStorage.FavoriteEntry> = []
@State private var recentTables: [RecentTablesStore.Entry] = []
@State private var settingsManager = AppSettingsManager.shared
@State private var showDatabaseFilter: Bool = false

private var schemaService: SchemaService { SchemaService.shared }
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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()
}
}

Expand Down
28 changes: 28 additions & 0 deletions TableProTests/Models/GeneralSettingsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
84 changes: 84 additions & 0 deletions TableProTests/Storage/RecentTablesStoreTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
}
Loading
Loading