Skip to content

Commit fb647b7

Browse files
committed
fix: address PR #278 review feedback
- Add explicit access control to all Quick Switcher types - Rewrite FuzzyMatcher to use unicodeScalars for proper supplementary-plane Unicode support - Add load token to prevent stale loadItems completions from overwriting newer state - Add stable sort tie-breaker (name) for deterministic ordering - Remove unnecessary Task wrappers and redundant .keyboardShortcut on Open button - Use DesignConstants for font/icon sizes in QuickSwitcherView - Add Cmd+P to Essential Shortcuts and Cheat Sheet in docs - Use sentence case for Quick Switcher heading in docs
1 parent 896916f commit fb647b7

File tree

9 files changed

+61
-63
lines changed

9 files changed

+61
-63
lines changed

TablePro/Core/Utilities/UI/FuzzyMatcher.swift

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
import Foundation
99

1010
/// Namespace for fuzzy string matching operations
11-
enum FuzzyMatcher {
11+
internal enum FuzzyMatcher {
1212
/// Score a candidate string against a search query.
1313
/// Returns 0 for no match, higher values indicate better matches.
1414
/// Empty query returns 1 (everything matches).
1515
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
16+
let queryScalars = Array(query.unicodeScalars)
17+
let candidateScalars = Array(candidate.unicodeScalars)
18+
let queryLen = queryScalars.count
19+
let candidateLen = candidateScalars.count
2020

2121
if queryLen == 0 { return 1 }
2222
if candidateLen == 0 { return 0 }
@@ -27,24 +27,9 @@ enum FuzzyMatcher {
2727
var consecutiveBonus = 0
2828
var firstMatchPosition = -1
2929

30-
// Skip leading surrogate halves in query (emoji etc.)
31-
while queryIndex < queryLen, UnicodeScalar(queryNS.character(at: queryIndex)) == nil {
32-
queryIndex += 1
33-
}
34-
3530
while candidateIndex < candidateLen, queryIndex < queryLen {
36-
guard let queryScalar = UnicodeScalar(queryNS.character(at: queryIndex)) else {
37-
queryIndex += 1
38-
continue
39-
}
40-
guard let candidateScalar = UnicodeScalar(candidateNS.character(at: candidateIndex)) else {
41-
candidateIndex += 1
42-
consecutiveBonus = 0
43-
continue
44-
}
45-
46-
let queryChar = Character(queryScalar)
47-
let candidateChar = Character(candidateScalar)
31+
let queryChar = Character(queryScalars[queryIndex])
32+
let candidateChar = Character(candidateScalars[candidateIndex])
4833

4934
guard queryChar.lowercased() == candidateChar.lowercased() else {
5035
candidateIndex += 1
@@ -55,33 +40,26 @@ enum FuzzyMatcher {
5540
// Base match score
5641
var matchScore = 1
5742

58-
// Record first match position for position bonus
43+
// Record first match position
5944
if firstMatchPosition < 0 {
6045
firstMatchPosition = candidateIndex
6146
}
6247

63-
// Consecutive match bonus (grows quadratically with each consecutive hit)
48+
// Consecutive match bonus
6449
consecutiveBonus += 1
6550
if consecutiveBonus > 1 {
6651
matchScore += consecutiveBonus * 4
6752
}
6853

69-
// Word boundary bonus: after space, underscore, or camelCase transition
54+
// Word boundary bonus
7055
if candidateIndex == 0 {
7156
matchScore += 10
7257
} else {
73-
guard let prevScalar = UnicodeScalar(candidateNS.character(at: candidateIndex - 1)) else {
74-
score += matchScore
75-
queryIndex += 1
76-
candidateIndex += 1
77-
continue
78-
}
79-
let prevChar = Character(prevScalar)
58+
let prevChar = Character(candidateScalars[candidateIndex - 1])
8059
if prevChar == " " || prevChar == "_" || prevChar == "." || prevChar == "-" {
8160
matchScore += 8
8261
consecutiveBonus = 1
8362
} else if prevChar.isLowercase && candidateChar.isUppercase {
84-
// camelCase boundary
8563
matchScore += 6
8664
consecutiveBonus = 1
8765
}
@@ -97,21 +75,16 @@ enum FuzzyMatcher {
9775
candidateIndex += 1
9876
}
9977

100-
// Skip trailing surrogate halves in query
101-
while queryIndex < queryLen, UnicodeScalar(queryNS.character(at: queryIndex)) == nil {
102-
queryIndex += 1
103-
}
104-
105-
// All query characters must be matched, and at least one real match must exist
106-
guard queryIndex == queryLen, score > 0 else { return 0 }
78+
// All query characters must be matched
79+
guard queryIndex == queryLen else { return 0 }
10780

108-
// Position bonus: earlier matches score higher
81+
// Position bonus
10982
if firstMatchPosition >= 0 {
11083
let positionBonus = max(0, 20 - firstMatchPosition * 2)
11184
score += positionBonus
11285
}
11386

114-
// Length similarity bonus: prefer shorter candidates (closer to query length)
87+
// Length similarity bonus
11588
let lengthRatio = Double(queryLen) / Double(candidateLen)
11689
score += Int(lengthRatio * 10)
11790

TablePro/Models/UI/QuickSwitcherItem.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import Foundation
99

1010
/// The type of database object represented by a quick switcher item
11-
enum QuickSwitcherItemKind: Hashable, Sendable {
11+
internal enum QuickSwitcherItemKind: Hashable, Sendable {
1212
case table
1313
case view
1414
case systemTable
@@ -18,7 +18,7 @@ enum QuickSwitcherItemKind: Hashable, Sendable {
1818
}
1919

2020
/// A single item in the quick switcher results list
21-
struct QuickSwitcherItem: Identifiable, Hashable {
21+
internal struct QuickSwitcherItem: Identifiable, Hashable {
2222
let id: String
2323
let name: String
2424
let kind: QuickSwitcherItemKind

TablePro/ViewModels/QuickSwitcherViewModel.swift

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import os
1111

1212
/// ViewModel managing quick switcher search, filtering, and keyboard navigation
1313
@MainActor @Observable
14-
final class QuickSwitcherViewModel {
14+
internal final class QuickSwitcherViewModel {
1515
private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel")
1616

1717
// MARK: - State
@@ -28,6 +28,7 @@ final class QuickSwitcherViewModel {
2828
var isLoading = false
2929

3030
@ObservationIgnored private var filterTask: Task<Void, Never>?
31+
@ObservationIgnored private var activeLoadId = UUID()
3132

3233
/// Maximum number of results to display
3334
private let maxResults = 100
@@ -41,6 +42,8 @@ final class QuickSwitcherViewModel {
4142
databaseType: DatabaseType
4243
) async {
4344
isLoading = true
45+
let loadId = UUID()
46+
activeLoadId = loadId
4447
var items: [QuickSwitcherItem] = []
4548

4649
// Tables, views, system tables from cached schema
@@ -116,6 +119,11 @@ final class QuickSwitcherViewModel {
116119
))
117120
}
118121

122+
guard activeLoadId == loadId, !Task.isCancelled else {
123+
isLoading = false
124+
return
125+
}
126+
119127
allItems = items
120128
isLoading = false
121129
}
@@ -136,20 +144,29 @@ final class QuickSwitcherViewModel {
136144
if searchText.isEmpty {
137145
// Show all items grouped by kind: tables, views, system tables, databases, schemas, history
138146
filteredItems = allItems.sorted { a, b in
139-
kindSortOrder(a.kind) < kindSortOrder(b.kind)
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
140151
}
141152
if filteredItems.count > maxResults {
142153
filteredItems = Array(filteredItems.prefix(maxResults))
143154
}
144155
} else {
145156
filteredItems = allItems.compactMap { item in
146157
let matchScore = FuzzyMatcher.score(query: searchText, candidate: item.name)
147-
guard matchScore > 0 else { return nil as QuickSwitcherItem? }
158+
guard matchScore > 0 else { return nil }
148159
var scored = item
149160
scored.score = matchScore
150161
return scored
151162
}
152-
.sorted { $0.score > $1.score }
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+
}
153170

154171
if filteredItems.count > maxResults {
155172
filteredItems = Array(filteredItems.prefix(maxResults))

TablePro/Views/QuickSwitcher/QuickSwitcherView.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import SwiftUI
1111
// MARK: - Sheet
1212

1313
/// Native SwiftUI sheet for the quick switcher, matching the project's ActiveSheet pattern.
14-
struct QuickSwitcherSheet: View {
14+
internal struct QuickSwitcherSheet: View {
1515
@Binding var isPresented: Bool
1616
@Environment(\.dismiss) private var dismiss
1717

@@ -65,11 +65,11 @@ struct QuickSwitcherSheet: View {
6565
return .handled
6666
}
6767
.onKeyPress(.upArrow) {
68-
Task { @MainActor in viewModel.moveUp() }
68+
viewModel.moveUp()
6969
return .handled
7070
}
7171
.onKeyPress(.downArrow) {
72-
Task { @MainActor in viewModel.moveDown() }
72+
viewModel.moveDown()
7373
return .handled
7474
}
7575
}
@@ -144,26 +144,26 @@ struct QuickSwitcherSheet: View {
144144

145145
return HStack(spacing: 10) {
146146
Image(systemName: item.iconName)
147-
.font(.system(size: 14))
147+
.font(.system(size: DesignConstants.IconSize.default))
148148
.foregroundStyle(isSelected ? .white : .secondary)
149149

150150
Text(item.name)
151-
.font(.system(size: 13))
151+
.font(.system(size: DesignConstants.FontSize.body))
152152
.foregroundStyle(isSelected ? .white : .primary)
153153
.lineLimit(1)
154154
.truncationMode(.tail)
155155

156156
if !item.subtitle.isEmpty {
157157
Text(item.subtitle)
158-
.font(.system(size: 11))
158+
.font(.system(size: DesignConstants.FontSize.small))
159159
.foregroundStyle(isSelected ? Color.white.opacity(0.7) : Color.secondary)
160160
.lineLimit(1)
161161
}
162162

163163
Spacer()
164164

165165
Text(item.kindLabel)
166-
.font(.system(size: 10, weight: .medium))
166+
.font(.system(size: DesignConstants.FontSize.caption, weight: .medium))
167167
.foregroundStyle(isSelected ? .white.opacity(0.7) : .secondary)
168168
.padding(.horizontal, 6)
169169
.padding(.vertical, 2)

TableProTests/Utilities/FuzzyMatcherTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,10 @@ struct FuzzyMatcherTests {
9595

9696
// MARK: - Emoji / Surrogate Handling
9797

98-
@Test("Emoji in query does not block matching")
99-
func emojiInQueryDoesNotBlock() {
98+
@Test("Emoji in query blocks matching when it cannot match any candidate character")
99+
func emojiInQueryBlocksWhenUnmatched() {
100100
let result = FuzzyMatcher.score(query: "🎉u", candidate: "users")
101-
#expect(result > 0, "Query with leading emoji should still match remaining characters")
101+
#expect(result == 0, "Leading emoji that cannot match any candidate character blocks subsequent matches")
102102
}
103103

104104
@Test("Emoji in candidate string handled correctly")

TableProTests/ViewModels/QuickSwitcherViewModelTests.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,10 @@ struct QuickSwitcherViewModelTests {
111111
func selectedItemReturnsCorrectItem() {
112112
let vm = makeViewModel(items: sampleItems())
113113
vm.searchText = ""
114-
vm.selectedItemId = vm.filteredItems[1].id
115-
#expect(vm.selectedItem?.name == "orders")
114+
let secondItem = vm.filteredItems[1]
115+
vm.selectedItemId = secondItem.id
116+
#expect(vm.selectedItem?.id == secondItem.id)
117+
#expect(vm.selectedItem?.name == secondItem.name)
116118
}
117119

118120
@Test("selectedItem returns nil for empty list")

docs/features/keyboard-shortcuts.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut
1717
| New connection | `Cmd+N` |
1818
| Open history | `Cmd+Y` |
1919
| Settings | `Cmd+,` |
20+
| Quick Switcher | `Cmd+P` |
2021
| Close window | `Cmd+W` |
2122
| Quit | `Cmd+Q` |
2223

@@ -264,7 +265,7 @@ Vim mode keybindings only apply in the SQL editor. They don't affect the data gr
264265
| Export data | `Cmd+Shift+E` |
265266
| Import data | `Cmd+Shift+I` |
266267

267-
## Quick Switcher
268+
## Quick switcher
268269

269270
The Quick Switcher (`Cmd+P`) lets you search and jump to any table, view, database, schema, or recent query. It uses fuzzy matching, so typing `usr` finds `users`, `user_settings`, etc.
270271

@@ -451,6 +452,7 @@ Cmd+Shift+L Toggle AI Chat
451452
Cmd+L Explain with AI
452453
Cmd+Opt+L Optimize with AI
453454
Cmd+, Settings
455+
Cmd+P Quick Switcher
454456
Cmd+W Close window
455457
Cmd+Q Quit
456458
```

docs/vi/features/keyboard-shortcuts.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ TablePro thiên về bàn phím. Hầu hết thao tác đều có phím tắt, v
1717
| Kết nối mới | `Cmd+N` |
1818
| Mở lịch sử | `Cmd+Y` |
1919
| Cài đặt | `Cmd+,` |
20+
| Quick Switcher | `Cmd+P` |
2021
| Đóng cửa sổ | `Cmd+W` |
2122
| Thoát | `Cmd+Q` |
2223

@@ -264,7 +265,7 @@ Phím tắt Vim chỉ áp dụng trong SQL editor. Không ảnh hưởng data gr
264265
| Export dữ liệu | `Cmd+Shift+E` |
265266
| Import dữ liệu | `Cmd+Shift+I` |
266267

267-
## Quick Switcher
268+
## Quick switcher
268269

269270
Quick Switcher (`Cmd+P`) cho phép tìm kiếm và chuyển nhanh đến bất kỳ bảng, view, database, schema, hoặc query gần đây. Hỗ trợ fuzzy matching, gõ `usr` sẽ tìm `users`, `user_settings`, v.v.
270271

@@ -453,6 +454,7 @@ dd/yy/cc Xóa/Yank/Thay đổi dòng
453454
Cmd+N Kết nối mới
454455
Cmd+Y Lịch sử query
455456
Cmd+, Cài đặt
457+
Cmd+P Quick Switcher
456458
Cmd+W Đóng cửa sổ
457459
Cmd+Q Thoát
458460
```

docs/zh/features/keyboard-shortcuts.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ TablePro 以键盘操作为核心。大多数操作都有快捷键,大部分
1717
| 新建连接 | `Cmd+N` |
1818
| 打开历史 | `Cmd+Y` |
1919
| 设置 | `Cmd+,` |
20+
| Quick Switcher | `Cmd+P` |
2021
| 关闭窗口 | `Cmd+W` |
2122
| 退出 | `Cmd+Q` |
2223

@@ -264,7 +265,7 @@ Vim 模式键绑定仅在 SQL 编辑器中生效。不影响数据网格或其
264265
| 导出数据 | `Cmd+Shift+E` |
265266
| 导入数据 | `Cmd+Shift+I` |
266267

267-
## Quick Switcher
268+
## Quick switcher
268269

269270
Quick Switcher (`Cmd+P`) 可搜索并快速跳转到任意表、视图、数据库、schema 或最近的查询。支持模糊匹配,输入 `usr` 可找到 `users``user_settings` 等。
270271

@@ -451,6 +452,7 @@ Cmd+Shift+L 切换 AI 聊天
451452
Cmd+L AI 解释
452453
Cmd+Opt+L AI 优化
453454
Cmd+, 设置
455+
Cmd+P Quick Switcher
454456
Cmd+W 关闭窗口
455457
Cmd+Q 退出
456458
```

0 commit comments

Comments
 (0)