Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Mark a table as a favorite by clicking the star button at the end of its sidebar row. Favorites are scoped to the connection, database, and schema, pinned to the top of their section, appear in a dedicated Tables group in the Favorites tab, and sync through iCloud when the Table Favorites toggle is on.
- A plus button in the bottom bar of the Tables sidebar opens a menu to create a new table or view, without right-clicking. It's disabled while safe mode blocks writes.
- 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)
- A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254)

### Changed
Expand Down
68 changes: 68 additions & 0 deletions TablePro/Core/Storage/RecentTablesStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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)
var list = entriesByKey[key] ?? []
let newEntryId = entryId(name: table.name, schema: table.schema)
list.removeAll { $0.id == newEntryId }
list.insert(
Entry(
name: table.name,
schema: table.schema,
type: table.type
),
at: 0
)
if list.count > cap {
list = Array(list.prefix(cap))
}
entriesByKey[key] = list
NotificationCenter.default.post(name: .recentTablesDidChange, object: nil)
}

func entries(connectionID: UUID, database: String?) -> [Entry] {
entriesByKey[Key(connectionID: connectionID, database: database)] ?? []
}

func clear(connectionID: UUID, database: String?) {
entriesByKey.removeValue(forKey: Key(connectionID: connectionID, database: database))
NotificationCenter.default.post(name: .recentTablesDidChange, object: nil)
}

func clearAll() {
entriesByKey.removeAll()
NotificationCenter.default.post(name: .recentTablesDidChange, object: nil)
}

var cappedSize: Int { cap }

private func entryId(name: String, schema: String?) -> String {
schema.map { "\($0).\(name)" } ?? name
}
}
11 changes: 9 additions & 2 deletions TablePro/Models/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,26 +61,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: .showWelcome,
language: .system,
automaticallyCheckForUpdates: true,
queryTimeoutSeconds: 60,
shareAnalytics: true
shareAnalytics: true,
showRecentTables: false
)

init(
startupBehavior: StartupBehavior = .showWelcome,
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 @@ -90,5 +96,6 @@ struct GeneralSettings: Codable, Equatable {
automaticallyCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .automaticallyCheckForUpdates) ?? true
queryTimeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .queryTimeoutSeconds) ?? 60
shareAnalytics = try container.decodeIfPresent(Bool.self, forKey: .shareAnalytics) ?? true
showRecentTables = try container.decodeIfPresent(Bool.self, forKey: .showRecentTables) ?? false
}
}
1 change: 1 addition & 0 deletions TablePro/ViewModels/SidebarViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ final class SidebarViewModel {
)
}
}
var isRecentsExpanded: Bool = true
var redisKeyTreeViewModel: RedisKeyTreeViewModel?
var showOperationDialog = false
var pendingOperationType: TableOperationType?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ extension MainContentCoordinator {
redirectToSibling: Bool = false,
forceNonPreview: 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
5 changes: 5 additions & 0 deletions TablePro/Views/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ struct GeneralSettingsView: View {
.help("When enabled, tabs from different connections share the same window instead of opening separate windows.")
}

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.")
}

Section("Query Execution") {
Picker("Query timeout:", selection: $settings.queryTimeoutSeconds) {
Text("No limit").tag(0)
Expand Down
71 changes: 71 additions & 0 deletions TablePro/Views/Sidebar/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ struct SidebarView: View {
@State private var viewModel: SidebarViewModel
@Bindable private var schemaService = SchemaService.shared
@State private var favoriteTables: Set<FavoriteTablesStorage.FavoriteEntry> = []
@State private var recentTables: [RecentTablesStore.Entry] = []
@State private var settingsManager = AppSettingsManager.shared

var sidebarState: SharedSidebarState
var windowState: WindowSidebarState
Expand Down Expand Up @@ -261,11 +263,24 @@ struct SidebarView: View {

// MARK: - Table List

private var filteredRecents: [RecentTablesStore.Entry] {
let search = viewModel.searchText
guard !search.isEmpty else { return recentTables }
return recentTables.filter { $0.name.localizedCaseInsensitiveContains(search) }
}

private var activeDatabase: String? {
let name = coordinator?.activeDatabaseName ?? ""
return name.isEmpty ? nil : name
}

private func tableInfo(forRecent entry: RecentTablesStore.Entry) -> TableInfo {
if let match = tables.first(where: { $0.name == entry.name && $0.schema == entry.schema }) {
return match
}
return TableInfo(name: entry.name, type: entry.type, rowCount: nil, schema: entry.schema)
}

private func isFavorite(_ table: TableInfo) -> Bool {
favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry(
connectionId: connectionId,
Expand All @@ -284,8 +299,57 @@ struct SidebarView: View {
)
}

private func reloadRecentTables() {
guard settingsManager.general.showRecentTables else {
recentTables = []
return
}
recentTables = RecentTablesStore.shared.entries(
connectionID: connectionId,
database: activeDatabase
)
}

@ViewBuilder
private var recentSection: some View {
let recents = filteredRecents
if settingsManager.general.showRecentTables, !recents.isEmpty {
Section(isExpanded: $viewModel.isRecentsExpanded) {
ForEach(recents) { entry in
let info = tableInfo(forRecent: entry)
TableRow(
table: info,
isPendingTruncate: pendingTruncates.contains(info.name),
isPendingDelete: pendingDeletes.contains(info.name),
isFavorite: isFavorite(info),
onToggleFavorite: { toggleFavorite(info) }
)
.selectionDisabled()
.contentShape(Rectangle())
.onTapGesture {
onDoubleClick?(info)
}
.contextMenu {
SidebarContextMenu(
clickedTable: info,
selectedTables: windowState.selectedTables,
isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false,
onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) },
onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) },
coordinator: coordinator
)
}
}
} header: {
Text(String(localized: "Recent"))
}
}
}

private var tableList: some View {
List(selection: selectedTablesBinding) {
recentSection

ForEach(SidebarObjectKind.allCases, id: \.self) { kind in
sectionView(for: kind)
}
Expand Down Expand Up @@ -322,8 +386,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)
}
}
95 changes: 95 additions & 0 deletions TableProTests/Storage/RecentTablesStoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import Foundation
import Testing

