Skip to content

Commit 66ebe7e

Browse files
committed
feat: add Quick Switcher with native SwiftUI sheet pattern
Implements Quick Switcher (Cmd+P) for fast navigation to tables, views, databases, schemas, and query history using fuzzy search. Uses the existing ActiveSheet enum + .sheet(item:) pattern matching other dialogs. Also fixes missing .refreshData notification for MySQL/MariaDB/ClickHouse database switching.
1 parent 83de4e2 commit 66ebe7e

11 files changed

Lines changed: 933 additions & 0 deletions
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//
2+
// FuzzyMatcher.swift
3+
// TablePro
4+
//
5+
// Standalone fuzzy matching utility for quick switcher search
6+
//
7+
8+
import Foundation
9+
10+
/// Namespace for fuzzy string matching operations
11+
enum FuzzyMatcher {
12+
/// Score a candidate string against a search query.
13+
/// Returns 0 for no match, higher values indicate better matches.
14+
/// Empty query returns 1 (everything matches).
15+
static func score(query: String, candidate: String) -> Int {
16+
let queryNS = query as NSString
17+
let candidateNS = candidate as NSString
18+
let queryLen = queryNS.length
19+
let candidateLen = candidateNS.length
20+
21+
if queryLen == 0 { return 1 }
22+
if candidateLen == 0 { return 0 }
23+
24+
var score = 0
25+
var queryIndex = 0
26+
var consecutiveBonus = 0
27+
var firstMatchPosition = -1
28+
29+
for candidateIndex in 0..<candidateLen {
30+
guard queryIndex < queryLen else { break }
31+
32+
guard let queryScalar = UnicodeScalar(queryNS.character(at: queryIndex)),
33+
let candidateScalar = UnicodeScalar(candidateNS.character(at: candidateIndex))
34+
else {
35+
consecutiveBonus = 0
36+
continue
37+
}
38+
39+
let queryChar = Character(queryScalar)
40+
let candidateChar = Character(candidateScalar)
41+
42+
guard queryChar.lowercased() == candidateChar.lowercased() else {
43+
consecutiveBonus = 0
44+
continue
45+
}
46+
47+
// Base match score
48+
var matchScore = 1
49+
50+
// Record first match position for position bonus
51+
if firstMatchPosition < 0 {
52+
firstMatchPosition = candidateIndex
53+
}
54+
55+
// Consecutive match bonus (grows quadratically with each consecutive hit)
56+
consecutiveBonus += 1
57+
if consecutiveBonus > 1 {
58+
matchScore += consecutiveBonus * 4
59+
}
60+
61+
// Word boundary bonus: after space, underscore, or camelCase transition
62+
if candidateIndex == 0 {
63+
matchScore += 10
64+
} else {
65+
guard let prevScalar = UnicodeScalar(candidateNS.character(at: candidateIndex - 1)) else {
66+
score += matchScore
67+
queryIndex += 1
68+
continue
69+
}
70+
let prevChar = Character(prevScalar)
71+
if prevChar == " " || prevChar == "_" || prevChar == "." || prevChar == "-" {
72+
matchScore += 8
73+
consecutiveBonus = 1
74+
} else if prevChar.isLowercase && candidateChar.isUppercase {
75+
// camelCase boundary
76+
matchScore += 6
77+
consecutiveBonus = 1
78+
}
79+
}
80+
81+
// Exact case match bonus
82+
if queryChar == candidateChar {
83+
matchScore += 1
84+
}
85+
86+
score += matchScore
87+
queryIndex += 1
88+
}
89+
90+
// All query characters must be matched
91+
guard queryIndex == queryLen else { return 0 }
92+
93+
// Position bonus: earlier matches score higher
94+
if firstMatchPosition >= 0 {
95+
let positionBonus = max(0, 20 - firstMatchPosition * 2)
96+
score += positionBonus
97+
}
98+
99+
// Length similarity bonus: prefer shorter candidates (closer to query length)
100+
let lengthRatio = Double(queryLen) / Double(candidateLen)
101+
score += Int(lengthRatio * 10)
102+
103+
return score
104+
}
105+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//
2+
// QuickSwitcherItem.swift
3+
// TablePro
4+
//
5+
// Data model for quick switcher search results
6+
//
7+
8+
import Foundation
9+
10+
/// The type of database object represented by a quick switcher item
11+
enum QuickSwitcherItemKind: Hashable, Sendable {
12+
case table
13+
case view
14+
case systemTable
15+
case database
16+
case schema
17+
case queryHistory
18+
}
19+
20+
/// A single item in the quick switcher results list
21+
struct QuickSwitcherItem: Identifiable, Hashable {
22+
let id: String
23+
let name: String
24+
let kind: QuickSwitcherItemKind
25+
let subtitle: String
26+
var score: Int = 0
27+
28+
/// SF Symbol name for this item's icon
29+
var iconName: String {
30+
switch kind {
31+
case .table: return "tablecells"
32+
case .view: return "eye"
33+
case .systemTable: return "gearshape"
34+
case .database: return "cylinder"
35+
case .schema: return "folder"
36+
case .queryHistory: return "clock.arrow.circlepath"
37+
}
38+
}
39+
40+
/// Localized display label for the item kind
41+
var kindLabel: String {
42+
switch kind {
43+
case .table: return String(localized: "Table")
44+
case .view: return String(localized: "View")
45+
case .systemTable: return String(localized: "System Table")
46+
case .database: return String(localized: "Database")
47+
case .schema: return String(localized: "Schema")
48+
case .queryHistory: return String(localized: "History")
49+
}
50+
}
51+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//
2+
// QuickSwitcherViewModel.swift
3+
// TablePro
4+
//
5+
// ViewModel for the quick switcher palette
6+
//
7+
8+
import Foundation
9+
import Observation
10+
import os
11+
12+
/// ViewModel managing quick switcher search, filtering, and keyboard navigation
13+
@MainActor @Observable
14+
final class QuickSwitcherViewModel {
15+
private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel")
16+
17+
// MARK: - State
18+
19+
var searchText = "" {
20+
didSet { updateFilter() }
21+
}
22+
23+
var allItems: [QuickSwitcherItem] = [] {
24+
didSet { applyFilter() }
25+
}
26+
private(set) var filteredItems: [QuickSwitcherItem] = []
27+
var selectedItemId: String?
28+
var isLoading = false
29+
30+
@ObservationIgnored private var filterTask: Task<Void, Never>?
31+
32+
/// Maximum number of results to display
33+
private let maxResults = 100
34+
35+
// MARK: - Loading
36+
37+
/// Load all searchable items from the database schema, databases, schemas, and history
38+
func loadItems(
39+
schemaProvider: SQLSchemaProvider,
40+
connectionId: UUID,
41+
databaseType: DatabaseType
42+
) async {
43+
isLoading = true
44+
var items: [QuickSwitcherItem] = []
45+
46+
// Tables, views, system tables from cached schema
47+
let tables = await schemaProvider.getTables()
48+
for table in tables {
49+
let kind: QuickSwitcherItemKind
50+
switch table.type {
51+
case .table: kind = .table
52+
case .view: kind = .view
53+
case .systemTable: kind = .systemTable
54+
}
55+
items.append(QuickSwitcherItem(
56+
id: "table_\(table.name)_\(table.type.rawValue)",
57+
name: table.name,
58+
kind: kind,
59+
subtitle: ""
60+
))
61+
}
62+
63+
// Databases
64+
if let driver = DatabaseManager.shared.driver(for: connectionId) {
65+
do {
66+
let databases = try await driver.fetchDatabases()
67+
for db in databases {
68+
items.append(QuickSwitcherItem(
69+
id: "db_\(db)",
70+
name: db,
71+
kind: .database,
72+
subtitle: String(localized: "Database")
73+
))
74+
}
75+
} catch {
76+
Self.logger.debug("Failed to fetch databases for quick switcher: \(error.localizedDescription, privacy: .public)")
77+
}
78+
79+
// Schemas (only for databases that support them)
80+
let supportsSchemas = [DatabaseType.postgresql, .redshift, .oracle, .mssql]
81+
if supportsSchemas.contains(databaseType) {
82+
do {
83+
let schemas = try await driver.fetchSchemas()
84+
for schema in schemas {
85+
items.append(QuickSwitcherItem(
86+
id: "schema_\(schema)",
87+
name: schema,
88+
kind: .schema,
89+
subtitle: String(localized: "Schema")
90+
))
91+
}
92+
} catch {
93+
Self.logger.debug("Failed to fetch schemas for quick switcher: \(error.localizedDescription, privacy: .public)")
94+
}
95+
}
96+
}
97+
98+
// Recent query history (last 50)
99+
let historyEntries = await QueryHistoryStorage.shared.fetchHistory(
100+
limit: 50,
101+
connectionId: connectionId
102+
)
103+
for entry in historyEntries {
104+
items.append(QuickSwitcherItem(
105+
id: "history_\(entry.id.uuidString)",
106+
name: entry.queryPreview,
107+
kind: .queryHistory,
108+
subtitle: entry.databaseName
109+
))
110+
}
111+
112+
allItems = items
113+
isLoading = false
114+
}
115+
116+
// MARK: - Filtering
117+
118+
/// Debounced filter update
119+
func updateFilter() {
120+
filterTask?.cancel()
121+
filterTask = Task { @MainActor in
122+
try? await Task.sleep(for: .milliseconds(50))
123+
guard !Task.isCancelled else { return }
124+
applyFilter()
125+
}
126+
}
127+
128+
private func applyFilter() {
129+
if searchText.isEmpty {
130+
// Show all items grouped by kind: tables, views, system tables, databases, schemas, history
131+
filteredItems = allItems.sorted { a, b in
132+
kindSortOrder(a.kind) < kindSortOrder(b.kind)
133+
}
134+
if filteredItems.count > maxResults {
135+
filteredItems = Array(filteredItems.prefix(maxResults))
136+
}
137+
} else {
138+
filteredItems = allItems.compactMap { item in
139+
let matchScore = FuzzyMatcher.score(query: searchText, candidate: item.name)
140+
guard matchScore > 0 else { return nil as QuickSwitcherItem? }
141+
var scored = item
142+
scored.score = matchScore
143+
return scored
144+
}
145+
.sorted { $0.score > $1.score }
146+
147+
if filteredItems.count > maxResults {
148+
filteredItems = Array(filteredItems.prefix(maxResults))
149+
}
150+
}
151+
152+
selectedItemId = filteredItems.first?.id
153+
}
154+
155+
private func kindSortOrder(_ kind: QuickSwitcherItemKind) -> Int {
156+
switch kind {
157+
case .table: return 0
158+
case .view: return 1
159+
case .systemTable: return 2
160+
case .database: return 3
161+
case .schema: return 4
162+
case .queryHistory: return 5
163+
}
164+
}
165+
166+
// MARK: - Navigation
167+
168+
func moveUp() {
169+
guard let currentId = selectedItemId,
170+
let currentIndex = filteredItems.firstIndex(where: { $0.id == currentId }),
171+
currentIndex > 0
172+
else { return }
173+
selectedItemId = filteredItems[currentIndex - 1].id
174+
}
175+
176+
func moveDown() {
177+
guard let currentId = selectedItemId,
178+
let currentIndex = filteredItems.firstIndex(where: { $0.id == currentId }),
179+
currentIndex < filteredItems.count - 1
180+
else { return }
181+
selectedItemId = filteredItems[currentIndex + 1].id
182+
}
183+
184+
var selectedItem: QuickSwitcherItem? {
185+
guard let selectedItemId else { return nil }
186+
return filteredItems.first { $0.id == selectedItemId }
187+
}
188+
189+
/// Items grouped by kind for sectioned display
190+
var groupedItems: [(kind: QuickSwitcherItemKind, items: [QuickSwitcherItem])] {
191+
var groups: [QuickSwitcherItemKind: [QuickSwitcherItem]] = [:]
192+
for item in filteredItems {
193+
groups[item.kind, default: []].append(item)
194+
}
195+
return groups.sorted { kindSortOrder($0.key) < kindSortOrder($1.key) }
196+
.map { (kind: $0.key, items: $0.value) }
197+
}
198+
}

TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,8 @@ extension MainContentCoordinator {
437437
}
438438

439439
await loadSchema()
440+
441+
NotificationCenter.default.post(name: .refreshData, object: nil)
440442
} else if connection.type == .postgresql {
441443
DatabaseManager.shared.updateSession(connectionId) { session in
442444
session.connection.database = database

0 commit comments

Comments
 (0)