Skip to content

Commit f24289f

Browse files
authored
Merge pull request #278 from datlechin/refactor/quick-switcher-native-sheet
feat: add Quick Switcher with native SwiftUI sheet
2 parents 83de4e2 + fb647b7 commit f24289f

14 files changed

Lines changed: 1061 additions & 0 deletions
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
internal 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 queryScalars = Array(query.unicodeScalars)
17+
let candidateScalars = Array(candidate.unicodeScalars)
18+
let queryLen = queryScalars.count
19+
let candidateLen = candidateScalars.count
20+
21+
if queryLen == 0 { return 1 }
22+
if candidateLen == 0 { return 0 }
23+
24+
var score = 0
25+
var queryIndex = 0
26+
var candidateIndex = 0
27+
var consecutiveBonus = 0
28+
var firstMatchPosition = -1
29+
30+
while candidateIndex < candidateLen, queryIndex < queryLen {
31+
let queryChar = Character(queryScalars[queryIndex])
32+
let candidateChar = Character(candidateScalars[candidateIndex])
33+
34+
guard queryChar.lowercased() == candidateChar.lowercased() else {
35+
candidateIndex += 1
36+
consecutiveBonus = 0
37+
continue
38+
}
39+
40+
// Base match score
41+
var matchScore = 1
42+
43+
// Record first match position
44+
if firstMatchPosition < 0 {
45+
firstMatchPosition = candidateIndex
46+
}
47+
48+
// Consecutive match bonus
49+
consecutiveBonus += 1
50+
if consecutiveBonus > 1 {
51+
matchScore += consecutiveBonus * 4
52+
}
53+
54+
// Word boundary bonus
55+
if candidateIndex == 0 {
56+
matchScore += 10
57+
} else {
58+
let prevChar = Character(candidateScalars[candidateIndex - 1])
59+
if prevChar == " " || prevChar == "_" || prevChar == "." || prevChar == "-" {
60+
matchScore += 8
61+
consecutiveBonus = 1
62+
} else if prevChar.isLowercase && candidateChar.isUppercase {
63+
matchScore += 6
64+
consecutiveBonus = 1
65+
}
66+
}
67+
68+
// Exact case match bonus
69+
if queryChar == candidateChar {
70+
matchScore += 1
71+
}
72+
73+
score += matchScore
74+
queryIndex += 1
75+
candidateIndex += 1
76+
}
77+
78+
// All query characters must be matched
79+
guard queryIndex == queryLen else { return 0 }
80+
81+
// Position bonus
82+
if firstMatchPosition >= 0 {
83+
let positionBonus = max(0, 20 - firstMatchPosition * 2)
84+
score += positionBonus
85+
}
86+
87+
// Length similarity bonus
88+
let lengthRatio = Double(queryLen) / Double(candidateLen)
89+
score += Int(lengthRatio * 10)
90+
91+
return score
92+
}
93+
}
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+
internal 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+
internal 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: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
internal 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+
@ObservationIgnored private var activeLoadId = UUID()
32+
33+
/// Maximum number of results to display
34+
private let maxResults = 100
35+
36+
// MARK: - Loading
37+
38+
/// Load all searchable items from the database schema, databases, schemas, and history
39+
func loadItems(
40+
schemaProvider: SQLSchemaProvider,
41+
connectionId: UUID,
42+
databaseType: DatabaseType
43+
) async {
44+
isLoading = true
45+
let loadId = UUID()
46+
activeLoadId = loadId
47+
var items: [QuickSwitcherItem] = []
48+
49+
// Tables, views, system tables from cached schema
50+
let tables = await schemaProvider.getTables()
51+
for table in tables {
52+
let kind: QuickSwitcherItemKind
53+
let subtitle: String
54+
switch table.type {
55+
case .table:
56+
kind = .table
57+
subtitle = ""
58+
case .view:
59+
kind = .view
60+
subtitle = String(localized: "View")
61+
case .systemTable:
62+
kind = .systemTable
63+
subtitle = String(localized: "System")
64+
}
65+
items.append(QuickSwitcherItem(
66+
id: "table_\(table.name)_\(table.type.rawValue)",
67+
name: table.name,
68+
kind: kind,
69+
subtitle: subtitle
70+
))
71+
}
72+
73+
// Databases
74+
if let driver = DatabaseManager.shared.driver(for: connectionId) {
75+
do {
76+
let databases = try await driver.fetchDatabases()
77+
for db in databases {
78+
items.append(QuickSwitcherItem(
79+
id: "db_\(db)",
80+
name: db,
81+
kind: .database,
82+
subtitle: String(localized: "Database")
83+
))
84+
}
85+
} catch {
86+
Self.logger.debug("Failed to fetch databases for quick switcher: \(error.localizedDescription, privacy: .public)")
87+
}
88+
89+
// Schemas (only for databases that support them)
90+
let supportsSchemas = [DatabaseType.postgresql, .redshift, .oracle, .mssql]
91+
if supportsSchemas.contains(databaseType) {
92+
do {
93+
let schemas = try await driver.fetchSchemas()
94+
for schema in schemas {
95+
items.append(QuickSwitcherItem(
96+
id: "schema_\(schema)",
97+
name: schema,
98+
kind: .schema,
99+
subtitle: String(localized: "Schema")
100+
))
101+
}
102+
} catch {
103+
Self.logger.debug("Failed to fetch schemas for quick switcher: \(error.localizedDescription, privacy: .public)")
104+
}
105+
}
106+
}
107+
108+
// Recent query history (last 50)
109+
let historyEntries = await QueryHistoryStorage.shared.fetchHistory(
110+
limit: 50,
111+
connectionId: connectionId
112+
)
113+
for entry in historyEntries {
114+
items.append(QuickSwitcherItem(
115+
id: "history_\(entry.id.uuidString)",
116+
name: entry.queryPreview,
117+
kind: .queryHistory,
118+
subtitle: entry.databaseName
119+
))
120+
}
121+
122+
guard activeLoadId == loadId, !Task.isCancelled else {
123+
isLoading = false
124+
return
125+
}
126+
127+
allItems = items
128+
isLoading = false
129+
}
130+
131+
// MARK: - Filtering
132+
133+
/// Debounced filter update
134+
func updateFilter() {
135+
filterTask?.cancel()
136+
filterTask = Task { @MainActor in
137+
try? await Task.sleep(for: .milliseconds(50))
138+
guard !Task.isCancelled else { return }
139+
applyFilter()
140+
}
141+
}
142+
143+
private func applyFilter() {
144+
if searchText.isEmpty {
145+
// Show all items grouped by kind: tables, views, system tables, databases, schemas, history
146+
filteredItems = allItems.sorted { a, b in
147+
let aOrder = kindSortOrder(a.kind)
148+
let bOrder = kindSortOrder(b.kind)
149+
if aOrder != bOrder { return aOrder < bOrder }
150+
return a.name < b.name
151+
}
152+
if filteredItems.count > maxResults {
153+
filteredItems = Array(filteredItems.prefix(maxResults))
154+
}
155+
} else {
156+
filteredItems = allItems.compactMap { item in
157+
let matchScore = FuzzyMatcher.score(query: searchText, candidate: item.name)
158+
guard matchScore > 0 else { return nil }
159+
var scored = item
160+
scored.score = matchScore
161+
return scored
162+
}
163+
.sorted { a, b in
164+
if a.score != b.score { return a.score > b.score }
165+
let aOrder = kindSortOrder(a.kind)
166+
let bOrder = kindSortOrder(b.kind)
167+
if aOrder != bOrder { return aOrder < bOrder }
168+
return a.name < b.name
169+
}
170+
171+
if filteredItems.count > maxResults {
172+
filteredItems = Array(filteredItems.prefix(maxResults))
173+
}
174+
}
175+
176+
selectedItemId = filteredItems.first?.id
177+
}
178+
179+
private func kindSortOrder(_ kind: QuickSwitcherItemKind) -> Int {
180+
switch kind {
181+
case .table: return 0
182+
case .view: return 1
183+
case .systemTable: return 2
184+
case .database: return 3
185+
case .schema: return 4
186+
case .queryHistory: return 5
187+
}
188+
}
189+
190+
// MARK: - Navigation
191+
192+
func moveUp() {
193+
guard let currentId = selectedItemId,
194+
let currentIndex = filteredItems.firstIndex(where: { $0.id == currentId }),
195+
currentIndex > 0
196+
else { return }
197+
selectedItemId = filteredItems[currentIndex - 1].id
198+
}
199+
200+
func moveDown() {
201+
guard let currentId = selectedItemId,
202+
let currentIndex = filteredItems.firstIndex(where: { $0.id == currentId }),
203+
currentIndex < filteredItems.count - 1
204+
else { return }
205+
selectedItemId = filteredItems[currentIndex + 1].id
206+
}
207+
208+
var selectedItem: QuickSwitcherItem? {
209+
guard let selectedItemId else { return nil }
210+
return filteredItems.first { $0.id == selectedItemId }
211+
}
212+
213+
/// Items grouped by kind for sectioned display
214+
var groupedItems: [(kind: QuickSwitcherItemKind, items: [QuickSwitcherItem])] {
215+
var groups: [QuickSwitcherItemKind: [QuickSwitcherItem]] = [:]
216+
for item in filteredItems {
217+
groups[item.kind, default: []].append(item)
218+
}
219+
return groups.sorted { kindSortOrder($0.key) < kindSortOrder($1.key) }
220+
.map { (kind: $0.key, items: $0.value) }
221+
}
222+
}

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)