Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9c98ffa
feat(toolbar): quick switcher fuzzy engine with match highlighting an…
datlechin Jun 10, 2026
4004371
feat(toolbar): quick switcher searches saved queries, loads schema on…
datlechin Jun 10, 2026
ec35850
feat(toolbar): present quick switcher as a floating panel with glass …
datlechin Jun 10, 2026
e3c312b
feat(toolbar): quick switcher scopes, open in new tab, switch-to-tab …
datlechin Jun 10, 2026
1871f30
refactor(sidebar): unify sidebar and switcher filtering on the fuzzy …
datlechin Jun 10, 2026
7f20dae
refactor(toolbar): rebuild quick switcher panel to the measured Spotl…
datlechin Jun 10, 2026
d4bb25c
Merge branch 'main' into feat/quick-switcher-fuzzy-engine
datlechin Jun 11, 2026
250ef26
Merge branch 'main' into feat/quick-switcher-fuzzy-engine
datlechin Jun 11, 2026
701f53a
refactor(toolbar): match quick switcher to real Spotlight anatomy and…
datlechin Jun 12, 2026
142349c
fix(toolbar): single click selects a quick switcher row, double click…
datlechin Jun 12, 2026
ae5ebcd
fix(toolbar): remove single-click delay in quick switcher rows via cl…
datlechin Jun 12, 2026
cbe451a
refactor(toolbar): move quick switcher list to native List selection …
datlechin Jun 12, 2026
0bc3cdc
chore(toolbar): sync string catalog for quick switcher strings
datlechin Jun 12, 2026
999709f
Revert "refactor(toolbar): move quick switcher list to native List se…
datlechin Jun 12, 2026
dd7c916
fix(coordinator): load queries into the empty window instead of openi…
datlechin Jun 12, 2026
c678440
refactor(toolbar): apply review fixes to quick switcher field coordin…
datlechin Jun 12, 2026
ae6193f
fix(toolbar): keep search field identity stable so typing is not inte…
datlechin Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- BigQuery datasets can be switched from the toolbar, the Cmd+K switcher, and the File menu, including creating and dropping datasets. (#509)
- Quick Switcher now searches saved queries alongside tables, views, databases, and history.
- 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.
- 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.
- Tables already open in a tab show an Open badge in the Quick Switcher, rank higher, and Return switches to the existing tab.
- `.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)

### Changed

- 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.
- Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509)
- 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.
- 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.
- 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`.
- 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.
- 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.

### Fixed

- 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.
- 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)
- Quick Switcher no longer shows an empty table list when opened before the schema has finished loading.
- 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.
- Opening a query from history in the Quick Switcher loads the full query instead of a 100-character preview.
- 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)
- 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.
- 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)
Expand Down
243 changes: 178 additions & 65 deletions TablePro/Core/Utilities/UI/FuzzyMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,92 +2,205 @@
// FuzzyMatcher.swift
// TablePro
//
// Standalone fuzzy matching utility for quick switcher search
//

import Foundation

/// Namespace for fuzzy string matching operations
internal struct FuzzyMatch: Equatable, Sendable {
let score: Int
let matchedIndices: [Int]
}

