Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
16c36f8
fix(sidebar): reload recent tables when the active database changes
datlechin Jun 27, 2026
38b1375
Merge remote-tracking branch 'origin/main' into recent-tables
datlechin Jun 27, 2026
0961d8e
Merge remote-tracking branch 'origin/main' into recent-tables
datlechin Jul 5, 2026
93cffea
refactor(sidebar): record recent tables at the open chokepoint and sh…
datlechin Jul 5, 2026
25bf1d8
refactor(sidebar): make recent tables self-contained and record commi…
datlechin Jul 5, 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 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)
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions TablePro/Core/Services/Infrastructure/SessionStateFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
95 changes: 95 additions & 0 deletions TablePro/Core/Storage/RecentTablesStore.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
30 changes: 30 additions & 0 deletions TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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: {
Expand All @@ -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
}

Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down
7 changes: 7 additions & 0 deletions TablePro/Models/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -72,6 +75,7 @@ struct GeneralSettings: Codable, Equatable {
automaticallyCheckForUpdates: true,
queryTimeoutSeconds: 60,
shareAnalytics: true,
showRecentTables: false,
showObjectComments: true
)

Expand All @@ -81,13 +85,15 @@ struct GeneralSettings: Codable, Equatable {
automaticallyCheckForUpdates: Bool = true,
queryTimeoutSeconds: Int = 60,
shareAnalytics: Bool = true,
showRecentTables: Bool = false,
showObjectComments: Bool = true
) {
self.startupBehavior = startupBehavior
self.language = language
self.automaticallyCheckForUpdates = automaticallyCheckForUpdates
self.queryTimeoutSeconds = queryTimeoutSeconds
self.shareAnalytics = shareAnalytics
self.showRecentTables = showRecentTables
self.showObjectComments = showObjectComments
}

Expand All @@ -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
}
}
4 changes: 4 additions & 0 deletions TablePro/Models/UI/QuickSwitcherItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions TablePro/Models/UI/SharedSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,64 @@ final class SharedSidebarState {
var searchText: String = ""
var favoritesSearchText: String = ""

var recentTables: [RecentTableEntry] = []

@ObservationIgnored private var pendingRecordTask: Task<Void, Never>?

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(
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading
Loading