Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,25 @@ 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: Cmd+1 to Cmd+4 narrow results to 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

- 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, scope chips, and rounded row selection. 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`.

### 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.
- 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)

### Security
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