internal enum FuzzyMatcher {
/// Score a candidate string against a search query.
/// Returns 0 for no match, higher values indicate better matches.
/// Empty query returns 1 (everything matches).
static func score(query: String, candidate: String) -> Int {
let queryScalars = Array(query.unicodeScalars)
let candidateScalars = Array(candidate.unicodeScalars)
let queryLen = queryScalars.count
let candidateLen = candidateScalars.count

if queryLen == 0 { return 1 }
if candidateLen == 0 { return 0 }
private enum Weight {
static let match = 16
static let consecutive = 24
static let firstCharacter = 28
static let separatorBoundary = 20
static let camelBoundary = 18
static let exactCase = 1
static let gapOpen = -3
static let gapExtension = -1
static let leadingGapExtension = -1
static let leadingGapFloor = -8
}

var score = 0
var queryIndex = 0
var candidateIndex = 0
var consecutiveBonus = 0
var firstMatchPosition = -1

while candidateIndex < candidateLen, queryIndex < queryLen {
let queryChar = Character(queryScalars[queryIndex])
let candidateChar = Character(candidateScalars[candidateIndex])

guard queryChar.lowercased() == candidateChar.lowercased() else {
candidateIndex += 1
consecutiveBonus = 0
continue
}
private static let separators: Set<Character> = [" ", "_", "-", ".", "/", "$"]
private static let maxScoredCandidateLength = 1_024
private static let maxScoredQueryLength = 64
private static let invalid = Int.min / 4

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

// Record first match position
if firstMatchPosition < 0 {
firstMatchPosition = candidateIndex
}
static func match(query: String, candidate: String) -> FuzzyMatch? {
let queryChars = Array(query)
let candidateChars = Array(candidate)
guard !queryChars.isEmpty, !candidateChars.isEmpty, queryChars.count <= candidateChars.count else {
return nil
}
if candidateChars.count > maxScoredCandidateLength || queryChars.count > maxScoredQueryLength {
return greedyMatch(queryChars: queryChars, candidateChars: candidateChars)
}
return optimalMatch(queryChars: queryChars, candidateChars: candidateChars)
}

// Consecutive match bonus
consecutiveBonus += 1
if consecutiveBonus > 1 {
matchScore += consecutiveBonus * 4
}
private static func optimalMatch(queryChars: [Character], candidateChars: [Character]) -> FuzzyMatch? {
let queryLength = queryChars.count
let candidateLength = candidateChars.count
let foldedQuery = queryChars.map { $0.lowercased() }
let foldedCandidate = candidateChars.map { $0.lowercased() }
let bonuses = boundaryBonuses(for: candidateChars)

var matchScores = [Int](repeating: invalid, count: queryLength * candidateLength)
var bestScores = [Int](repeating: invalid, count: queryLength * candidateLength)

for queryIndex in 0..<queryLength {
var runningGapScore = invalid
for candidateIndex in 0..<candidateLength {
let cell = queryIndex * candidateLength + candidateIndex
var matchScore = invalid

if foldedQuery[queryIndex] == foldedCandidate[candidateIndex] {
let base: Int
if queryIndex == 0 {
base = leadingGapPenalty(for: candidateIndex) + bonuses[candidateIndex]
} else if candidateIndex > 0 {
let diagonal = cell - candidateLength - 1
let viaBoundary = bestScores[diagonal] + bonuses[candidateIndex]
let viaConsecutive = matchScores[diagonal] + Weight.consecutive
base = max(viaBoundary, viaConsecutive)
} else {
base = invalid
}
if isValid(base) {
let caseBonus = queryChars[queryIndex] == candidateChars[candidateIndex]
? Weight.exactCase
: 0
matchScore = base + Weight.match + caseBonus
}
}

// Word boundary bonus
if candidateIndex == 0 {
matchScore += 10
} else {
let prevChar = Character(candidateScalars[candidateIndex - 1])
if prevChar == " " || prevChar == "_" || prevChar == "." || prevChar == "-" {
matchScore += 8
consecutiveBonus = 1
} else if prevChar.isLowercase && candidateChar.isUppercase {
matchScore += 6
consecutiveBonus = 1
matchScores[cell] = matchScore

if candidateIndex > 0 {
let previousMatch = matchScores[cell - 1]
let opened = isValid(previousMatch) ? previousMatch + Weight.gapOpen : invalid
let extended = isValid(runningGapScore) ? runningGapScore + Weight.gapExtension : invalid
runningGapScore = max(opened, extended)
} else {
runningGapScore = invalid
}

bestScores[cell] = max(matchScore, runningGapScore)
}
}

// Exact case match bonus
if queryChar == candidateChar {
matchScore += 1
let finalScore = bestScores[queryLength * candidateLength - 1]
guard isValid(finalScore) else { return nil }

let indices = traceback(
queryChars: queryChars,
candidateChars: candidateChars,
matchScores: matchScores,
bestScores: bestScores
)
return FuzzyMatch(score: finalScore, matchedIndices: indices)
}

private static func traceback(
queryChars: [Character],
candidateChars: [Character],
matchScores: [Int],
bestScores: [Int]
) -> [Int] {
let queryLength = queryChars.count
let candidateLength = candidateChars.count
var indices = [Int](repeating: 0, count: queryLength)
var queryIndex = queryLength - 1
var candidateIndex = candidateLength - 1
var matchRequired = false

while queryIndex >= 0, candidateIndex >= 0 {
var cell = queryIndex * candidateLength + candidateIndex
while !matchRequired, candidateIndex > 0, matchScores[cell] != bestScores[cell] {
candidateIndex -= 1
cell -= 1
}
indices[queryIndex] = candidateIndex
if queryIndex > 0, candidateIndex > 0 {
let caseBonus = queryChars[queryIndex] == candidateChars[candidateIndex]
? Weight.exactCase
: 0
let diagonal = cell - candidateLength - 1
matchRequired = matchScores[cell] == matchScores[diagonal] + Weight.consecutive + Weight.match + caseBonus
}
queryIndex -= 1
candidateIndex -= 1
}
return indices
}

private static func greedyMatch(queryChars: [Character], candidateChars: [Character]) -> FuzzyMatch? {
let foldedQuery = queryChars.map { $0.lowercased() }
var score = 0
var indices: [Int] = []
indices.reserveCapacity(queryChars.count)
var queryIndex = 0
var lastMatchIndex = -2

for (candidateIndex, character) in candidateChars.enumerated() {
guard queryIndex < queryChars.count else { break }
guard character.lowercased() == foldedQuery[queryIndex] else { continue }

var matchScore = Weight.match + boundaryBonus(at: candidateIndex, in: candidateChars)
if candidateIndex == lastMatchIndex + 1 {
matchScore += Weight.consecutive
}
if queryChars[queryIndex] == character {
matchScore += Weight.exactCase
}
if indices.isEmpty {
score += leadingGapPenalty(for: candidateIndex)
} else {
let gap = candidateIndex - lastMatchIndex - 1
if gap > 0 {
score += Weight.gapOpen + Weight.gapExtension * (gap - 1)
}
}
score += matchScore
indices.append(candidateIndex)
lastMatchIndex = candidateIndex
queryIndex += 1
candidateIndex += 1
}

// All query characters must be matched
guard queryIndex == queryLen else { return 0 }
guard queryIndex == queryChars.count else { return nil }
return FuzzyMatch(score: score, matchedIndices: indices)
}

// Position bonus
if firstMatchPosition >= 0 {
let positionBonus = max(0, 20 - firstMatchPosition * 2)
score += positionBonus
private static func boundaryBonuses(for candidateChars: [Character]) -> [Int] {
candidateChars.indices.map { boundaryBonus(at: $0, in: candidateChars) }
}

private static func boundaryBonus(at index: Int, in candidateChars: [Character]) -> Int {
guard index > 0 else { return Weight.firstCharacter }
let previous = candidateChars[index - 1]
if separators.contains(previous) {
return Weight.separatorBoundary
}
if previous.isLowercase, candidateChars[index].isUppercase {
return Weight.camelBoundary
}
return 0
}

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

return score
private static func isValid(_ score: Int) -> Bool {
score > invalid / 2
}
}
Loading
Loading