Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
120 changes: 120 additions & 0 deletions TablePro/Core/Utilities/UI/FuzzyMatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//
// FuzzyMatcher.swift
// TablePro
//
// Standalone fuzzy matching utility for quick switcher search
//

import Foundation

/// Namespace for fuzzy string matching operations
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 queryNS = query as NSString
let candidateNS = candidate as NSString
let queryLen = queryNS.length
let candidateLen = candidateNS.length

if queryLen == 0 { return 1 }
if candidateLen == 0 { return 0 }

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

// Skip leading surrogate halves in query (emoji etc.)
while queryIndex < queryLen, UnicodeScalar(queryNS.character(at: queryIndex)) == nil {
queryIndex += 1
}

while candidateIndex < candidateLen, queryIndex < queryLen {
guard let queryScalar = UnicodeScalar(queryNS.character(at: queryIndex)) else {
queryIndex += 1
continue
}
guard let candidateScalar = UnicodeScalar(candidateNS.character(at: candidateIndex)) else {
candidateIndex += 1
consecutiveBonus = 0
continue
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

let queryChar = Character(queryScalar)
let candidateChar = Character(candidateScalar)

guard queryChar.lowercased() == candidateChar.lowercased() else {
candidateIndex += 1
consecutiveBonus = 0
continue
}

// Base match score
var matchScore = 1

// Record first match position for position bonus
if firstMatchPosition < 0 {
firstMatchPosition = candidateIndex
}

// Consecutive match bonus (grows quadratically with each consecutive hit)
consecutiveBonus += 1
if consecutiveBonus > 1 {
matchScore += consecutiveBonus * 4
}

// Word boundary bonus: after space, underscore, or camelCase transition
if candidateIndex == 0 {
matchScore += 10
} else {
guard let prevScalar = UnicodeScalar(candidateNS.character(at: candidateIndex - 1)) else {
score += matchScore
queryIndex += 1
candidateIndex += 1
continue
}
let prevChar = Character(prevScalar)
if prevChar == " " || prevChar == "_" || prevChar == "." || prevChar == "-" {
matchScore += 8
consecutiveBonus = 1
} else if prevChar.isLowercase && candidateChar.isUppercase {
// camelCase boundary
matchScore += 6
consecutiveBonus = 1
}
}

// Exact case match bonus
if queryChar == candidateChar {
matchScore += 1
}

score += matchScore
queryIndex += 1
candidateIndex += 1
}

// Skip trailing surrogate halves in query
while queryIndex < queryLen, UnicodeScalar(queryNS.character(at: queryIndex)) == nil {
queryIndex += 1
}

// All query characters must be matched, and at least one real match must exist
guard queryIndex == queryLen, score > 0 else { return 0 }

// Position bonus: earlier matches score higher
if firstMatchPosition >= 0 {
let positionBonus = max(0, 20 - firstMatchPosition * 2)
score += positionBonus
}

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

return score
}
}
51 changes: 51 additions & 0 deletions TablePro/Models/UI/QuickSwitcherItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// QuickSwitcherItem.swift
// TablePro
//
// Data model for quick switcher search results
//

import Foundation

/// The type of database object represented by a quick switcher item
enum QuickSwitcherItemKind: Hashable, Sendable {
case table
case view
case systemTable
case database
case schema
case queryHistory
}

/// A single item in the quick switcher results list
struct QuickSwitcherItem: Identifiable, Hashable {
let id: String
let name: String
let kind: QuickSwitcherItemKind
let subtitle: String
var score: Int = 0

/// SF Symbol name for this item's icon
var iconName: String {
switch kind {
case .table: return "tablecells"
case .view: return "eye"
case .systemTable: return "gearshape"
case .database: return "cylinder"
case .schema: return "folder"
case .queryHistory: return "clock.arrow.circlepath"
}
}

/// Localized display label for the item kind
var kindLabel: String {
switch kind {
case .table: return String(localized: "Table")
case .view: return String(localized: "View")
case .systemTable: return String(localized: "System Table")
case .database: return String(localized: "Database")
case .schema: return String(localized: "Schema")
case .queryHistory: return String(localized: "History")
}
}
}
205 changes: 205 additions & 0 deletions TablePro/ViewModels/QuickSwitcherViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//
// QuickSwitcherViewModel.swift
// TablePro
//
// ViewModel for the quick switcher palette
//

import Foundation
import Observation
import os

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

// MARK: - State

var searchText = "" {
didSet { updateFilter() }
}

var allItems: [QuickSwitcherItem] = [] {
didSet { applyFilter() }
}
private(set) var filteredItems: [QuickSwitcherItem] = []
var selectedItemId: String?
var isLoading = false

@ObservationIgnored private var filterTask: Task<Void, Never>?

/// Maximum number of results to display
private let maxResults = 100

