Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Safe Mode list offering only the levels a connection allows, with the reason under it and in the toolbar tooltip.
- **Show Previous Window Tab** and **Show Next Window Tab** for window tabs, with no default shortcut.
- SQLite 3.53.4 built into the SQLite and libSQL drivers in place of the macOS copy.
- One-time reset of Open Quickly's Recent query history, and of its objects on connections that switch databases.

### Removed

Expand Down Expand Up @@ -339,6 +340,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Closing a background tab with unsaved work landing on its neighbour instead of the tab you were on.
- Show Previous Tab, Show Next Tab and Select Tab 1 to 9 enabled in Agent mode and with no tab to go to.
- Row data of a window's first connection kept in memory after switching to another connection.
- Query picked from Open Quickly's Recent list dropping out of it once the query ran again.
- Table opened in one database shown in Open Quickly's Recent in every other database, and opened there.
- Open Quickly's Recent split between the Connections scope and the other scopes, each showing about half.

### Security

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ enum SessionStateFactory {
},
tabSessionRegistry: tabSessionRegistry
)
let databaseType = connection.type
tabMgr.onTableOpened = { tableName, schemaName, databaseName, isView, objectType, isPreview in
SharedSidebarState.forConnection(connectionId).recordTableOpen(
database: databaseName, schema: schemaName, name: tableName,
isView: isView, objectType: objectType, isPreview: isPreview
isView: isView, objectType: objectType, isPreview: isPreview,
connectionSwitchesDatabases: PluginManager.shared.supportsDatabaseSwitching(for: databaseType)
)
}
tabMgr.onTableSchemaResolved = { tableName, databaseName, schemaName in
Expand Down
3 changes: 1 addition & 2 deletions TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,10 @@ internal struct QuickSwitcherFrecencyStore {
loadAccesses().mapValues { score(for: $0, now: now) }
}

func recentItemIds(limit: Int) -> [String] {
func recentItemIds() -> [String] {
loadAccesses()
.compactMap { itemId, samples in samples.max().map { (itemId, $0) } }
.sorted { $0.1 > $1.1 }
.prefix(limit)
.map(\.0)
}

Expand Down
2 changes: 1 addition & 1 deletion TablePro/Models/Database/IdentityPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal enum IdentityPath {
return joined([schema, name], separator: ".")
}

private static func escaped(_ component: String, separator: Unicode.Scalar) -> String {
internal static func escaped(_ component: String, separator: Unicode.Scalar) -> String {
guard component.unicodeScalars.contains(where: { $0 == separator || $0 == "\\" }) else {
return component
}
Expand Down
63 changes: 63 additions & 0 deletions TablePro/Models/UI/QuickSwitcherFrecencyKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// QuickSwitcherFrecencyKey.swift
// TablePro
//

import CryptoKit
import Foundation

internal enum QuickSwitcherFrecencyKey {
internal struct DatabaseQualifier: Hashable, Sendable {
let database: String?

init(database: String?, connectionSwitchesDatabases: Bool) {
guard connectionSwitchesDatabases, let database, !database.isEmpty else {
self.database = nil
return
}
self.database = database
}
}

static func table(name: String, schema: String?, in qualifier: DatabaseQualifier) -> String {
qualified("table_\(IdentityPath.qualified(name: name, schema: schema))", by: qualifier)
}

static func schema(_ name: String, in qualifier: DatabaseQualifier) -> String {
qualified("schema_\(name)", by: qualifier)
}

static func routine(_ routineId: String, in qualifier: DatabaseQualifier) -> String {
qualified("routine_\(routineId)", by: qualifier)
}

static func trigger(_ triggerId: String, in qualifier: DatabaseQualifier) -> String {
qualified("trigger_\(triggerId)", by: qualifier)
}

static func userType(_ typeId: String, in qualifier: DatabaseQualifier) -> String {
qualified("usertype_\(typeId)", by: qualifier)
}

static func database(_ name: String) -> String {
"db_\(name)"
}

static func savedQuery(_ favoriteId: UUID) -> String {
"favorite_\(favoriteId.uuidString)"
}

static func queryHistory(_ query: String) -> String {
let digest = SHA256.hash(data: Data(normalizedQuery(query).utf8))
return "history_" + digest.map { String(format: "%02x", $0) }.joined()
}

static func normalizedQuery(_ query: String) -> String {
query.trimmingCharacters(in: .whitespacesAndNewlines)
}

private static func qualified(_ key: String, by qualifier: DatabaseQualifier) -> String {
guard let database = qualifier.database else { return key }
return "@\(IdentityPath.escaped(database, separator: "/"))/\(key)"
}
}
30 changes: 6 additions & 24 deletions TablePro/Models/UI/QuickSwitcherItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable {

/// A single item in the quick switcher results list
internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable {
let id: String
let frecencyKey: String
let name: String
let kind: QuickSwitcherItemKind
let subtitle: String
Expand Down Expand Up @@ -132,31 +132,13 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable {
return QualifiedSearchQuery.location(database: target.databaseName, schema: target.schemaName)
}

/// The frecency identity of a table, produced identically by the two places that record one:
/// the quick switcher, which knows the object's `TableInfo.TableType`, and the tab open
/// chokepoint, which only ever learns a Bool.
///
/// The type used to be part of this. It cannot be, because the two sides spell it differently
/// and one of them cannot spell it at all: the switcher used the full `TableType` raw value
/// while the tab derived `isView` from `allowsRowEditing`, so a materialized view was recorded
/// as `TABLE` and looked up as `MATERIALIZED VIEW`. Five of the seven table types disagreed,
/// and those objects could never reach the Recent section or earn a frecency boost no matter
/// how often they were opened. A name and a schema identify one object in a database whatever
/// its type, so the type buys nothing here.
///
/// A dot or backslash inside a name is escaped, or schema `a` with table `b.c` and schema `a.b`
/// with table `c` would share one id, one row selection and one Recent entry. A name with
/// neither keeps the id it always had, so no Recent history is lost.
static func tableItemId(name: String, schema: String?) -> String {
guard let schema, !schema.isEmpty else { return "table_\(escapedIdComponent(name))" }
return "table_\(escapedIdComponent(schema)).\(escapedIdComponent(name))"
var id: String {
guard let target else { return frecencyKey }
return "\(target.connectionId.uuidString)/\(frecencyKey)"
}

private static func escapedIdComponent(_ component: String) -> String {
guard component.contains(where: { $0 == "." || $0 == "\\" }) else { return component }
return component
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: ".", with: "\\.")
func belongs(to connectionId: UUID) -> Bool {
target.map { $0.connectionId == connectionId } ?? true
}

/// SF Symbol name for this item's icon
Expand Down
40 changes: 33 additions & 7 deletions TablePro/Models/UI/SharedSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,32 +44,58 @@ final class SharedSidebarState: ObservableObject {
name: String,
isView: Bool,
objectType: TableInfo.TableType?,
isPreview: Bool
isPreview: Bool,
connectionSwitchesDatabases: Bool
) {
let frecencyKey = Self.tableFrecencyKey(
database: database, schema: schema, name: name,
connectionSwitchesDatabases: connectionSwitchesDatabases
)
guard isPreview else {
pendingRecordTask?.cancel()
pendingRecordTask = nil
commitTableOpen(database: database, schema: schema, name: name, isView: isView, objectType: objectType)
commitTableOpen(
database: database, schema: schema, name: name,
isView: isView, objectType: objectType, frecencyKey: frecencyKey
)
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, objectType: objectType)
self.commitTableOpen(
database: database, schema: schema, name: name,
isView: isView, objectType: objectType, frecencyKey: frecencyKey
)
}
}

nonisolated static func tableFrecencyKey(
database: String?,
schema: String?,
name: String,
connectionSwitchesDatabases: Bool
) -> String {
QuickSwitcherFrecencyKey.table(
name: name,
schema: schema,
in: QuickSwitcherFrecencyKey.DatabaseQualifier(
database: database,
connectionSwitchesDatabases: connectionSwitchesDatabases
)
)
}

private func commitTableOpen(
database: String?,
schema: String?,
name: String,
isView: Bool,
objectType: TableInfo.TableType?
objectType: TableInfo.TableType?,
frecencyKey: String
) {
QuickSwitcherFrecencyStore(connectionId: connectionId).recordAccess(
itemId: QuickSwitcherItem.tableItemId(name: name, schema: schema)
)
QuickSwitcherFrecencyStore(connectionId: connectionId).recordAccess(itemId: frecencyKey)
guard AppSettingsManager.shared.general.showRecentTables else { return }
recentTables = RecentTablesStore.shared.record(
connectionId: connectionId, database: normalizedDatabase(database),
Expand Down
130 changes: 130 additions & 0 deletions TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//
// QuickSwitcherViewModel+QueryItems.swift
// TablePro
//

import Foundation

internal extension QuickSwitcherViewModel {
nonisolated static func makeHistoryItems(_ entries: [QueryHistoryEntry]) -> [QuickSwitcherItem] {
distinctByQuery(entries).prefix(QuickSwitcherRanking.localHistoryLimit).map { entry in
QuickSwitcherItem(
frecencyKey: QuickSwitcherFrecencyKey.queryHistory(entry.query),
name: entry.queryPreview,
kind: .queryHistory,
subtitle: entry.databaseDisplayName,
payload: entry.query
)
}
}

nonisolated static func makeCrossConnectionQueryItems(
favorites: [SQLFavorite],
historyEntries: [QueryHistoryEntry],
targets: [UUID: QuickSwitcherTarget],
currentConnectionId: UUID
) -> [QuickSwitcherItem] {
let favoriteItems = favorites.compactMap { favorite -> QuickSwitcherItem? in
let targetConnectionId = favorite.connectionId ?? currentConnectionId
guard let target = targets[targetConnectionId] else { return nil }
let subtitle = [favorite.keyword, connectionPath(for: target)]
.compactMap { value in value.flatMap { $0.isEmpty ? nil : $0 } }
.joined(separator: " · ")
return QuickSwitcherItem(
frecencyKey: QuickSwitcherFrecencyKey.savedQuery(favorite.id),
name: favorite.name,
kind: .savedQuery,
subtitle: subtitle,
keyword: favorite.keyword,
payload: favorite.query,
target: target
)
}

let historyItems = distinctByQuery(historyEntries).compactMap { entry -> QuickSwitcherItem? in
guard let baseTarget = targets[entry.connectionId] else { return nil }
let databaseName = entry.databaseName.isEmpty ? nil : entry.databaseName
let target = QuickSwitcherTarget(
connectionId: baseTarget.connectionId,
connectionName: baseTarget.connectionName,
databaseName: databaseName,
schemaName: nil,
databaseDisplayName: databaseDisplayName(
databaseName,
pathFieldRole: baseTarget.pathFieldRole
)
)
return QuickSwitcherItem(
frecencyKey: QuickSwitcherFrecencyKey.queryHistory(entry.query),
name: entry.queryPreview,
kind: .queryHistory,
subtitle: [
connectionPath(for: target),
entry.hasMeasuredDuration ? entry.formattedExecutionTime : ""
]
.filter { !$0.isEmpty }
.joined(separator: " · "),
payload: entry.query,
target: target
)
}

return interleaveToCap(favoriteItems, historyItems, cap: QuickSwitcherRanking.maxResults)
}

/// The switcher is a recall list, so one statement run twenty times is one thing to recall.
/// Every execution stays in history; only the list collapses them, keeping the most recent.
nonisolated static func distinctByQuery(_ entries: [QueryHistoryEntry]) -> [QueryHistoryEntry] {
var seen: Set<HistoryStatement> = []
var distinct: [QueryHistoryEntry] = []
for entry in entries {
let query = QuickSwitcherFrecencyKey.normalizedQuery(entry.query)
guard !query.isEmpty,
seen.insert(HistoryStatement(connectionId: entry.connectionId, query: query)).inserted else {
continue
}
distinct.append(entry)
}
return distinct
}

/// Concatenating and truncating let a long favourites list push recent queries out of the
/// panel entirely. Each source keeps its own half of the cap and only lends what it does
/// not use.
nonisolated static func interleaveToCap(
_ favorites: [QuickSwitcherItem],
_ history: [QuickSwitcherItem],
cap: Int
) -> [QuickSwitcherItem] {
guard favorites.count + history.count > cap else { return favorites + history }

let share = cap / 2
let favoriteCount = min(favorites.count, max(share, cap - history.count))
let historyCount = min(history.count, cap - favoriteCount)
return Array(favorites.prefix(favoriteCount)) + Array(history.prefix(historyCount))
}

nonisolated static func interleaveByConnection(
_ perConnection: [[QueryHistoryEntry]],
limit: Int
) -> [QueryHistoryEntry] {
var queues = perConnection.filter { !$0.isEmpty }
var merged: [QueryHistoryEntry] = []
var queueIndex = 0
while merged.count < limit, !queues.isEmpty {
if queueIndex >= queues.count { queueIndex = 0 }
merged.append(queues[queueIndex].removeFirst())
if queues[queueIndex].isEmpty {
queues.remove(at: queueIndex)
} else {
queueIndex += 1
}
}
return merged.sorted { $0.executedAt > $1.executedAt }
}
}

private struct HistoryStatement: Hashable {
let connectionId: UUID
let query: String
}
Loading
Loading