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
Original file line number Diff line number Diff line change
Expand Up @@ -369,14 +369,14 @@ struct QuickSwitcherPanelContent: View {
/// on screen saying so, that first press reads as a key that did nothing.
private var footer: some View {
HStack(spacing: 14) {
QuickSwitcherKeyHint(symbol: "\u{21A9}", label: String(localized: "Open"))
QuickSwitcherKeyHint(symbol: "\u{2318}\u{21A9}", label: String(localized: "New Tab"))
QuickSwitcherKeyHint(keys: [.returnKey], label: String(localized: "Open"))
QuickSwitcherKeyHint(keys: [.text("\u{2318}"), .returnKey], label: String(localized: "New Tab"))

Spacer(minLength: 0)

QuickSwitcherKeyHint(symbol: "\u{2318}1\u{2013}5", label: String(localized: "Scope"))
QuickSwitcherKeyHint(keys: [.text("\u{2318}1\u{2013}5")], label: String(localized: "Scope"))
QuickSwitcherKeyHint(
symbol: "\u{238B}",
keys: [.text("\u{238B}")],
label: escapeDismissesPanel ? String(localized: "Close") : String(localized: "Clear")
)
}
Expand Down
37 changes: 27 additions & 10 deletions apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,37 @@ internal enum QuickSwitcherRowChrome {
}

internal struct QuickSwitcherKeyHint: View {
let symbol: String
enum Glyph {
case text(String)
case symbol(String)

// The system font has no U+21A9: its first draw in a process loads a fallback font, about 10 ms.
static let returnKey = Glyph.symbol("return")
}

let keys: [Glyph]
let label: String

var body: some View {
HStack(spacing: 4) {
Text(symbol)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.frame(minWidth: 20, minHeight: 17)
.background(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Color(nsColor: .quaternaryFill))
)
HStack(spacing: 1) {
ForEach(keys.indices, id: \.self) { index in
switch keys[index] {
case .text(let text):
Text(text)
case .symbol(let name):
Image(systemName: name)
}
}
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.frame(minWidth: 20, minHeight: 17)
.background(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Color(nsColor: .quaternaryFill))
)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Expand Down
4 changes: 2 additions & 2 deletions apple/TablePro/Views/Results/ColumnJumpPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,12 @@ struct ColumnJumpPanelContent: View {

private var footer: some View {
HStack(spacing: 14) {
QuickSwitcherKeyHint(symbol: "\u{21A9}", label: String(localized: "Jump"))
QuickSwitcherKeyHint(keys: [.returnKey], label: String(localized: "Jump"))

Spacer(minLength: 0)

QuickSwitcherKeyHint(
symbol: "\u{238B}",
keys: [.text("\u{238B}")],
label: escapeDismissesPanel ? String(localized: "Close") : String(localized: "Clear")
)
}
Expand Down
86 changes: 71 additions & 15 deletions apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,23 @@ enum DatabaseTreeFilter {
/// each object's own schema, and the database only when the search names one.
static func filteredTables(_ tables: [TableInfo], searchText: String, database: String? = nil) -> [TableInfo] {
let matched = SidebarSearch(searchText).ranked(tables, name: { $0.name }, database: database, schema: { $0.schema })
return deduplicated(matched, by: \.id)
return deduplicated(matched, by: TableIdentity.init)
}

// The fields `TableInfo.id` is built from, without escaping and joining a string for every row.
// The id reads an empty schema as none, so the key does too.
private struct TableIdentity: Hashable {
let name: String
let schema: String?
let type: TableInfo.TableType
let isTemporary: Bool

init(_ table: TableInfo) {
name = table.name
schema = table.schema?.isEmpty == false ? table.schema : nil
type = table.type
isTemporary = table.isTemporary
}
}

static func filteredRoutines(
Expand Down Expand Up @@ -390,30 +406,49 @@ enum DatabaseTreeFilter {
let rowsBySchema: [String: [TableInfo]]

init(listing: CatalogTableListing.Result, database: String, searchText: String, covered: Set<String>? = nil) {
self.init(
groups: SchemaListingGroups(tables: listing.tables),
unlisted: listing.unlistedSchemas,
database: database,
searchText: searchText,
covered: covered
)
}

init(groups: SchemaListingGroups, unlisted: Set<String>, database: String, searchText: String, covered: Set<String>?) {
let search = SidebarSearch(searchText)
var listed: Set<String> = []
var matched: Set<String> = []
var rowsBySchema: [String: [TableInfo]] = [:]
for table in listing.tables {
guard let schema = table.schema else { continue }
listed.insert(schema)
rowsBySchema[schema, default: []].append(table)
guard !matched.contains(schema),
search.matchesObject(named: table.name, database: database, schema: schema) else { continue }
for (schema, rows) in groups.rowsBySchema
where rows.contains(where: { search.matchesObject(named: $0.name, database: database, schema: schema) }) {
matched.insert(schema)
}
self.listed = listed
self.listed = Set(groups.rowsBySchema.keys)
self.matched = matched
self.unlisted = listing.unlistedSchemas
self.unlisted = unlisted
self.covered = covered
self.rowsBySchema = rowsBySchema
self.rowsBySchema = groups.rowsBySchema
}

func answers(for schema: String) -> Bool {
!unlisted.contains(schema) && covered?.contains(schema) != false
}
}

// A keystroke changes which schemas match, never which rows each schema lists, so the grouping
// waits for the next listing. Grouping 50,000 rows again on every keystroke copied each of them.
struct SchemaListingGroups {
let rowsBySchema: [String: [TableInfo]]

init(tables: [TableInfo]) {
var rowsBySchema: [String: [TableInfo]] = [:]
for table in tables {
guard let schema = table.schema else { continue }
rowsBySchema[schema, default: []].append(table)
}
self.rowsBySchema = rowsBySchema
}
}

// Redraws and body evaluations share one scan per database until the listing, search or coverage moves.
struct SchemaListingMatchesMemo {
private struct Entry {
Expand All @@ -424,21 +459,41 @@ enum DatabaseTreeFilter {
let matches: SchemaListingMatches
}

private struct GroupsEntry {
let tables: SameRows<TableInfo>
let groups: SchemaListingGroups
}

private var entries: [String: Entry] = [:]
private var groupsEntries: [String: GroupsEntry] = [:]

mutating func matches(
listing: CatalogTableListing.Result,
database: String,
searchText: String,
covered: Set<String>?
) -> SchemaListingMatches {
let tables = SameRows(rows: listing.tables)
if let entry = entries[database], entry.searchText == searchText, entry.covered == covered,
entry.unlisted == listing.unlistedSchemas, entry.tables == SameRows(rows: listing.tables) {
entry.unlisted == listing.unlistedSchemas, entry.tables == tables {
return entry.matches
}
let matches = SchemaListingMatches(listing: listing, database: database, searchText: searchText, covered: covered)
let groups: SchemaListingGroups
if let entry = groupsEntries[database], entry.tables == tables {
groups = entry.groups
} else {
groups = SchemaListingGroups(tables: listing.tables)
groupsEntries[database] = GroupsEntry(tables: tables, groups: groups)
}
let matches = SchemaListingMatches(
groups: groups,
unlisted: listing.unlistedSchemas,
database: database,
searchText: searchText,
covered: covered
)
entries[database] = Entry(
tables: SameRows(rows: listing.tables),
tables: tables,
unlisted: listing.unlistedSchemas,
searchText: searchText,
covered: covered,
Expand All @@ -449,6 +504,7 @@ enum DatabaseTreeFilter {

mutating func removeAll() {
entries.removeAll()
groupsEntries.removeAll()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,29 @@ struct DatabaseTreeFilterMemoTests {
#expect(other.matched == ["sales"])
}

@Test("A new search over the same listing keeps its grouping, and a new listing groups again")
func listingGroupingOutlivesTheSearch() {
var memo = DatabaseTreeFilter.SchemaListingMatchesMemo()
let listing = CatalogTableListing.Result(
tables: [table("timesheet", "attendance"), table("orders", "sales"), table("refunds", "sales")],
unlistedSchemas: []
)

let first = memo.matches(listing: listing, database: "shop", searchText: "time", covered: nil)
let refined = memo.matches(listing: listing, database: "shop", searchText: "ref", covered: nil)
let reread = memo.matches(
listing: CatalogTableListing.Result(tables: listing.tables.map { $0 }, unlistedSchemas: []),
database: "shop", searchText: "ref", covered: nil
)

#expect(first.matched == ["attendance"])
#expect(refined.matched == ["sales"])
#expect(refined.listed == ["attendance", "sales"])
#expect(SameRows(rows: refined.rowsBySchema["sales"] ?? []) == SameRows(rows: first.rowsBySchema["sales"] ?? []))
#expect(SameRows(rows: reread.rowsBySchema["sales"] ?? []) != SameRows(rows: refined.rowsBySchema["sales"] ?? []))
#expect(reread.matched == ["sales"])
}

// MARK: - Outline

private struct Fixture {
Expand Down
22 changes: 22 additions & 0 deletions apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ struct DatabaseTreeFilterTests {
#expect(DatabaseTreeFilter.filteredTables(tables, searchText: "c").count == 2)
}

@Test("filteredTables keeps a table apart from a view or a temporary table of the same name")
func filteredTablesKeepsKindsApart() {
let tables = [
TableInfo(name: "users", type: .table, rowCount: 0, schema: "public"),
TableInfo(name: "users", type: .view, rowCount: 0, schema: "public"),
TableInfo(name: "users", type: .table, rowCount: 0, schema: "public", isTemporary: true),
TableInfo(name: "users", type: .table, rowCount: 12, schema: "public")
]
let result = DatabaseTreeFilter.filteredTables(tables, searchText: "use")
#expect(result.map(\.id) == tables.dropLast().map(\.id))
}

@Test("filteredTables reads an empty schema as none, the way a table's id does")
func filteredTablesTreatsEmptySchemaAsNone() {
let tables = [
TableInfo(name: "users", type: .table, rowCount: 0, schema: nil),
TableInfo(name: "users", type: .table, rowCount: 0, schema: "")
]
#expect(tables[0].id == tables[1].id)
#expect(DatabaseTreeFilter.filteredTables(tables, searchText: "").count == 1)
}

@Test("filteredRoutines and filteredUserTypes keep objects whose schema holds a period")
func filteredRoutinesAndTypesKeepDottedNamesApart() {
let routines = [
Expand Down
1 change: 1 addition & 0 deletions apple/changelog.d/3889.changed.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Open Quickly opens faster the first time after launch.
1 change: 1 addition & 0 deletions apple/changelog.d/3889.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The sidebar search responds faster on a database with many schemas.
Loading