@testable import TablePro

@Suite("RecentTablesStore")
@MainActor
struct RecentTablesStoreTests {
private func makeStore() -> RecentTablesStore {
RecentTablesStore()
}

private func makeTable(_ name: String, schema: String? = nil) -> TableInfo {
TableInfo(name: name, type: .table, rowCount: nil, schema: schema)
}

@Test("Push inserts entry at the front")
func pushInsertsAtFront() {
let store = makeStore()
let conn = UUID()
store.push(connectionID: conn, database: "db", table: makeTable("a"))
store.push(connectionID: conn, database: "db", table: makeTable("b"))
let entries = store.entries(connectionID: conn, database: "db")
#expect(entries.map(\.name) == ["b", "a"])
}

@Test("Push dedupes by table id and bumps to front")
func pushDedupes() {
let store = makeStore()
let conn = UUID()
store.push(connectionID: conn, database: "db", table: makeTable("a"))
store.push(connectionID: conn, database: "db", table: makeTable("b"))
store.push(connectionID: conn, database: "db", table: makeTable("a"))
let entries = store.entries(connectionID: conn, database: "db")
#expect(entries.map(\.name) == ["a", "b"])
}

@Test("Push caps list at 10 entries")
func pushCaps() {
let store = makeStore()
let conn = UUID()
for index in 0..<15 {
store.push(connectionID: conn, database: "db", table: makeTable("t\(index)"))
}
let entries = store.entries(connectionID: conn, database: "db")
#expect(entries.count == store.cappedSize)
#expect(entries.first?.name == "t14")
#expect(entries.last?.name == "t5")
}

@Test("Entries isolated per (connection, database) key")
func entriesIsolated() {
let store = makeStore()
let connA = UUID()
let connB = UUID()
store.push(connectionID: connA, database: "db", table: makeTable("alpha"))
store.push(connectionID: connB, database: "db", table: makeTable("beta"))
store.push(connectionID: connA, database: "other", table: makeTable("gamma"))

#expect(store.entries(connectionID: connA, database: "db").map(\.name) == ["alpha"])
#expect(store.entries(connectionID: connB, database: "db").map(\.name) == ["beta"])
#expect(store.entries(connectionID: connA, database: "other").map(\.name) == ["gamma"])
}

@Test("Schema-qualified table is distinct from same-name unqualified")
func schemaDistinct() {
let store = makeStore()
let conn = UUID()
store.push(connectionID: conn, database: "db", table: makeTable("users", schema: "public"))
store.push(connectionID: conn, database: "db", table: makeTable("users", schema: nil))
let entries = store.entries(connectionID: conn, database: "db")
#expect(entries.count == 2)
}

@Test("Clear removes all entries for a key")
func clearKey() {
let store = makeStore()
let conn = UUID()
store.push(connectionID: conn, database: "db", table: makeTable("a"))
store.push(connectionID: conn, database: "other", table: makeTable("b"))
store.clear(connectionID: conn, database: "db")
#expect(store.entries(connectionID: conn, database: "db").isEmpty)
#expect(store.entries(connectionID: conn, database: "other").map(\.name) == ["b"])
}

@Test("Nil database key is distinct from empty-string database")
func nilDatabaseDistinctFromEmpty() {
let store = makeStore()
let conn = UUID()
store.push(connectionID: conn, database: nil, table: makeTable("sqlite_table"))
store.push(connectionID: conn, database: "postgres", table: makeTable("pg_table"))
#expect(store.entries(connectionID: conn, database: nil).map(\.name) == ["sqlite_table"])
#expect(store.entries(connectionID: conn, database: "postgres").map(\.name) == ["pg_table"])
}
}
6 changes: 6 additions & 0 deletions docs/customization/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ No queries or database content is transmitted.

A tab is "clean" when it's a table tab (not query/create), unpinned, no unsaved changes, and no interactions (sort, filter, selection).

### Sidebar

| Setting | Default | Description |
|---------|---------|-------------|
| **Show recent tables** | Off | Adds a Recent section at the top of the Tables sidebar with the last 10 tables opened per connection and database |

## AI

The **AI** tab configures providers and chat behavior. See [AI Assistant](/features/ai-assistant) for usage. The tab has these sections.
Expand Down
Loading
Loading