diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d1425dfd..cd8b7a888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,19 @@ 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 - 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, 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`. - 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. @@ -23,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - 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) diff --git a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift index ceb9caf7c..0511ea818 100644 --- a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift +++ b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift @@ -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 = [" ", "_", "-", ".", "/", "$"] + 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.. 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 } } diff --git a/TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift b/TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift new file mode 100644 index 000000000..72b3587d8 --- /dev/null +++ b/TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift @@ -0,0 +1,107 @@ +// +// QuickSwitcherFrecencyStore.swift +// TablePro +// + +import Foundation + +internal struct QuickSwitcherFrecencyStore { + private struct RecencyBucket { + let maxAge: TimeInterval + let weight: Double + } + + private static let keyPrefix = "QuickSwitcher.frecency." + private static let legacyMRUKeyPrefix = "QuickSwitcher.mru." + private static let maxSamplesPerItem = 10 + private static let maxTrackedItems = 100 + private static let maxBucketWeight: Double = 100 + + private static let recencyBuckets: [RecencyBucket] = [ + RecencyBucket(maxAge: 4 * 86_400, weight: 100), + RecencyBucket(maxAge: 14 * 86_400, weight: 70), + RecencyBucket(maxAge: 31 * 86_400, weight: 50), + RecencyBucket(maxAge: 90 * 86_400, weight: 30) + ] + private static let olderThanBucketsWeight: Double = 10 + + private let defaults: UserDefaults + private let key: String + private let legacyKey: String + + init(connectionId: UUID, defaults: UserDefaults = .standard) { + self.defaults = defaults + self.key = Self.keyPrefix + connectionId.uuidString + self.legacyKey = Self.legacyMRUKeyPrefix + connectionId.uuidString + } + + func recordAccess(itemId: String, at date: Date = Date()) { + var accesses = loadAccesses() + var samples = accesses[itemId] ?? [] + samples.append(date.timeIntervalSince1970) + if samples.count > Self.maxSamplesPerItem { + samples.removeFirst(samples.count - Self.maxSamplesPerItem) + } + accesses[itemId] = samples + if accesses.count > Self.maxTrackedItems { + prune(&accesses) + } + defaults.set(accesses, forKey: key) + } + + func scores(now: Date = Date()) -> [String: Double] { + loadAccesses().mapValues { score(for: $0, now: now) } + } + + func recentItemIds(limit: Int) -> [String] { + loadAccesses() + .compactMap { itemId, samples in samples.max().map { (itemId, $0) } } + .sorted { $0.1 > $1.1 } + .prefix(limit) + .map(\.0) + } + + func clearHistory() { + defaults.removeObject(forKey: key) + defaults.removeObject(forKey: legacyKey) + } + + private func score(for samples: [TimeInterval], now: Date) -> Double { + let reference = now.timeIntervalSince1970 + let total = samples.reduce(0.0) { sum, sample in + let age = reference - sample + let weight = Self.recencyBuckets.first { age <= $0.maxAge }?.weight + ?? Self.olderThanBucketsWeight + return sum + weight + } + return min(1, total / (Double(Self.maxSamplesPerItem) * Self.maxBucketWeight)) + } + + private func loadAccesses() -> [String: [TimeInterval]] { + if let stored = defaults.dictionary(forKey: key) as? [String: [TimeInterval]] { + return stored + } + return migrateLegacyMRU() + } + + private func migrateLegacyMRU() -> [String: [TimeInterval]] { + guard let legacy = defaults.stringArray(forKey: legacyKey), !legacy.isEmpty else { + return [:] + } + let now = Date().timeIntervalSince1970 + var accesses: [String: [TimeInterval]] = [:] + for (index, itemId) in legacy.enumerated() { + accesses[itemId] = [now - TimeInterval(index * 60)] + } + defaults.set(accesses, forKey: key) + defaults.removeObject(forKey: legacyKey) + return accesses + } + + private func prune(_ accesses: inout [String: [TimeInterval]]) { + let kept = accesses + .sorted { ($0.value.max() ?? 0) > ($1.value.max() ?? 0) } + .prefix(Self.maxTrackedItems) + accesses = Dictionary(uniqueKeysWithValues: Array(kept)) + } +} diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 538c64255..dbfeab5fc 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -14,16 +14,54 @@ internal enum QuickSwitcherItemKind: String, Hashable, Sendable { case systemTable case database case schema + case savedQuery case queryHistory } +/// How a quick switcher selection should be opened +internal enum QuickSwitcherCommitIntent: Sendable { + case open + case openInNewWindowTab + case openStructure +} + +/// A search scope limiting which kinds of objects the quick switcher shows +internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { + case all + case tables + case containers + case queries + + var id: String { rawValue } + + var includedKinds: Set? { + switch self { + case .all: return nil + case .tables: return [.table, .view, .systemTable] + case .containers: return [.database, .schema] + case .queries: return [.savedQuery, .queryHistory] + } + } + + var title: String { + switch self { + case .all: return String(localized: "All") + case .tables: return String(localized: "Tables") + case .containers: return String(localized: "Databases") + case .queries: return String(localized: "Queries") + } + } +} + /// A single item in the quick switcher results list -internal struct QuickSwitcherItem: Identifiable, Hashable { +internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { let id: String let name: String let kind: QuickSwitcherItemKind let subtitle: String - var score: Int = 0 + var matchedIndices: [Int] = [] + var payload: String? + var isOpenInTab: Bool = false /// SF Symbol name for this item's icon var iconName: String { @@ -33,6 +71,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable { case .systemTable: return "gearshape" case .database: return "cylinder" case .schema: return "folder" + case .savedQuery: return "star" case .queryHistory: return "clock.arrow.circlepath" } } @@ -45,6 +84,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable { case .systemTable: return String(localized: "System Table") case .database: return String(localized: "Database") case .schema: return String(localized: "Schema") + case .savedQuery: return String(localized: "Saved Query") case .queryHistory: return String(localized: "History") } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 816a4e43f..4dea37de2 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7060,6 +7060,16 @@ } } }, + "An external link wants to connect to a %@ database:\n\n%@\n\nConnect only if you trust the source of this link." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An external link wants to connect to a %1$@ database:\n\n%2$@\n\nConnect only if you trust the source of this link." + } + } + } + }, "An external link wants to open a query on \"%@\":\n\n%@" : { "localizations" : { "en" : { @@ -29625,6 +29635,9 @@ } } } + }, + "Host: %@" : { + }, "Hostname" : { "localizations" : { @@ -41753,6 +41766,9 @@ } } } + }, + "Open External Database Connection?" : { + }, "Open File" : { "localizations" : { @@ -62675,6 +62691,7 @@ } }, "Token '%@' with permission '%@' cannot access '%@'" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -65025,6 +65042,9 @@ } } } + }, + "User: %@" : { + }, "username" : { "localizations" : { diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 663d7bd52..c4f71deb8 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -29,12 +29,18 @@ final class DatabaseSwitcherViewModel { @ObservationIgnored private let services: AppServices var filteredDatabases: [DatabaseMetadata] { - if searchText.isEmpty { - return databases - } - return databases.filter { - $0.name.localizedCaseInsensitiveContains(searchText) - } + let trimmed = searchText.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return databases } + return databases + .compactMap { database -> (DatabaseMetadata, Int)? in + guard let match = FuzzyMatcher.match(query: trimmed, candidate: database.name) else { return nil } + return (database, match.score) + } + .sorted { lhs, rhs in + if lhs.1 != rhs.1 { return lhs.1 > rhs.1 } + return lhs.0.name.localizedStandardCompare(rhs.0.name) == .orderedAscending + } + .map(\.0) } init( diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 696387acc..5dc2abdce 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -7,27 +7,32 @@ import Foundation import Observation import os +private enum QuickSwitcherRanking { + static let maxResults = 200 + static let subtitleMatchPenalty = 0.6 + static let frecencyBoost = 0.5 + static let openTabBoost = 1.2 +} + @MainActor @Observable internal final class QuickSwitcherViewModel { - struct Group: Identifiable { + struct Group: Identifiable, Sendable { let id: String let header: String? let items: [QuickSwitcherItem] } private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel") - private static let mruDefaultsKeyPrefix = "QuickSwitcher.mru." - private static let mruLimit = 10 - private static let maxResults = 200 + private static let recentLimit = 10 private static let filterDebounceNanoseconds: UInt64 = 40_000_000 @ObservationIgnored private let services: AppServices - @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private let connectionId: UUID + @ObservationIgnored private let frecencyStore: QuickSwitcherFrecencyStore @ObservationIgnored internal var allItems: [QuickSwitcherItem] = [] { - didSet { applyFilter() } + didSet { scheduleFilter(debounced: false) } } @ObservationIgnored private var filterTask: Task? @ObservationIgnored private var activeLoadId = UUID() @@ -39,7 +44,14 @@ internal final class QuickSwitcherViewModel { var searchText = "" { didSet { guard oldValue != searchText else { return } - scheduleFilter() + scheduleFilter(debounced: true) + } + } + + var scope: QuickSwitcherScope = .all { + didSet { + guard oldValue != scope else { return } + scheduleFilter(debounced: false) } } @@ -57,7 +69,7 @@ internal final class QuickSwitcherViewModel { init(connectionId: UUID, services: AppServices, defaults: UserDefaults = .standard) { self.connectionId = connectionId self.services = services - self.defaults = defaults + self.frecencyStore = QuickSwitcherFrecencyStore(connectionId: connectionId, defaults: defaults) } convenience init(connectionId: UUID = UUID()) { @@ -66,7 +78,8 @@ internal final class QuickSwitcherViewModel { func loadItems( schemaProvider: SQLSchemaProvider, - databaseType: DatabaseType + databaseType: DatabaseType, + openTableNames: Set = [] ) async { isLoading = true @@ -75,6 +88,11 @@ internal final class QuickSwitcherViewModel { var items: [QuickSwitcherItem] = [] + if await !schemaProvider.isSchemaLoaded(), + let driver = services.databaseManager.driver(for: connectionId) { + await schemaProvider.loadSchema(using: driver) + } + let tables = await schemaProvider.getTables() for table in tables { let kind: QuickSwitcherItemKind @@ -100,7 +118,8 @@ internal final class QuickSwitcherViewModel { id: "table_\(table.name)_\(table.type.rawValue)", name: table.name, kind: kind, - subtitle: subtitle + subtitle: subtitle, + isOpenInTab: openTableNames.contains(table.name) )) } @@ -145,6 +164,17 @@ internal final class QuickSwitcherViewModel { } } + let favorites = await services.sqlFavoriteManager.fetchFavorites(connectionId: connectionId) + for favorite in favorites { + items.append(QuickSwitcherItem( + id: "favorite_\(favorite.id.uuidString)", + name: favorite.name, + kind: .savedQuery, + subtitle: favorite.keyword ?? "", + payload: favorite.query + )) + } + let historyEntries = await services.queryHistoryManager.fetchHistory( limit: 50, connectionId: connectionId @@ -154,7 +184,8 @@ internal final class QuickSwitcherViewModel { id: "history_\(entry.id.uuidString)", name: entry.queryPreview, kind: .queryHistory, - subtitle: entry.databaseName + subtitle: entry.databaseName, + payload: entry.query )) } @@ -183,30 +214,34 @@ internal final class QuickSwitcherViewModel { } } - func recordSelection(_ item: QuickSwitcherItem) { - var mru = loadMRU() - mru.removeAll { $0 == item.id } - mru.insert(item.id, at: 0) - if mru.count > Self.mruLimit { - mru = Array(mru.prefix(Self.mruLimit)) - } - defaults.set(mru, forKey: mruKey) + func recordSelection(_ item: QuickSwitcherItem, at date: Date = Date()) { + frecencyStore.recordAccess(itemId: item.id, at: date) } - private func scheduleFilter() { + private func scheduleFilter(debounced: Bool) { filterTask?.cancel() + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { + filterTask = nil + groups = buildEmptyQueryGroups() + reconcileSelection() + return + } + let items = scopedItems() + let frecencyScores = frecencyStore.scores() filterTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: Self.filterDebounceNanoseconds) - guard !Task.isCancelled else { return } - self?.applyFilter() + if debounced { + try? await Task.sleep(nanoseconds: Self.filterDebounceNanoseconds) + guard !Task.isCancelled else { return } + } + let groups = await Self.filteredGroups(items: items, query: query, frecencyScores: frecencyScores) + guard !Task.isCancelled, let self else { return } + self.groups = groups + self.reconcileSelection() } } - private func applyFilter() { - let trimmed = searchText.trimmingCharacters(in: .whitespaces) - groups = trimmed.isEmpty - ? buildEmptyQueryGroups() - : buildFilteredGroups(for: trimmed) + private func reconcileSelection() { let items = flatItems if let current = selectedItemId, items.contains(where: { $0.id == current }) { return @@ -214,66 +249,99 @@ internal final class QuickSwitcherViewModel { selectedItemId = items.first?.id } + private func scopedItems() -> [QuickSwitcherItem] { + guard let includedKinds = scope.includedKinds else { return allItems } + return allItems.filter { includedKinds.contains($0.kind) } + } + private func buildEmptyQueryGroups() -> [Group] { - let mruList = loadMRU() - let mruIds = Set(mruList) - let mruOrder = Dictionary(uniqueKeysWithValues: mruList.enumerated().map { ($1, $0) }) + let scoped = scopedItems() + let recentIds = frecencyStore.recentItemIds(limit: Self.recentLimit) + let recentIdSet = Set(recentIds) + let recentOrder = Dictionary(uniqueKeysWithValues: recentIds.enumerated().map { ($1, $0) }) var result: [Group] = [] - let recent = allItems - .filter { mruIds.contains($0.id) } - .sorted { (mruOrder[$0.id] ?? 0) < (mruOrder[$1.id] ?? 0) } + let recent = scoped + .filter { recentIdSet.contains($0.id) } + .sorted { (recentOrder[$0.id] ?? 0) < (recentOrder[$1.id] ?? 0) } if !recent.isEmpty { result.append(Group(id: "recent", header: String(localized: "Recent"), items: recent)) } for kind in QuickSwitcherItemKind.displayOrder { - let items = allItems - .filter { $0.kind == kind && !mruIds.contains($0.id) } + let items = scoped + .filter { $0.kind == kind && !recentIdSet.contains($0.id) } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } guard !items.isEmpty else { continue } result.append(Group( id: "kind-\(kind.rawValue)", header: kind.sectionTitle, - items: Array(items.prefix(Self.maxResults)) + items: Array(items.prefix(QuickSwitcherRanking.maxResults)) )) } return result } - private func buildFilteredGroups(for query: String) -> [Group] { - var scored = allItems.compactMap { item -> (QuickSwitcherItem, Int)? in - let score = FuzzyMatcher.score(query: query, candidate: item.name) - guard score > 0 else { return nil } - return (item, score) + nonisolated private static func filteredGroups( + items: [QuickSwitcherItem], + query: String, + frecencyScores: [String: Double] + ) async -> [Group] { + var ranked = items.compactMap { item -> (item: QuickSwitcherItem, rank: Double)? in + guard let (matchScore, matchedIndices) = bestMatch(for: item, query: query) else { return nil } + var matched = item + matched.matchedIndices = matchedIndices + let frecency = 1 + (frecencyScores[item.id] ?? 0) * QuickSwitcherRanking.frecencyBoost + let openBoost = item.isOpenInTab ? QuickSwitcherRanking.openTabBoost : 1 + return (matched, matchScore * item.kind.rankWeight * frecency * openBoost) } - scored.sort { lhs, rhs in - if lhs.1 != rhs.1 { return lhs.1 > rhs.1 } - let lOrder = QuickSwitcherItemKind.displayOrder.firstIndex(of: lhs.0.kind) ?? Int.max - let rOrder = QuickSwitcherItemKind.displayOrder.firstIndex(of: rhs.0.kind) ?? Int.max - if lOrder != rOrder { return lOrder < rOrder } - return lhs.0.name.localizedStandardCompare(rhs.0.name) == .orderedAscending + ranked.sort { lhs, rhs in + if lhs.rank != rhs.rank { return lhs.rank > rhs.rank } + let lhsOrder = QuickSwitcherItemKind.displayOrder.firstIndex(of: lhs.item.kind) ?? Int.max + let rhsOrder = QuickSwitcherItemKind.displayOrder.firstIndex(of: rhs.item.kind) ?? Int.max + if lhsOrder != rhsOrder { return lhsOrder < rhsOrder } + let lhsLength = (lhs.item.name as NSString).length + let rhsLength = (rhs.item.name as NSString).length + if lhsLength != rhsLength { return lhsLength < rhsLength } + return lhs.item.name.localizedStandardCompare(rhs.item.name) == .orderedAscending } - let items = Array(scored.prefix(Self.maxResults).map(\.0)) + let items = Array(ranked.prefix(QuickSwitcherRanking.maxResults).map(\.item)) guard !items.isEmpty else { return [] } return [Group(id: "results", header: nil, items: items)] } - private var mruKey: String { - Self.mruDefaultsKeyPrefix + connectionId.uuidString - } - - private func loadMRU() -> [String] { - defaults.stringArray(forKey: mruKey) ?? [] + nonisolated private static func bestMatch( + for item: QuickSwitcherItem, + query: String + ) -> (score: Double, matchedIndices: [Int])? { + if let nameMatch = FuzzyMatcher.match(query: query, candidate: item.name) { + return (Double(nameMatch.score), nameMatch.matchedIndices) + } + guard !item.subtitle.isEmpty, + let subtitleMatch = FuzzyMatcher.match(query: query, candidate: item.subtitle) + else { return nil } + return (Double(subtitleMatch.score) * QuickSwitcherRanking.subtitleMatchPenalty, []) } } private extension QuickSwitcherItemKind { static let displayOrder: [QuickSwitcherItemKind] = [ - .table, .view, .systemTable, .database, .schema, .queryHistory + .table, .view, .systemTable, .database, .schema, .savedQuery, .queryHistory ] + var rankWeight: Double { + switch self { + case .table: return 1.0 + case .view: return 0.98 + case .systemTable: return 0.85 + case .database: return 0.95 + case .schema: return 0.93 + case .savedQuery: return 0.9 + case .queryHistory: return 0.7 + } + } + var sectionTitle: String { switch self { case .table: return String(localized: "Tables") @@ -281,6 +349,7 @@ private extension QuickSwitcherItemKind { case .systemTable: return String(localized: "System Tables") case .database: return String(localized: "Databases") case .schema: return String(localized: "Schemas") + case .savedQuery: return String(localized: "Saved Queries") case .queryHistory: return String(localized: "Recent Queries") } } diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 5c2e21e5e..01609ac84 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -340,7 +340,7 @@ final class SidebarViewModel { if query.isEmpty { result = tables } else { - result = tables.filter { $0.name.localizedCaseInsensitiveContains(query) } + result = tables.filter { FuzzyMatcher.matches(query: query, candidate: $0.name) } } cachedFilteredTables = result cachedFilterInputs = fingerprint @@ -399,12 +399,12 @@ final class SidebarViewModel { private func applyQuery(_ query: String, to tables: [TableInfo]) -> [TableInfo] { guard !query.isEmpty else { return tables } - return tables.filter { $0.name.localizedCaseInsensitiveContains(query) } + return tables.filter { FuzzyMatcher.matches(query: query, candidate: $0.name) } } private func applyRoutineQuery(_ query: String, to routines: [RoutineInfo]) -> [RoutineInfo] { guard !query.isEmpty else { return routines } - return routines.filter { $0.name.localizedCaseInsensitiveContains(query) } + return routines.filter { FuzzyMatcher.matches(query: query, candidate: $0.name) } } private func rebuildKindBuckets(from tables: [TableInfo]) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 609ec17a2..0f0458d7c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -37,7 +37,8 @@ extension MainContentCoordinator { showStructure: Bool = false, isView: Bool = false, forceNonPreview: Bool = false, - activateGridFocus: Bool = false + activateGridFocus: Bool = false, + forceNewWindowTab: Bool = false ) { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId @@ -54,9 +55,10 @@ extension MainContentCoordinator { } let resolvedSchema = schema - let createAsPreview = !forceNonPreview && AppSettingsManager.shared.tabs.enablePreviewTabs + let createAsPreview = !forceNonPreview && !forceNewWindowTab + && AppSettingsManager.shared.tabs.enablePreviewTabs - if activateIfAlreadyOpen( + if !forceNewWindowTab, activateIfAlreadyOpen( tableName: tableName, databaseName: currentDatabase, schemaName: resolvedSchema, @@ -136,7 +138,7 @@ extension MainContentCoordinator { return } - if isActiveTabReusable { + if isActiveTabReusable, !forceNewWindowTab { reuseActiveTab( for: tableName, currentDatabase: currentDatabase, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index ed6a6678a..a1959c098 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -5,20 +5,49 @@ // Quick switcher navigation handler for MainContentCoordinator // +import AppKit import Foundation extension MainContentCoordinator { func showQuickSwitcher() { - activeSheet = .quickSwitcher + guard !quickSwitcherPanel.isPresented else { + quickSwitcherPanel.dismiss() + return + } + let openTableNames = Set( + tabManager.tabs + .filter { $0.tabType == .table } + .compactMap(\.tableContext.tableName) + ) + let panelView = QuickSwitcherPanelView( + schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connectionId), + connectionId: connectionId, + databaseType: connection.type, + openTableNames: openTableNames, + onSelect: { [weak self] item, intent in self?.handleQuickSwitcherSelection(item, intent: intent) }, + onDismiss: { [weak self] in self?.quickSwitcherPanel.dismiss() } + ) + quickSwitcherPanel.present(panelView, over: contentWindow) } - func handleQuickSwitcherSelection(_ item: QuickSwitcherItem) { + func handleQuickSwitcherSelection(_ item: QuickSwitcherItem, intent: QuickSwitcherCommitIntent = .open) { switch item.kind { case .table, .systemTable: - openTableTab(item.name, activateGridFocus: true) + openTableTab( + item.name, + showStructure: intent == .openStructure, + activateGridFocus: true, + forceNewWindowTab: intent == .openInNewWindowTab + ) case .view: - openTableTab(item.name, isView: true, activateGridFocus: true) + openTableTab( + item.name, + showStructure: intent == .openStructure, + isView: true, + activateGridFocus: true, + forceNewWindowTab: intent == .openInNewWindowTab + ) case .database: Task { @@ -30,8 +59,11 @@ extension MainContentCoordinator { await switchSchema(to: item.name) } + case .savedQuery: + loadQueryIntoEditor(item.payload ?? item.name) + case .queryHistory: - loadQueryIntoEditor(item.name) + loadQueryIntoEditor(item.payload ?? item.name) } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index d18e00e7b..cf522105d 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -42,7 +42,6 @@ struct DisplayFormatsCacheEntry { /// Represents which sheet is currently active in MainContentView. /// Uses a single `.sheet(item:)` modifier instead of multiple `.sheet(isPresented:)`. enum ActiveSheet: Identifiable { - case quickSwitcher case sqlPreview case exportDialog case importDialog(formatId: String) @@ -55,7 +54,6 @@ enum ActiveSheet: Identifiable { var id: String { switch self { - case .quickSwitcher: "quickSwitcher" case .sqlPreview: "sqlPreview" case .exportDialog: "exportDialog" case .importDialog(let formatId): "importDialog-\(formatId)" @@ -160,6 +158,9 @@ final class MainContentCoordinator { /// lookup when `@FocusedValue(\.commandActions)` has not resolved (e.g. focus in an AppKit subview). @ObservationIgnored weak var commandActions: MainContentCommandActions? + /// Presents the quick switcher as a floating panel anchored over this coordinator's window. + @ObservationIgnored let quickSwitcherPanel = QuickSwitcherPanelController() + // MARK: - Published State var cursorPositions: [CursorPosition] = [] diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index d3bc34b63..2cb67ada7 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -278,14 +278,6 @@ struct MainContentView: View { databaseType: connection.type, onExecute: coordinator.executeMaintenance ) - case .quickSwitcher: - QuickSwitcherSheet( - isPresented: dismissBinding, - schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connection.id), - connectionId: connection.id, - databaseType: connection.type, - onSelect: coordinator.handleQuickSwitcherSelection - ) case .sqlPreview: SQLReviewSheet( isPresented: dismissBinding, diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift new file mode 100644 index 000000000..5d99d72a2 --- /dev/null +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -0,0 +1,124 @@ +// +// QuickSwitcherPanel.swift +// TablePro +// + +import AppKit +import SwiftUI + +internal final class QuickSwitcherPanel: NSPanel { + var onCancel: (() -> Void)? + + init(contentView: NSView) { + super.init( + contentRect: NSRect(origin: .zero, size: contentView.fittingSize), + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + isFloatingPanel = true + level = .floating + collectionBehavior.insert(.fullScreenAuxiliary) + isOpaque = false + backgroundColor = .clear + hasShadow = true + isMovableByWindowBackground = false + animationBehavior = .utilityWindow + self.contentView = contentView + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + override func cancelOperation(_ sender: Any?) { + onCancel?() + } +} + +@MainActor +internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { + private struct Anchor { + let centerX: CGFloat + let top: CGFloat + } + + private static let topOffsetRatio: CGFloat = 0.20 + + private var panel: QuickSwitcherPanel? + private var anchor: Anchor? + + var isPresented: Bool { panel != nil } + + func present(_ content: some View, over parentWindow: NSWindow?) { + dismiss() + + let hostingView = NSHostingView(rootView: content) + hostingView.sizingOptions = .preferredContentSize + + let panel = QuickSwitcherPanel(contentView: hostingView) + panel.delegate = self + panel.onCancel = { [weak self] in self?.dismiss() } + self.panel = panel + + let reference = parentWindow?.frame + ?? NSScreen.main?.visibleFrame + ?? NSRect(x: 0, y: 0, width: 1_280, height: 800) + anchor = Anchor( + centerX: reference.midX, + top: reference.maxY - reference.height * Self.topOffsetRatio + ) + applyAnchor(to: panel) + panel.makeKeyAndOrderFront(nil) + } + + func dismiss() { + guard let panel else { return } + panel.delegate = nil + panel.onCancel = nil + self.panel = nil + anchor = nil + panel.orderOut(nil) + } + + func windowDidResignKey(_ notification: Notification) { + dismiss() + } + + func windowDidResize(_ notification: Notification) { + guard let panel else { return } + applyAnchor(to: panel) + panel.invalidateShadow() + } + + private func applyAnchor(to panel: QuickSwitcherPanel) { + guard let anchor else { return } + let size = panel.frame.size + panel.setFrameOrigin(NSPoint( + x: anchor.centerX - size.width / 2, + y: anchor.top - size.height + )) + } +} + +internal struct QuickSwitcherPanelBackground: NSViewRepresentable { + let cornerRadius: CGFloat + + func makeNSView(context: Context) -> NSView { + if #available(macOS 26.0, *) { + let glassView = NSGlassEffectView() + glassView.cornerRadius = cornerRadius + return glassView + } + let effectView = NSVisualEffectView() + effectView.material = .popover + effectView.blendingMode = .behindWindow + effectView.state = .active + effectView.wantsLayer = true + effectView.layer?.cornerRadius = cornerRadius + effectView.layer?.cornerCurve = .continuous + effectView.layer?.masksToBounds = true + return effectView + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift new file mode 100644 index 000000000..e21624b34 --- /dev/null +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -0,0 +1,343 @@ +// +// QuickSwitcherPanelView.swift +// TablePro +// + +import AppKit +import SwiftUI + +private enum PanelMetrics { + static let width: CGFloat = 640 + static let inputRowHeight: CGFloat = 52 + static let rowHeight: CGFloat = 30 + static let sectionHeaderHeight: CGFloat = 34 + static let chipRowHeight: CGFloat = 40 + static let listVerticalPadding: CGFloat = 6 + static let maxVisibleRows = 12 + + static var cornerRadius: CGFloat { + if #available(macOS 26.0, *) { + return 28 + } + return 13 + } +} + +struct QuickSwitcherPanelView: View { + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + let schemaProvider: SQLSchemaProvider + let connectionId: UUID + let databaseType: DatabaseType + let openTableNames: Set + let onSelect: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void + let onDismiss: () -> Void + + @State private var viewModel: QuickSwitcherViewModel + @State private var keyMonitor: Any? + + init( + schemaProvider: SQLSchemaProvider, + connectionId: UUID, + databaseType: DatabaseType, + openTableNames: Set = [], + onSelect: @escaping (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void, + onDismiss: @escaping () -> Void + ) { + self.schemaProvider = schemaProvider + self.connectionId = connectionId + self.databaseType = databaseType + self.openTableNames = openTableNames + self.onSelect = onSelect + self.onDismiss = onDismiss + self._viewModel = State(wrappedValue: QuickSwitcherViewModel(connectionId: connectionId)) + } + + var body: some View { + VStack(spacing: 0) { + inputRow + + if showsContent { + Divider() + scopeChips + if viewModel.flatItems.isEmpty { + noResultsRow + } else { + resultsList + } + } + } + .frame(width: PanelMetrics.width) + .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.cornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: PanelMetrics.cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: PanelMetrics.cornerRadius, style: .continuous) + .strokeBorder( + colorSchemeContrast == .increased ? Color(nsColor: .separatorColor) : .clear, + lineWidth: 1 + ) + ) + .task { + await viewModel.loadItems( + schemaProvider: schemaProvider, + databaseType: databaseType, + openTableNames: openTableNames + ) + } + .onAppear { installKeyMonitor() } + .onDisappear { removeKeyMonitor() } + } + + private var showsContent: Bool { + guard !viewModel.isLoading else { return false } + if viewModel.flatItems.isEmpty { + return !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty + } + return true + } + + private var inputRow: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.secondary) + + QuickSwitcherSearchField( + text: $viewModel.searchText, + placeholder: String(localized: "Search tables, views, databases, queries..."), + onMoveUp: { viewModel.moveSelection(by: -1) }, + onMoveDown: { viewModel.moveSelection(by: 1) }, + onSubmit: { openSelectedItem() } + ) + } + .padding(.horizontal, 16) + .frame(height: PanelMetrics.inputRowHeight) + } + + private var scopeChips: some View { + HStack(spacing: 8) { + ForEach(QuickSwitcherScope.allCases) { scope in + scopeChip(scope) + } + } + .padding(.horizontal, 14) + .frame(height: PanelMetrics.chipRowHeight) + } + + private func scopeChip(_ scope: QuickSwitcherScope) -> some View { + let isSelected = viewModel.scope == scope + return Button { + viewModel.scope = scope + } label: { + Text(scope.title) + .font(.system(size: 12, weight: isSelected ? .medium : .regular)) + .foregroundStyle(isSelected ? Color.primary : Color.secondary) + .frame(maxWidth: .infinity) + .frame(height: 24) + .background( + Capsule().fill(isSelected ? Color(nsColor: .quaternarySystemFill) : Color.clear) + ) + .overlay( + Capsule().strokeBorder( + Color(nsColor: .separatorColor), + lineWidth: isSelected ? 0 : 0.5 + ) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var resultsList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewModel.groups) { group in + if let header = group.header { + sectionHeader(header) + } + ForEach(group.items) { item in + itemRow(item) + } + } + } + .padding(.vertical, PanelMetrics.listVerticalPadding) + } + .frame(height: listHeight) + .onChange(of: viewModel.selectedItemId) { _, newValue in + if let id = newValue { + proxy.scrollTo(id) + } + } + } + } + + private var listHeight: CGFloat { + viewModel.listHeight( + rowHeight: PanelMetrics.rowHeight, + headerHeight: PanelMetrics.sectionHeaderHeight, + maxVisibleRows: PanelMetrics.maxVisibleRows + ) + PanelMetrics.listVerticalPadding * 2 + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 20) + .padding(.top, 18) + .padding(.bottom, 4) + } + + private func itemRow(_ item: QuickSwitcherItem) -> some View { + HStack(spacing: 10) { + Image(systemName: item.iconName) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text(highlightedName(for: item)) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 8) + + if item.isOpenInTab { + Text(String(localized: "Open")) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + } + + if !item.subtitle.isEmpty { + Text(item.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 14) + .frame(height: PanelMetrics.rowHeight) + .background { + if item.id == viewModel.selectedItemId { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .unemphasizedSelectedContentBackgroundColor)) + .padding(.horizontal, 6) + } + } + .contentShape(Rectangle()) + .onTapGesture { + viewModel.selectedItemId = item.id + commit(item, intent: .open) + } + .contextMenu { contextMenuActions(for: item) } + .id(item.id) + } + + private var noResultsRow: some View { + Text(String(format: String(localized: "No results for \"%@\""), viewModel.searchText)) + .font(.body) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .frame(height: PanelMetrics.rowHeight + PanelMetrics.listVerticalPadding * 2) + } + + @ViewBuilder + private func contextMenuActions(for item: QuickSwitcherItem) -> some View { + Button(String(localized: "Open")) { + viewModel.selectedItemId = item.id + commit(item, intent: .open) + } + if item.kind == .table || item.kind == .view || item.kind == .systemTable { + Button(String(localized: "Open in New Tab")) { + viewModel.selectedItemId = item.id + commit(item, intent: .openInNewWindowTab) + } + Button(String(localized: "Open Structure")) { + viewModel.selectedItemId = item.id + commit(item, intent: .openStructure) + } + } + Divider() + Button(String(localized: "Copy Name")) { + copyToPasteboard(item.name) + } + if item.kind == .savedQuery || item.kind == .queryHistory { + Button(String(localized: "Copy Query")) { + copyToPasteboard(item.payload ?? item.name) + } + } + } + + private func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard event.window is QuickSwitcherPanel else { return event } + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let characters = event.charactersIgnoringModifiers ?? "" + + if modifiers == .command, + let digit = Int(characters), + digit >= 1, digit <= QuickSwitcherScope.allCases.count { + viewModel.scope = QuickSwitcherScope.allCases[digit - 1] + return nil + } + if modifiers == .control { + switch characters { + case "j", "n": + viewModel.moveSelection(by: 1) + return nil + case "k", "p": + viewModel.moveSelection(by: -1) + return nil + default: + break + } + } + return event + } + } + + private func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + } + keyMonitor = nil + } + + private func copyToPasteboard(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + + private func highlightedName(for item: QuickSwitcherItem) -> AttributedString { + var attributed = AttributedString(item.name) + guard !item.matchedIndices.isEmpty else { return attributed } + let characterIndices = Array(attributed.characters.indices) + for index in item.matchedIndices where index < characterIndices.count { + let start = characterIndices[index] + let end = attributed.characters.index(after: start) + attributed[start.. Void + var onMoveDown: () -> Void + var onSubmit: () -> Void + + func makeNSView(context: Context) -> QuickSwitcherTextField { + let field = QuickSwitcherTextField() + field.isBordered = false + field.isBezeled = false + field.drawsBackground = false + field.focusRingType = .none + field.font = .systemFont(ofSize: 22) + field.placeholderString = placeholder + field.maximumNumberOfLines = 1 + field.lineBreakMode = .byTruncatingTail + field.cell?.isScrollable = true + field.cell?.wraps = false + field.delegate = context.coordinator + field.setContentHuggingPriority(.defaultLow, for: .horizontal) + return field + } + + func updateNSView(_ nsView: QuickSwitcherTextField, context: Context) { + if nsView.stringValue != text { + nsView.stringValue = text + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + @MainActor + final class Coordinator: NSObject, NSTextFieldDelegate { + private let parent: QuickSwitcherSearchField + + init(_ parent: QuickSwitcherSearchField) { + self.parent = parent + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + switch selector { + case #selector(NSResponder.moveUp(_:)): + parent.onMoveUp() + return true + case #selector(NSResponder.moveDown(_:)): + parent.onMoveDown() + return true + case #selector(NSResponder.insertNewline(_:)): + parent.onSubmit() + return true + case #selector(NSResponder.cancelOperation(_:)): + guard !control.stringValue.isEmpty else { return false } + control.stringValue = "" + parent.text = "" + return true + default: + return false + } + } + } +} + +internal final class QuickSwitcherTextField: NSTextField { + private var becomeKeyObserver: NSObjectProtocol? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { return } + if window.isKeyWindow { + window.makeFirstResponder(self) + return + } + becomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.window?.makeFirstResponder(self) + if let observer = self.becomeKeyObserver { + NotificationCenter.default.removeObserver(observer) + self.becomeKeyObserver = nil + } + } + } + } + + deinit { + if let becomeKeyObserver { + NotificationCenter.default.removeObserver(becomeKeyObserver) + } + } +} diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift deleted file mode 100644 index 7efbfdca5..000000000 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift +++ /dev/null @@ -1,228 +0,0 @@ -// -// QuickSwitcherSheet.swift -// TablePro -// - -import SwiftUI - -struct QuickSwitcherSheet: View { - @Binding var isPresented: Bool - @Environment(\.dismiss) private var dismiss - - let schemaProvider: SQLSchemaProvider - let connectionId: UUID - let databaseType: DatabaseType - let onSelect: (QuickSwitcherItem) -> Void - - private let sheetWidth: CGFloat = 460 - private let rowHeight: CGFloat = 30 - private let sectionHeaderHeight: CGFloat = 28 - private let maxVisibleRows = 9 - - @State private var viewModel: QuickSwitcherViewModel - - init( - isPresented: Binding, - schemaProvider: SQLSchemaProvider, - connectionId: UUID, - databaseType: DatabaseType, - onSelect: @escaping (QuickSwitcherItem) -> Void - ) { - self._isPresented = isPresented - self.schemaProvider = schemaProvider - self.connectionId = connectionId - self.databaseType = databaseType - self.onSelect = onSelect - self._viewModel = State(wrappedValue: QuickSwitcherViewModel(connectionId: connectionId)) - } - - var body: some View { - VStack(spacing: 0) { - toolbar - - Divider() - - if viewModel.isLoading { - loadingView - } else if viewModel.flatItems.isEmpty { - emptyState - } else { - itemList - .frame(height: viewModel.listHeight( - rowHeight: rowHeight, - headerHeight: sectionHeaderHeight, - maxVisibleRows: maxVisibleRows - )) - } - - Divider() - - footer - } - .frame(width: sheetWidth) - .navigationTitle(String(localized: "Quick Switcher")) - .background(Color(nsColor: .windowBackgroundColor)) - .task { - await viewModel.loadItems( - schemaProvider: schemaProvider, - databaseType: databaseType - ) - } - .onExitCommand { dismiss() } - .onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in - guard keyPress.modifiers.contains(.control) else { return .ignored } - viewModel.moveSelection(by: 1) - return .handled - } - .onKeyPress(characters: .init(charactersIn: "kp"), phases: [.down, .repeat]) { keyPress in - guard keyPress.modifiers.contains(.control) else { return .ignored } - viewModel.moveSelection(by: -1) - return .handled - } - } - - private var toolbar: some View { - NativeSearchField( - text: $viewModel.searchText, - placeholder: String(localized: "Search tables, views, databases..."), - onMoveUp: { viewModel.moveSelection(by: -1) }, - onMoveDown: { viewModel.moveSelection(by: 1) }, - focusOnAppear: true - ) - .padding(.horizontal, 12) - .padding(.vertical, 10) - } - - private var itemList: some View { - ScrollViewReader { proxy in - List(selection: $viewModel.selectedItemId) { - ForEach(viewModel.groups) { group in - if let header = group.header { - Section { - ForEach(group.items) { item in - itemRow(item) - } - } header: { - Text(header) - } - } else { - ForEach(group.items) { item in - itemRow(item) - } - } - } - } - .listStyle(.inset) - .scrollContentBackground(.hidden) - .contextMenu(forSelectionType: String.self) { _ in - EmptyView() - } primaryAction: { selection in - guard let id = selection.first, - let item = viewModel.flatItems.first(where: { $0.id == id }) - else { return } - viewModel.selectedItemId = id - commit(item) - } - .onChange(of: viewModel.selectedItemId) { _, newValue in - if let id = newValue { - withAnimation(.easeInOut(duration: 0.15)) { - proxy.scrollTo(id, anchor: .center) - } - } - } - } - } - - private func itemRow(_ item: QuickSwitcherItem) -> some View { - HStack(spacing: 10) { - Image(systemName: item.iconName) - .font(.body) - .foregroundStyle(.secondary) - .frame(width: 18) - - Text(item.name) - .font(.body) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - if !item.subtitle.isEmpty { - Text(item.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .frame(height: rowHeight) - .contentShape(Rectangle()) - .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) - .listRowSeparator(.hidden) - .id(item.id) - .tag(item.id) - } - - private var loadingView: some View { - VStack(spacing: 12) { - ProgressView() - .scaleEffect(0.8) - Text(String(localized: "Loading...")) - .font(.callout) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 32) - } - - private var emptyState: some View { - VStack(spacing: 12) { - Image(systemName: "magnifyingglass") - .font(.title2) - .foregroundStyle(.secondary) - - if viewModel.searchText.isEmpty { - Text(String(localized: "No objects found")) - .font(.body.weight(.medium)) - } else { - Text(String(localized: "No matching objects")) - .font(.body.weight(.medium)) - - Text(String(format: String(localized: "No objects match \"%@\""), viewModel.searchText)) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 32) - } - - private var footer: some View { - HStack { - Button("Cancel") { - dismiss() - } - .keyboardShortcut(.cancelAction) - - Spacer() - - Button("Open") { - openSelectedItem() - } - .buttonStyle(.borderedProminent) - .disabled(viewModel.selectedItemId == nil) - .keyboardShortcut(.defaultAction) - } - .padding(12) - } - - private func openSelectedItem() { - guard let item = viewModel.selectedItem() else { return } - commit(item) - } - - private func commit(_ item: QuickSwitcherItem) { - viewModel.recordSelection(item) - onSelect(item) - dismiss() - } -} diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 95d811f94..735f82ec7 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -442,9 +442,9 @@ struct DatabaseTreeView: View { } private func databaseMatchesSearch(_ db: DatabaseMetadata) -> Bool { - if db.name.localizedCaseInsensitiveContains(searchText) { return true } + if FuzzyMatcher.matches(query: searchText, candidate: db.name) { return true } if case .loaded(let list) = treeService.schemaListState(connectionId: connectionId, database: db.name) { - if list.contains(where: { $0.localizedCaseInsensitiveContains(searchText) }) { return true } + if list.contains(where: { FuzzyMatcher.matches(query: searchText, candidate: $0) }) { return true } for schema in list where schemaContentMatchesSearch(database: db.name, schema: schema) { return true } @@ -453,11 +453,11 @@ struct DatabaseTreeView: View { } private func schemaContentMatchesSearch(database: String, schema: String?) -> Bool { - if let schema, schema.localizedCaseInsensitiveContains(searchText) { return true } - if tables(database: database, schema: schema).contains(where: { $0.name.localizedCaseInsensitiveContains(searchText) }) { + if let schema, FuzzyMatcher.matches(query: searchText, candidate: schema) { return true } + if tables(database: database, schema: schema).contains(where: { FuzzyMatcher.matches(query: searchText, candidate: $0.name) }) { return true } - return routines(database: database, schema: schema).contains { $0.name.localizedCaseInsensitiveContains(searchText) } + return routines(database: database, schema: schema).contains { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } } private func visibleSchemas(database: String, all: [String]) -> [String] { @@ -465,7 +465,7 @@ struct DatabaseTreeView: View { let matched = searchText.isEmpty ? nonSystem : nonSystem.filter { schema in - schema.localizedCaseInsensitiveContains(searchText) + FuzzyMatcher.matches(query: searchText, candidate: schema) || schemaContentMatchesSearch(database: database, schema: schema) } var seen = Set() @@ -476,7 +476,7 @@ struct DatabaseTreeView: View { let all = tables(database: database, schema: schema) let matched = searchText.isEmpty ? all - : all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + : all.filter { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } var seen = Set() return matched.filter { seen.insert($0.id).inserted } } @@ -485,7 +485,7 @@ struct DatabaseTreeView: View { let all = routines(database: database, schema: schema) let matched = searchText.isEmpty ? all - : all.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + : all.filter { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } var seen = Set() return matched.filter { seen.insert($0.id).inserted } } diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index babc46f98..2ef6e6ccd 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -167,14 +167,14 @@ struct SidebarTreeView: View { private func tablesToShow(for schema: String) -> [TableInfo] { let tables = schemaService.tables(for: connectionId, schema: schema) - guard !searchText.isEmpty, !schema.localizedCaseInsensitiveContains(searchText) else { + guard !searchText.isEmpty, !FuzzyMatcher.matches(query: searchText, candidate: schema) else { return tables } - return tables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + return tables.filter { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } } private func schemaIsVisibleDuringSearch(_ schema: String) -> Bool { - if schema.localizedCaseInsensitiveContains(searchText) { return true } + if FuzzyMatcher.matches(query: searchText, candidate: schema) { return true } switch schemaService.schemaState(for: connectionId, schema: schema) { case .loaded: return !tablesToShow(for: schema).isEmpty diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index e99000451..4b7c38892 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -11,10 +11,9 @@ enum ConnectionSwitcherFilter { static func matches(_ connection: DatabaseConnection, query: String) -> Bool { let trimmed = query.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return true } - let needle = trimmed.lowercased() - return connection.name.lowercased().contains(needle) - || connection.host.lowercased().contains(needle) - || connection.database.lowercased().contains(needle) + return FuzzyMatcher.matches(query: trimmed, candidate: connection.name) + || FuzzyMatcher.matches(query: trimmed, candidate: connection.host) + || FuzzyMatcher.matches(query: trimmed, candidate: connection.database) } } diff --git a/TableProTests/Utilities/FuzzyMatcherTests.swift b/TableProTests/Utilities/FuzzyMatcherTests.swift index 577dd9aac..7311d82ed 100644 --- a/TableProTests/Utilities/FuzzyMatcherTests.swift +++ b/TableProTests/Utilities/FuzzyMatcherTests.swift @@ -2,125 +2,157 @@ // FuzzyMatcherTests.swift // TableProTests // -// Tests for FuzzyMatcher fuzzy string matching -// -import TableProPluginKit @testable import TablePro import Testing struct FuzzyMatcherTests { + private func score(_ query: String, _ candidate: String) -> Int { + FuzzyMatcher.match(query: query, candidate: candidate)?.score ?? 0 + } + // MARK: - Basic Matching - @Test("Empty query matches everything with score 1") - func emptyQueryMatchesAll() { - #expect(FuzzyMatcher.score(query: "", candidate: "users") == 1) - #expect(FuzzyMatcher.score(query: "", candidate: "") == 1) + @Test("Empty query returns nil") + func emptyQueryReturnsNil() { + #expect(FuzzyMatcher.match(query: "", candidate: "users") == nil) + #expect(FuzzyMatcher.match(query: "", candidate: "") == nil) + } + + @Test("Empty candidate returns nil") + func emptyCandidateReturnsNil() { + #expect(FuzzyMatcher.match(query: "abc", candidate: "") == nil) } - @Test("Empty candidate returns 0") - func emptyCandidateReturnsZero() { - #expect(FuzzyMatcher.score(query: "abc", candidate: "") == 0) + @Test("Non-matching query returns nil") + func nonMatchingQueryReturnsNil() { + #expect(FuzzyMatcher.match(query: "xyz", candidate: "users") == nil) } - @Test("Non-matching query returns 0") - func nonMatchingQueryReturnsZero() { - #expect(FuzzyMatcher.score(query: "xyz", candidate: "users") == 0) + @Test("Partial match where not all characters found returns nil") + func partialMatchReturnsNil() { + #expect(FuzzyMatcher.match(query: "uzx", candidate: "users") == nil) } - @Test("Partial match where not all characters found returns 0") - func partialMatchReturnsZero() { - #expect(FuzzyMatcher.score(query: "uzx", candidate: "users") == 0) + @Test("Query longer than candidate returns nil") + func queryLongerThanCandidateReturnsNil() { + #expect(FuzzyMatcher.match(query: "userstable", candidate: "users") == nil) } // MARK: - Scoring Quality @Test("Exact match scores higher than substring match") func exactMatchScoresHigher() { - let exact = FuzzyMatcher.score(query: "users", candidate: "users") - let partial = FuzzyMatcher.score(query: "users", candidate: "all_users_table") - #expect(exact > partial) + #expect(score("users", "users") > score("users", "all_users_table")) } @Test("Consecutive matches score higher than scattered") func consecutiveMatchesScoreHigher() { - let consecutive = FuzzyMatcher.score(query: "use", candidate: "users") - let scattered = FuzzyMatcher.score(query: "use", candidate: "u_s_e") - #expect(consecutive > scattered) + #expect(score("use", "users") > score("use", "u_s_e")) } @Test("Word boundary match scores higher") func wordBoundaryMatchScoresHigher() { - let boundary = FuzzyMatcher.score(query: "ut", candidate: "user_table") - let middle = FuzzyMatcher.score(query: "ut", candidate: "butter") - #expect(boundary > middle) + #expect(score("ut", "user_table") > score("ut", "butter")) } @Test("Earlier match position scores higher") func earlierMatchScoresHigher() { - let early = FuzzyMatcher.score(query: "a", candidate: "abc") - let late = FuzzyMatcher.score(query: "a", candidate: "xxa") - #expect(early > late) + #expect(score("a", "abc") > score("a", "xxa")) + } + + @Test("Prefix match beats infix match of the same length") + func prefixBeatsInfix() { + #expect(score("user", "user_roles") > score("user", "power_user")) } - // MARK: - Case Insensitivity + // MARK: - Case Sensitivity @Test("Matching is case insensitive") func caseInsensitiveMatching() { - let lower = FuzzyMatcher.score(query: "users", candidate: "USERS") - #expect(lower > 0) + #expect(score("users", "USERS") > 0) + #expect(score("USERS", "users") > 0) + } - let upper = FuzzyMatcher.score(query: "USERS", candidate: "users") - #expect(upper > 0) + @Test("Exact case match scores higher than cross-case match") + func exactCaseScoresHigher() { + #expect(score("Users", "Users") > score("users", "Users")) } - // MARK: - Special Characters + // MARK: - Boundaries - @Test("Handles underscores as word boundaries") - func handlesUnderscores() { - let score = FuzzyMatcher.score(query: "ut", candidate: "user_table") - #expect(score > 0) + @Test("Underscore abbreviation matches with boundary-aligned indices") + func underscoreAbbreviationIndices() { + let match = FuzzyMatcher.match(query: "uid", candidate: "user_id") + #expect(match?.matchedIndices == [0, 5, 6]) } - @Test("Handles camelCase as word boundaries") - func handlesCamelCase() { - let score = FuzzyMatcher.score(query: "uT", candidate: "userTable") - #expect(score > 0) + @Test("Camel case abbreviation picks boundary alignment over greedy") + func camelCaseOptimalAlignment() { + let match = FuzzyMatcher.match(query: "lll", candidate: "SVisualLoggerLogsList") + #expect(match?.matchedIndices == [7, 13, 17]) + } + + @Test("Consecutive substring reports contiguous indices") + func consecutiveSubstringIndices() { + let match = FuzzyMatcher.match(query: "use", candidate: "users") + #expect(match?.matchedIndices == [0, 1, 2]) + } + + @Test("Dollar sign acts as a word boundary") + func dollarSignBoundary() { + #expect(score("bp", "v$buffer_pool") > score("bp", "albpx")) } @Test("Single character query matches") func singleCharacterQuery() { - #expect(FuzzyMatcher.score(query: "u", candidate: "users") > 0) - #expect(FuzzyMatcher.score(query: "z", candidate: "users") == 0) + #expect(score("u", "users") > 0) + #expect(FuzzyMatcher.match(query: "z", candidate: "users") == nil) + } + + // MARK: - Determinism + + @Test("Same input always produces the same result") + func deterministicResult() { + let first = FuzzyMatcher.match(query: "ust", candidate: "user_settings_table") + let second = FuzzyMatcher.match(query: "ust", candidate: "user_settings_table") + #expect(first == second) } // MARK: - Emoji / Surrogate Handling @Test("Emoji in query blocks matching when it cannot match any candidate character") func emojiInQueryBlocksWhenUnmatched() { - let result = FuzzyMatcher.score(query: "🎉u", candidate: "users") - #expect(result == 0, "Leading emoji that cannot match any candidate character blocks subsequent matches") + #expect(FuzzyMatcher.match(query: "🎉u", candidate: "users") == nil) } @Test("Emoji in candidate string handled correctly") func emojiInCandidateHandled() { - let result = FuzzyMatcher.score(query: "ab", candidate: "a🎉b") - #expect(result > 0, "Candidate with emoji between matches should still match") + let match = FuzzyMatcher.match(query: "ab", candidate: "a🎉b") + #expect(match?.matchedIndices == [0, 2]) } - @Test("Pure emoji query against plain candidate returns 0") - func pureEmojiQueryReturnsZero() { - let result = FuzzyMatcher.score(query: "🎉🔥", candidate: "users") - #expect(result == 0) + @Test("Pure emoji query against plain candidate returns nil") + func pureEmojiQueryReturnsNil() { + #expect(FuzzyMatcher.match(query: "🎉🔥", candidate: "users") == nil) } - // MARK: - Performance + // MARK: - Long Input Fallback - @Test("Very long strings complete in reasonable time") - func veryLongStringsPerformance() { + @Test("Very long candidates fall back to greedy matching with indices") + func veryLongCandidateGreedyFallback() { let longCandidate = String(repeating: "abcdefghij", count: 1_000) - let query = "aej" - let result = FuzzyMatcher.score(query: query, candidate: longCandidate) - #expect(result > 0) + let match = FuzzyMatcher.match(query: "aej", candidate: longCandidate) + #expect(match != nil) + #expect(match?.matchedIndices == [0, 4, 9]) + } + + @Test("Very long queries fall back to greedy matching") + func veryLongQueryGreedyFallback() { + let query = String(repeating: "ab", count: 40) + let candidate = String(repeating: "ab", count: 50) + let match = FuzzyMatcher.match(query: query, candidate: candidate) + #expect(match != nil) + #expect(match?.matchedIndices.count == 80) } } diff --git a/TableProTests/Utilities/QuickSwitcherFrecencyStoreTests.swift b/TableProTests/Utilities/QuickSwitcherFrecencyStoreTests.swift new file mode 100644 index 000000000..796f4962f --- /dev/null +++ b/TableProTests/Utilities/QuickSwitcherFrecencyStoreTests.swift @@ -0,0 +1,136 @@ +// +// QuickSwitcherFrecencyStoreTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct QuickSwitcherFrecencyStoreTests { + private func makeDefaults() -> UserDefaults { + guard let suite = UserDefaults(suiteName: "QuickSwitcherFrecencyTests.\(UUID().uuidString)") else { + return .standard + } + return suite + } + + private func makeStore( + connectionId: UUID = UUID(), + defaults: UserDefaults? = nil + ) -> (store: QuickSwitcherFrecencyStore, defaults: UserDefaults, connectionId: UUID) { + let suite = defaults ?? makeDefaults() + let id = connectionId + return (QuickSwitcherFrecencyStore(connectionId: id, defaults: suite), suite, id) + } + + @Test("recordAccess produces a positive score") + func recordAccessProducesScore() { + let (store, _, _) = makeStore() + store.recordAccess(itemId: "table_users") + let score = store.scores()["table_users"] ?? 0 + #expect(score > 0) + } + + @Test("Unknown items have no score entry") + func unknownItemHasNoScore() { + let (store, _, _) = makeStore() + #expect(store.scores()["missing"] == nil) + } + + @Test("Recent access scores higher than an old access") + func recentBeatsOld() { + let (store, _, _) = makeStore() + let now = Date() + store.recordAccess(itemId: "recent", at: now) + store.recordAccess(itemId: "old", at: now.addingTimeInterval(-120 * 86_400)) + let scores = store.scores(now: now) + #expect((scores["recent"] ?? 0) > (scores["old"] ?? 0)) + } + + @Test("Frequent access scores higher than a single access") + func frequentBeatsSingle() { + let (store, _, _) = makeStore() + let now = Date() + for offset in 0..<5 { + store.recordAccess(itemId: "frequent", at: now.addingTimeInterval(TimeInterval(-offset * 3_600))) + } + store.recordAccess(itemId: "single", at: now) + let scores = store.scores(now: now) + #expect((scores["frequent"] ?? 0) > (scores["single"] ?? 0)) + } + + @Test("Score is capped at 1") + func scoreCapsAtOne() { + let (store, _, _) = makeStore() + let now = Date() + for offset in 0..<20 { + store.recordAccess(itemId: "hot", at: now.addingTimeInterval(TimeInterval(-offset))) + } + #expect((store.scores(now: now)["hot"] ?? 0) <= 1) + } + + @Test("Samples per item are capped at 10") + func samplesCapPerItem() { + let (store, defaults, connectionId) = makeStore() + for offset in 0..<15 { + store.recordAccess(itemId: "busy", at: Date().addingTimeInterval(TimeInterval(offset))) + } + let key = "QuickSwitcher.frecency.\(connectionId.uuidString)" + let stored = defaults.dictionary(forKey: key) as? [String: [TimeInterval]] + #expect(stored?["busy"]?.count == 10) + } + + @Test("Tracked items are pruned to the most recently used 100") + func trackedItemsPruned() { + let (store, _, _) = makeStore() + let now = Date() + for index in 0..<120 { + store.recordAccess(itemId: "item_\(index)", at: now.addingTimeInterval(TimeInterval(index))) + } + let scores = store.scores(now: now) + #expect(scores.count == 100) + #expect(scores["item_119"] != nil) + #expect(scores["item_0"] == nil) + } + + @Test("recentItemIds orders by last access, newest first") + func recentItemIdsOrdered() { + let (store, _, _) = makeStore() + let now = Date() + store.recordAccess(itemId: "first", at: now.addingTimeInterval(-300)) + store.recordAccess(itemId: "second", at: now.addingTimeInterval(-200)) + store.recordAccess(itemId: "third", at: now.addingTimeInterval(-100)) + #expect(store.recentItemIds(limit: 2) == ["third", "second"]) + } + + @Test("Legacy MRU list migrates preserving order and removes the old key") + func legacyMRUMigrates() { + let suite = makeDefaults() + let connectionId = UUID() + let legacyKey = "QuickSwitcher.mru.\(connectionId.uuidString)" + suite.set(["newest", "middle", "oldest"], forKey: legacyKey) + + let store = QuickSwitcherFrecencyStore(connectionId: connectionId, defaults: suite) + #expect(store.recentItemIds(limit: 10) == ["newest", "middle", "oldest"]) + #expect(suite.stringArray(forKey: legacyKey) == nil) + } + + @Test("clearHistory removes all tracked accesses") + func clearHistoryRemovesAll() { + let (store, _, _) = makeStore() + store.recordAccess(itemId: "table_users") + store.clearHistory() + #expect(store.scores().isEmpty) + #expect(store.recentItemIds(limit: 10).isEmpty) + } + + @Test("Stores for different connections are isolated") + func storesAreIsolatedPerConnection() { + let suite = makeDefaults() + let storeA = QuickSwitcherFrecencyStore(connectionId: UUID(), defaults: suite) + let storeB = QuickSwitcherFrecencyStore(connectionId: UUID(), defaults: suite) + storeA.recordAccess(itemId: "table_users") + #expect(storeB.scores().isEmpty) + } +} diff --git a/TableProTests/ViewModels/DatabaseSwitcherFilterTests.swift b/TableProTests/ViewModels/DatabaseSwitcherFilterTests.swift new file mode 100644 index 000000000..6d77811e1 --- /dev/null +++ b/TableProTests/ViewModels/DatabaseSwitcherFilterTests.swift @@ -0,0 +1,48 @@ +// +// DatabaseSwitcherFilterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct DatabaseSwitcherFilterTests { + private func makeViewModel(databaseNames: [String]) -> DatabaseSwitcherViewModel { + let vm = DatabaseSwitcherViewModel( + connectionId: UUID(), + currentDatabase: nil, + databaseType: .mysql + ) + vm.databases = databaseNames.map { DatabaseMetadata.minimal(name: $0) } + return vm + } + + @Test("Empty search returns every database") + func emptySearchReturnsAll() { + let vm = makeViewModel(databaseNames: ["app", "analytics", "staging"]) + #expect(vm.filteredDatabases.count == 3) + } + + @Test("Search matches subsequences, not just substrings") + func searchMatchesSubsequence() { + let vm = makeViewModel(databaseNames: ["analytics_prod", "staging"]) + vm.searchText = "anprd" + #expect(vm.filteredDatabases.map(\.name) == ["analytics_prod"]) + } + + @Test("Better matches rank first") + func betterMatchesRankFirst() { + let vm = makeViewModel(databaseNames: ["my_app_db", "app"]) + vm.searchText = "app" + #expect(vm.filteredDatabases.first?.name == "app") + } + + @Test("Non-matching search returns nothing") + func nonMatchingSearchReturnsNothing() { + let vm = makeViewModel(databaseNames: ["app", "analytics"]) + vm.searchText = "zzz" + #expect(vm.filteredDatabases.isEmpty) + } +} diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 089daf48a..823c1ea25 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -52,7 +52,7 @@ struct QuickSwitcherViewModelTests { func filteredGroupHasNoHeader() async throws { let vm = makeViewModel(items: sampleItems()) vm.searchText = "users" - try await Task.sleep(nanoseconds: 80_000_000) + try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.groups.count == 1) #expect(vm.groups.first?.header == nil) #expect(vm.flatItems.allSatisfy { $0.name.localizedCaseInsensitiveContains("u") }) @@ -125,8 +125,8 @@ struct QuickSwitcherViewModelTests { #expect(recentGroup?.items.first?.id == chosen.id) } - @Test("recordSelection trims MRU to 10 entries") - func mruTrimsToLimit() { + @Test("Recent group caps at 10 entries, newest first") + func recentGroupCapsAtLimit() { let suite = makeDefaults() let connectionId = UUID() var items: [QuickSwitcherItem] = [] @@ -134,12 +134,115 @@ struct QuickSwitcherViewModelTests { items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) } let vm = makeViewModel(items: items, connectionId: connectionId, defaults: suite) - for item in items { - vm.recordSelection(item) + for (index, item) in items.enumerated() { + vm.recordSelection(item, at: Date(timeIntervalSinceNow: TimeInterval(index))) } - let stored = suite.stringArray(forKey: "QuickSwitcher.mru.\(connectionId.uuidString)") ?? [] - #expect(stored.count == 10) - #expect(stored.first == items.last?.id) + + let vm2 = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) + vm2.allItems = items + let recentGroup = vm2.groups.first { $0.header == String(localized: "Recent") } + #expect(recentGroup?.items.count == 10) + #expect(recentGroup?.items.first?.id == items.last?.id) + } + + @Test("Filtered results carry matched character indices") + func filteredResultsCarryMatchedIndices() async throws { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "usr" + try await Task.sleep(nanoseconds: 200_000_000) + let users = vm.flatItems.first { $0.id == "t1" } + #expect(users?.matchedIndices == [0, 1, 3]) + } + + @Test("Frecency boosts a previously opened item over an equal match") + func frecencyBoostsPreviouslyOpenedItem() async throws { + let suite = makeDefaults() + let connectionId = UUID() + let items = [ + QuickSwitcherItem(id: "ta", name: "users_a", kind: .table, subtitle: ""), + QuickSwitcherItem(id: "tb", name: "users_b", kind: .table, subtitle: "") + ] + let vm = makeViewModel(items: items, connectionId: connectionId, defaults: suite) + vm.searchText = "users" + try await Task.sleep(nanoseconds: 200_000_000) + #expect(vm.flatItems.first?.id == "ta") + + vm.recordSelection(items[1]) + let vm2 = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) + vm2.allItems = items + vm2.searchText = "users" + try await Task.sleep(nanoseconds: 200_000_000) + #expect(vm2.flatItems.first?.id == "tb") + } + + @Test("Saved queries get their own section in the empty-query view") + func savedQueriesGetOwnSection() { + var items = sampleItems() + items.append(QuickSwitcherItem( + id: "f1", + name: "Monthly revenue", + kind: .savedQuery, + subtitle: "rev", + payload: "SELECT SUM(total) FROM orders GROUP BY month;" + )) + let vm = makeViewModel(items: items) + let headers = vm.groups.compactMap(\.header) + #expect(headers.contains(String(localized: "Saved Queries"))) + } + + @Test("Payload survives filtering") + func payloadSurvivesFiltering() async throws { + let items = [QuickSwitcherItem( + id: "f1", + name: "Monthly revenue", + kind: .savedQuery, + subtitle: "", + payload: "SELECT SUM(total) FROM orders GROUP BY month;" + )] + let vm = makeViewModel(items: items) + vm.searchText = "revenue" + try await Task.sleep(nanoseconds: 200_000_000) + #expect(vm.flatItems.first?.payload == "SELECT SUM(total) FROM orders GROUP BY month;") + } + + @Test("Scope limits the empty-query view to its kinds") + func scopeLimitsEmptyQueryView() { + let vm = makeViewModel(items: sampleItems()) + vm.scope = .tables + #expect(vm.flatItems.allSatisfy { [.table, .view, .systemTable].contains($0.kind) }) + vm.scope = .queries + #expect(vm.flatItems.allSatisfy { [.savedQuery, .queryHistory].contains($0.kind) }) + } + + @Test("Scope limits filtered results to its kinds") + func scopeLimitsFilteredResults() async throws { + let vm = makeViewModel(items: sampleItems()) + vm.scope = .containers + vm.searchText = "r" + try await Task.sleep(nanoseconds: 200_000_000) + #expect(vm.flatItems.allSatisfy { [.database, .schema].contains($0.kind) }) + #expect(vm.flatItems.contains { $0.id == "d1" }) + } + + @Test("A table already open in a tab outranks an equal match") + func openTabOutranksEqualMatch() async throws { + let items = [ + QuickSwitcherItem(id: "ta", name: "users_a", kind: .table, subtitle: ""), + QuickSwitcherItem(id: "tb", name: "users_b", kind: .table, subtitle: "", isOpenInTab: true) + ] + let vm = makeViewModel(items: items) + vm.searchText = "users" + try await Task.sleep(nanoseconds: 200_000_000) + #expect(vm.flatItems.first?.id == "tb") + } + + @Test("Query matching only the subtitle still surfaces the item") + func subtitleMatchSurfacesItem() async throws { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "mydb" + try await Task.sleep(nanoseconds: 200_000_000) + #expect(vm.flatItems.contains { $0.id == "h1" }) + #expect(vm.flatItems.first { $0.id == "h1" }?.matchedIndices.isEmpty == true) } @Test("Search keeps selection if still in results") @@ -151,7 +254,7 @@ struct QuickSwitcherViewModelTests { } vm.selectedItemId = usersItem.id vm.searchText = "users" - try await Task.sleep(nanoseconds: 80_000_000) + try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.flatItems.contains(where: { $0.id == usersItem.id })) #expect(vm.selectedItemId == usersItem.id) } @@ -161,7 +264,7 @@ struct QuickSwitcherViewModelTests { let vm = makeViewModel(items: sampleItems()) vm.selectedItemId = "d1" vm.searchText = "users" - try await Task.sleep(nanoseconds: 80_000_000) + try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.flatItems.contains(where: { $0.id == "d1" }) == false) #expect(vm.selectedItemId == vm.flatItems.first?.id) } @@ -176,7 +279,7 @@ struct QuickSwitcherViewModelTests { func listHeightSingleFilteredRow() async throws { let vm = makeViewModel(items: [QuickSwitcherItem(id: "t1", name: "users", kind: .table, subtitle: "")]) vm.searchText = "users" - try await Task.sleep(nanoseconds: 80_000_000) + try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.groups.first?.header == nil) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 30) } @@ -189,7 +292,7 @@ struct QuickSwitcherViewModelTests { } let vm = makeViewModel(items: items) vm.searchText = "tbl" - try await Task.sleep(nanoseconds: 80_000_000) + try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.flatItems.count == 9) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) } @@ -202,7 +305,7 @@ struct QuickSwitcherViewModelTests { } let vm = makeViewModel(items: items) vm.searchText = "tbl" - try await Task.sleep(nanoseconds: 80_000_000) + try await Task.sleep(nanoseconds: 200_000_000) #expect(vm.flatItems.count == 20) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) } diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index 9a6798e8e..e2548c953 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -347,6 +347,19 @@ struct SidebarViewModelMultiSectionTests { #expect(funcs.map(\.name) == ["calculate_age"]) } + @Test("Sidebar filter matches fuzzy abbreviations like Xcode's navigator") + @MainActor + func sidebarFilterMatchesAbbreviation() { + let vm = makeViewModel() + let userProfileView = TestFixtures.makeTableInfo(name: "user_profile_view", type: .view) + let orders = TestFixtures.makeTableInfo(name: "orders", type: .view) + vm.searchText = "upv" + + let matches = vm.filteredTables(of: .view, from: [userProfileView, orders]) + + #expect(matches.map(\.name) == ["user_profile_view"]) + } + @Test("filteredRoutines search matches name case insensitively") @MainActor func filteredRoutinesSearch() { diff --git a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift new file mode 100644 index 000000000..d650d1478 --- /dev/null +++ b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift @@ -0,0 +1,64 @@ +// +// QuickSwitcherPanelControllerTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct QuickSwitcherPanelControllerTests { + @Test("present shows the panel") + func presentShowsPanel() { + let controller = QuickSwitcherPanelController() + controller.present(Text(verbatim: "content"), over: nil) + #expect(controller.isPresented) + controller.dismiss() + } + + @Test("dismiss hides the panel") + func dismissHidesPanel() { + let controller = QuickSwitcherPanelController() + controller.present(Text(verbatim: "content"), over: nil) + controller.dismiss() + #expect(controller.isPresented == false) + } + + @Test("presenting again replaces the previous panel") + func presentReplacesPreviousPanel() { + let controller = QuickSwitcherPanelController() + controller.present(Text(verbatim: "first"), over: nil) + controller.present(Text(verbatim: "second"), over: nil) + #expect(controller.isPresented) + controller.dismiss() + #expect(controller.isPresented == false) + } + + @Test("losing key status dismisses the panel") + func resignKeyDismissesPanel() { + let controller = QuickSwitcherPanelController() + controller.present(Text(verbatim: "content"), over: nil) + controller.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification)) + #expect(controller.isPresented == false) + } + + @Test("panel cannot become main but can become key") + func panelKeyAndMainBehavior() { + let panel = QuickSwitcherPanel(contentView: NSView()) + #expect(panel.canBecomeKey) + #expect(panel.canBecomeMain == false) + panel.orderOut(nil) + } + + @Test("escape on the panel invokes onCancel") + func escapeInvokesOnCancel() { + let panel = QuickSwitcherPanel(contentView: NSView()) + var cancelled = false + panel.onCancel = { cancelled = true } + panel.cancelOperation(nil) + #expect(cancelled) + panel.orderOut(nil) + } +} diff --git a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift index d860110cf..7e68b1758 100644 --- a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift +++ b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift @@ -41,6 +41,12 @@ struct ConnectionSwitcherFilterTests { let connection = TestFixtures.makeConnection(name: "Primary", database: "analytics") #expect(!ConnectionSwitcherFilter.matches(connection, query: "zzz")) } + + @Test("Fuzzy abbreviation matches across word boundaries") + func fuzzyAbbreviationMatches() { + let connection = TestFixtures.makeConnection(name: "Production DB", database: "app") + #expect(ConnectionSwitcherFilter.matches(connection, query: "pdb")) + } } @Suite("Connection Switcher Selection") diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index da17ae013..eac357308 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -298,16 +298,18 @@ These shortcuts only apply when the table structure view is focused. They overri ## Quick switcher -The Quick Switcher (`Cmd+Shift+O`) 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. +The Quick Switcher (`Cmd+Shift+O`) opens a floating panel to search and jump to any table, view, database, schema, saved query, or recent query. It uses fuzzy matching, so typing `usr` finds `users`, `user_settings`, etc. Matched characters are highlighted in each result. | Action | Shortcut | |--------|----------| | Open Quick Switcher | `Cmd+Shift+O` | | Navigate results | `Up` / `Down` arrows | | Open selected item | `Return` | +| Open in a new tab | `Option+Return` | +| Switch scope (All, Tables, Databases, Queries) | `Cmd+1` to `Cmd+4` | | Dismiss | `Escape` | -Results are grouped by type (tables, views, system tables, databases, schemas, recent queries) and ranked by match quality when searching. +Results are grouped by type (tables, views, system tables, databases, schemas, saved queries, recent queries) and ranked by match quality, how often you open each item, and how recently. Tables already open in a tab show an `Open` badge and rank higher; selecting one switches to the existing tab instead of opening a duplicate. Right-click a result for more actions: open the table structure, copy the name, or copy the query. ## Global Shortcuts