Skip to content

Commit f767cb5

Browse files
authored
Quick Switcher revamp: floating panel, fuzzy engine, frecency ranking (#1644)
* feat(toolbar): quick switcher fuzzy engine with match highlighting and frecency ranking * feat(toolbar): quick switcher searches saved queries, loads schema on demand, opens full history queries * feat(toolbar): present quick switcher as a floating panel with glass material * feat(toolbar): quick switcher scopes, open in new tab, switch-to-tab boost, and row actions * refactor(sidebar): unify sidebar and switcher filtering on the fuzzy matcher * refactor(toolbar): rebuild quick switcher panel to the measured Spotlight spec * refactor(toolbar): match quick switcher to real Spotlight anatomy and behavior * fix(toolbar): single click selects a quick switcher row, double click opens it * fix(toolbar): remove single-click delay in quick switcher rows via clickCount * refactor(toolbar): move quick switcher list to native List selection and scope bar * chore(toolbar): sync string catalog for quick switcher strings * Revert "refactor(toolbar): move quick switcher list to native List selection and scope bar" This reverts commit cbe451a. * fix(coordinator): load queries into the empty window instead of opening a second window tab * refactor(toolbar): apply review fixes to quick switcher field coordinator and schema load * fix(toolbar): keep search field identity stable so typing is not interrupted --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 6109cd7 commit f767cb5

28 files changed

Lines changed: 1840 additions & 489 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- BigQuery datasets can be switched from the toolbar, the Cmd+K switcher, and the File menu, including creating and dropping datasets. (#509)
13+
- Quick Switcher now searches saved queries alongside tables, views, databases, and history.
14+
- Quick Switcher scopes: an empty search shows your recent items with round scope buttons beside the search bar; Cmd+1 to Cmd+4 (or the buttons) browse all tables, databases, or queries.
15+
- Option+Return in the Quick Switcher opens the table in a new tab; right-click a result to open its structure, copy the name, or copy the query.
16+
- Tables already open in a tab show an Open badge in the Quick Switcher, rank higher, and Return switches to the existing tab.
1317
- `.psql` and `.pgsql` files now open in the SQL editor like `.sql`: Finder double-click, the open and save panels, and linked SQL folders all accept them. (#1641)
1418

1519
### Changed
1620

1721
- Redis connections now filter with a key-pattern search field and a key-type scope instead of the SQL-style filter row. Patterns use glob syntax like `user:*`, are matched server-side across the whole keyspace, and the type scope narrows results by value type. The old filter row only matched one batch of keys and ignored any filter on Type, TTL, or Value.
1822
- Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509)
23+
- Quick Switcher highlights the matched characters in each result, finds better alignments for camelCase and snake_case names, and ranks items you open often and recently higher.
24+
- Quick Switcher now opens as a Spotlight-style floating panel over the window instead of a modal sheet: large borderless search field, rounded row selection that turns accent-colored while navigating with the keyboard, and an action hint on the selected row. On macOS 26 the panel uses Liquid Glass.
25+
- The sidebar filter, database switcher, and connection switcher now use the same fuzzy matching as the Quick Switcher, so abbreviations like `upv` find `user_profile_view`.
1926
- Refresh (Cmd+R) now acts only on the focused window's connection, instead of also reloading views and clearing autocomplete caches for every other open connection.
2027
- Holding Cmd+R no longer queues a backlog of refreshes that kept running after the key was released; refresh fires once per key press, and rapid presses collapse into a single reload.
2128

@@ -25,6 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2532
- Sorting a query result no longer overwrites the SQL editor text or the contents of an opened `.sql` file; the sort runs as a separate query and the editor keeps what you wrote. (#1645)
2633
- iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads.
2734
- Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633)
35+
- Quick Switcher no longer shows an empty table list when opened before the schema has finished loading.
36+
- Loading a saved query or history entry from the no-tabs screen now opens it in the current window instead of creating a second window tab.
37+
- Opening a query from history in the Quick Switcher loads the full query instead of a 100-character preview.
2838
- Refreshing a table now reloads its data even when the previous load is still running; before, the refresh was silently dropped and the grid kept stale rows. (#1637)
2939
- Cmd+R on a table now reloads its rows instead of failing with a query error; the refresh was sending the database a stray cancel that aborted its own freshly-issued reload.
3040
- SQL autocomplete now suggests tables after JOIN. It detects the clause at the cursor across multi-join and multi-clause queries, so columns no longer appear where a table is expected, and tables lead the list. (#1646)

TablePro/Core/Utilities/UI/FuzzyMatcher.swift

Lines changed: 178 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,92 +2,205 @@
22
// FuzzyMatcher.swift
33
// TablePro
44
//
5-
// Standalone fuzzy matching utility for quick switcher search
6-
//
75

86
import Foundation
97

10-
/// Namespace for fuzzy string matching operations
8+
internal struct FuzzyMatch: Equatable, Sendable {
9+
let score: Int
10+
let matchedIndices: [Int]
11+
}
12+
1113
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 }
14+
private enum Weight {
15+
static let match = 16
16+
static let consecutive = 24
17+
static let firstCharacter = 28
18+
static let separatorBoundary = 20
19+
static let camelBoundary = 18
20+
static let exactCase = 1
21+
static let gapOpen = -3
22+
static let gapExtension = -1
23+
static let leadingGapExtension = -1
24+
static let leadingGapFloor = -8
25+
}
2326

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-
}
27+
private static let separators: Set<Character> = [" ", "_", "-", ".", "/", "$"]
28+
private static let maxScoredCandidateLength = 1_024
29+
private static let maxScoredQueryLength = 64
30+
private static let invalid = Int.min / 4
3931

40-
// Base match score
41-
var matchScore = 1
32+
static func matches(query: String, candidate: String) -> Bool {
33+
let trimmed = query.trimmingCharacters(in: .whitespaces)
34+
guard !trimmed.isEmpty else { return true }
35+
return match(query: trimmed, candidate: candidate) != nil
36+
}
4237

43-
// Record first match position
44-
if firstMatchPosition < 0 {
45-
firstMatchPosition = candidateIndex
46-
}
38+
static func match(query: String, candidate: String) -> FuzzyMatch? {
39+
let queryChars = Array(query)
40+
let candidateChars = Array(candidate)
41+
guard !queryChars.isEmpty, !candidateChars.isEmpty, queryChars.count <= candidateChars.count else {
42+
return nil
43+
}
44+
if candidateChars.count > maxScoredCandidateLength || queryChars.count > maxScoredQueryLength {
45+
return greedyMatch(queryChars: queryChars, candidateChars: candidateChars)
46+
}
47+
return optimalMatch(queryChars: queryChars, candidateChars: candidateChars)
48+
}
4749

48-
// Consecutive match bonus
49-
consecutiveBonus += 1
50-
if consecutiveBonus > 1 {
51-
matchScore += consecutiveBonus * 4
52-
}
50+
private static func optimalMatch(queryChars: [Character], candidateChars: [Character]) -> FuzzyMatch? {
51+
let queryLength = queryChars.count
52+
let candidateLength = candidateChars.count
53+
let foldedQuery = queryChars.map { $0.lowercased() }
54+
let foldedCandidate = candidateChars.map { $0.lowercased() }
55+
let bonuses = boundaryBonuses(for: candidateChars)
56+
57+
var matchScores = [Int](repeating: invalid, count: queryLength * candidateLength)
58+
var bestScores = [Int](repeating: invalid, count: queryLength * candidateLength)
59+
60+
for queryIndex in 0..<queryLength {
61+
var runningGapScore = invalid
62+
for candidateIndex in 0..<candidateLength {
63+
let cell = queryIndex * candidateLength + candidateIndex
64+
var matchScore = invalid
65+
66+
if foldedQuery[queryIndex] == foldedCandidate[candidateIndex] {
67+
let base: Int
68+
if queryIndex == 0 {
69+
base = leadingGapPenalty(for: candidateIndex) + bonuses[candidateIndex]
70+
} else if candidateIndex > 0 {
71+
let diagonal = cell - candidateLength - 1
72+
let viaBoundary = bestScores[diagonal] + bonuses[candidateIndex]
73+
let viaConsecutive = matchScores[diagonal] + Weight.consecutive
74+
base = max(viaBoundary, viaConsecutive)
75+
} else {
76+
base = invalid
77+
}
78+
if isValid(base) {
79+
let caseBonus = queryChars[queryIndex] == candidateChars[candidateIndex]
80+
? Weight.exactCase
81+
: 0
82+
matchScore = base + Weight.match + caseBonus
83+
}
84+
}
5385

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
86+
matchScores[cell] = matchScore
87+
88+
if candidateIndex > 0 {
89+
let previousMatch = matchScores[cell - 1]
90+
let opened = isValid(previousMatch) ? previousMatch + Weight.gapOpen : invalid
91+
let extended = isValid(runningGapScore) ? runningGapScore + Weight.gapExtension : invalid
92+
runningGapScore = max(opened, extended)
93+
} else {
94+
runningGapScore = invalid
6595
}
96+
97+
bestScores[cell] = max(matchScore, runningGapScore)
6698
}
99+
}
67100

68-
// Exact case match bonus
69-
if queryChar == candidateChar {
70-
matchScore += 1
101+
let finalScore = bestScores[queryLength * candidateLength - 1]
102+
guard isValid(finalScore) else { return nil }
103+
104+
let indices = traceback(
105+
queryChars: queryChars,
106+
candidateChars: candidateChars,
107+
matchScores: matchScores,
108+
bestScores: bestScores
109+
)
110+
return FuzzyMatch(score: finalScore, matchedIndices: indices)
111+
}
112+
113+
private static func traceback(
114+
queryChars: [Character],
115+
candidateChars: [Character],
116+
matchScores: [Int],
117+
bestScores: [Int]
118+
) -> [Int] {
119+
let queryLength = queryChars.count
120+
let candidateLength = candidateChars.count
121+
var indices = [Int](repeating: 0, count: queryLength)
122+
var queryIndex = queryLength - 1
123+
var candidateIndex = candidateLength - 1
124+
var matchRequired = false
125+
126+
while queryIndex >= 0, candidateIndex >= 0 {
127+
var cell = queryIndex * candidateLength + candidateIndex
128+
while !matchRequired, candidateIndex > 0, matchScores[cell] != bestScores[cell] {
129+
candidateIndex -= 1
130+
cell -= 1
131+
}
132+
indices[queryIndex] = candidateIndex
133+
if queryIndex > 0, candidateIndex > 0 {
134+
let caseBonus = queryChars[queryIndex] == candidateChars[candidateIndex]
135+
? Weight.exactCase
136+
: 0
137+
let diagonal = cell - candidateLength - 1
138+
matchRequired = matchScores[cell] == matchScores[diagonal] + Weight.consecutive + Weight.match + caseBonus
71139
}
140+
queryIndex -= 1
141+
candidateIndex -= 1
142+
}
143+
return indices
144+
}
145+
146+
private static func greedyMatch(queryChars: [Character], candidateChars: [Character]) -> FuzzyMatch? {
147+
let foldedQuery = queryChars.map { $0.lowercased() }
148+
var score = 0
149+
var indices: [Int] = []
150+
indices.reserveCapacity(queryChars.count)
151+
var queryIndex = 0
152+
var lastMatchIndex = -2
153+
154+
for (candidateIndex, character) in candidateChars.enumerated() {
155+
guard queryIndex < queryChars.count else { break }
156+
guard character.lowercased() == foldedQuery[queryIndex] else { continue }
72157

158+
var matchScore = Weight.match + boundaryBonus(at: candidateIndex, in: candidateChars)
159+
if candidateIndex == lastMatchIndex + 1 {
160+
matchScore += Weight.consecutive
161+
}
162+
if queryChars[queryIndex] == character {
163+
matchScore += Weight.exactCase
164+
}
165+
if indices.isEmpty {
166+
score += leadingGapPenalty(for: candidateIndex)
167+
} else {
168+
let gap = candidateIndex - lastMatchIndex - 1
169+
if gap > 0 {
170+
score += Weight.gapOpen + Weight.gapExtension * (gap - 1)
171+
}
172+
}
73173
score += matchScore
174+
indices.append(candidateIndex)
175+
lastMatchIndex = candidateIndex
74176
queryIndex += 1
75-
candidateIndex += 1
76177
}
77178

78-
// All query characters must be matched
79-
guard queryIndex == queryLen else { return 0 }
179+
guard queryIndex == queryChars.count else { return nil }
180+
return FuzzyMatch(score: score, matchedIndices: indices)
181+
}
80182

81-
// Position bonus
82-
if firstMatchPosition >= 0 {
83-
let positionBonus = max(0, 20 - firstMatchPosition * 2)
84-
score += positionBonus
183+
private static func boundaryBonuses(for candidateChars: [Character]) -> [Int] {
184+
candidateChars.indices.map { boundaryBonus(at: $0, in: candidateChars) }
185+
}
186+
187+
private static func boundaryBonus(at index: Int, in candidateChars: [Character]) -> Int {
188+
guard index > 0 else { return Weight.firstCharacter }
189+
let previous = candidateChars[index - 1]
190+
if separators.contains(previous) {
191+
return Weight.separatorBoundary
192+
}
193+
if previous.isLowercase, candidateChars[index].isUppercase {
194+
return Weight.camelBoundary
85195
}
196+
return 0
197+
}
86198

87-
// Length similarity bonus
88-
let lengthRatio = Double(queryLen) / Double(candidateLen)
89-
score += Int(lengthRatio * 10)
199+
private static func leadingGapPenalty(for firstMatchIndex: Int) -> Int {
200+
max(Weight.leadingGapFloor, Weight.leadingGapExtension * firstMatchIndex)
201+
}
90202

91-
return score
203+
private static func isValid(_ score: Int) -> Bool {
204+
score > invalid / 2
92205
}
93206
}

0 commit comments

Comments
 (0)