From b7135acee20a4775418d02e0798e3df9934fe39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 30 Oct 2026 05:36:08 +0700 Subject: [PATCH] perf(sidebar): group the schema listing once per listing and draw Return as a symbol (#3889) (cherry picked from commit 1a80a0bca16efd729520e17769a07cb597f17791) --- .../QuickSwitcherPanelView.swift | 8 +- .../QuickSwitcherRowChrome.swift | 37 +++++--- .../Views/Results/ColumnJumpPanelView.swift | 4 +- .../Views/Sidebar/DatabaseTreeFilter.swift | 86 +++++++++++++++---- .../Sidebar/DatabaseTreeFilterMemoTests.swift | 23 +++++ .../Sidebar/DatabaseTreeFilterTests.swift | 22 +++++ apple/changelog.d/3889.changed.1.md | 1 + apple/changelog.d/3889.changed.md | 1 + 8 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 apple/changelog.d/3889.changed.1.md create mode 100644 apple/changelog.d/3889.changed.md diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 4cfc871dd8..8e9abc9f6d 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -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") ) } diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift index 6aa2616a88..a40ef8b2b6 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherRowChrome.swift @@ -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) diff --git a/apple/TablePro/Views/Results/ColumnJumpPanelView.swift b/apple/TablePro/Views/Results/ColumnJumpPanelView.swift index 90dd8bc4df..d5404f6b96 100644 --- a/apple/TablePro/Views/Results/ColumnJumpPanelView.swift +++ b/apple/TablePro/Views/Results/ColumnJumpPanelView.swift @@ -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") ) } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift index b3b5bb2dcb..adbf4d9282 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -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( @@ -390,23 +406,27 @@ enum DatabaseTreeFilter { let rowsBySchema: [String: [TableInfo]] init(listing: CatalogTableListing.Result, database: String, searchText: String, covered: Set? = nil) { + self.init( + groups: SchemaListingGroups(tables: listing.tables), + unlisted: listing.unlistedSchemas, + database: database, + searchText: searchText, + covered: covered + ) + } + + init(groups: SchemaListingGroups, unlisted: Set, database: String, searchText: String, covered: Set?) { let search = SidebarSearch(searchText) - var listed: Set = [] var matched: Set = [] - 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 { @@ -414,6 +434,21 @@ enum DatabaseTreeFilter { } } + // 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 { @@ -424,7 +459,13 @@ enum DatabaseTreeFilter { let matches: SchemaListingMatches } + private struct GroupsEntry { + let tables: SameRows + let groups: SchemaListingGroups + } + private var entries: [String: Entry] = [:] + private var groupsEntries: [String: GroupsEntry] = [:] mutating func matches( listing: CatalogTableListing.Result, @@ -432,13 +473,27 @@ enum DatabaseTreeFilter { searchText: String, covered: Set? ) -> 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, @@ -449,6 +504,7 @@ enum DatabaseTreeFilter { mutating func removeAll() { entries.removeAll() + groupsEntries.removeAll() } } diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterMemoTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterMemoTests.swift index 5d3a75d61e..b06d1d8826 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterMemoTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterMemoTests.swift @@ -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 { diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift index e1f5323fca..21e52a2e21 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift @@ -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 = [ diff --git a/apple/changelog.d/3889.changed.1.md b/apple/changelog.d/3889.changed.1.md new file mode 100644 index 0000000000..ac7cf24a7c --- /dev/null +++ b/apple/changelog.d/3889.changed.1.md @@ -0,0 +1 @@ +Open Quickly opens faster the first time after launch. diff --git a/apple/changelog.d/3889.changed.md b/apple/changelog.d/3889.changed.md new file mode 100644 index 0000000000..b6257b68ce --- /dev/null +++ b/apple/changelog.d/3889.changed.md @@ -0,0 +1 @@ +The sidebar search responds faster on a database with many schemas.