// MARK: - Loading

/// Load all searchable items from the database schema, databases, schemas, and history
func loadItems(
schemaProvider: SQLSchemaProvider,
connectionId: UUID,
databaseType: DatabaseType
) async {
isLoading = true
var items: [QuickSwitcherItem] = []

// Tables, views, system tables from cached schema
let tables = await schemaProvider.getTables()
for table in tables {
let kind: QuickSwitcherItemKind
let subtitle: String
switch table.type {
case .table:
kind = .table
subtitle = ""
case .view:
kind = .view
subtitle = String(localized: "View")
case .systemTable:
kind = .systemTable
subtitle = String(localized: "System")
}
items.append(QuickSwitcherItem(
id: "table_\(table.name)_\(table.type.rawValue)",
name: table.name,
kind: kind,
subtitle: subtitle
))
}

// Databases
if let driver = DatabaseManager.shared.driver(for: connectionId) {
do {
let databases = try await driver.fetchDatabases()
for db in databases {
items.append(QuickSwitcherItem(
id: "db_\(db)",
name: db,
kind: .database,
subtitle: String(localized: "Database")
))
}
} catch {
Self.logger.debug("Failed to fetch databases for quick switcher: \(error.localizedDescription, privacy: .public)")
}

// Schemas (only for databases that support them)
let supportsSchemas = [DatabaseType.postgresql, .redshift, .oracle, .mssql]
if supportsSchemas.contains(databaseType) {
do {
let schemas = try await driver.fetchSchemas()
for schema in schemas {
items.append(QuickSwitcherItem(
id: "schema_\(schema)",
name: schema,
kind: .schema,
subtitle: String(localized: "Schema")
))
}
} catch {
Self.logger.debug("Failed to fetch schemas for quick switcher: \(error.localizedDescription, privacy: .public)")
}
}
}

// Recent query history (last 50)
let historyEntries = await QueryHistoryStorage.shared.fetchHistory(
limit: 50,
connectionId: connectionId
)
for entry in historyEntries {
items.append(QuickSwitcherItem(
id: "history_\(entry.id.uuidString)",
name: entry.queryPreview,
kind: .queryHistory,
subtitle: entry.databaseName
))
}

allItems = items
isLoading = false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// MARK: - Filtering

/// Debounced filter update
func updateFilter() {
filterTask?.cancel()
filterTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
guard !Task.isCancelled else { return }
applyFilter()
}
}

private func applyFilter() {
if searchText.isEmpty {
// Show all items grouped by kind: tables, views, system tables, databases, schemas, history
filteredItems = allItems.sorted { a, b in
kindSortOrder(a.kind) < kindSortOrder(b.kind)
}
if filteredItems.count > maxResults {
filteredItems = Array(filteredItems.prefix(maxResults))
}
} else {
filteredItems = allItems.compactMap { item in
let matchScore = FuzzyMatcher.score(query: searchText, candidate: item.name)
guard matchScore > 0 else { return nil as QuickSwitcherItem? }
var scored = item
scored.score = matchScore
return scored
}
.sorted { $0.score > $1.score }

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

selectedItemId = filteredItems.first?.id
}

private func kindSortOrder(_ kind: QuickSwitcherItemKind) -> Int {
switch kind {
case .table: return 0
case .view: return 1
case .systemTable: return 2
case .database: return 3
case .schema: return 4
case .queryHistory: return 5
}
}

// MARK: - Navigation

func moveUp() {
guard let currentId = selectedItemId,
let currentIndex = filteredItems.firstIndex(where: { $0.id == currentId }),
currentIndex > 0
else { return }
selectedItemId = filteredItems[currentIndex - 1].id
}

func moveDown() {
guard let currentId = selectedItemId,
let currentIndex = filteredItems.firstIndex(where: { $0.id == currentId }),
currentIndex < filteredItems.count - 1
else { return }
selectedItemId = filteredItems[currentIndex + 1].id
}

var selectedItem: QuickSwitcherItem? {
guard let selectedItemId else { return nil }
return filteredItems.first { $0.id == selectedItemId }
}

/// Items grouped by kind for sectioned display
var groupedItems: [(kind: QuickSwitcherItemKind, items: [QuickSwitcherItem])] {
var groups: [QuickSwitcherItemKind: [QuickSwitcherItem]] = [:]
for item in filteredItems {
groups[item.kind, default: []].append(item)
}
return groups.sorted { kindSortOrder($0.key) < kindSortOrder($1.key) }
.map { (kind: $0.key, items: $0.value) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,8 @@ extension MainContentCoordinator {
}

await loadSchema()

NotificationCenter.default.post(name: .refreshData, object: nil)
} else if connection.type == .postgresql {
DatabaseManager.shared.updateSession(connectionId) { session in
session.connection.database = database
Expand Down
Loading
Loading