From 9c98ffa9af3f406a728036e01a99386fa6e6951a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 10 Jun 2026 13:37:22 +0700 Subject: [PATCH 01/15] feat(toolbar): quick switcher fuzzy engine with match highlighting and frecency ranking --- CHANGELOG.md | 1 + TablePro/Core/Utilities/UI/FuzzyMatcher.swift | 237 +++++++++++++----- .../UI/QuickSwitcherFrecencyStore.swift | 107 ++++++++ TablePro/Models/UI/QuickSwitcherItem.swift | 4 +- .../ViewModels/QuickSwitcherViewModel.swift | 133 ++++++---- TablePro/Views/Main/MainContentView.swift | 1 - .../QuickSwitcher/QuickSwitcherSheet.swift | 18 +- .../Utilities/FuzzyMatcherTests.swift | 148 ++++++----- .../QuickSwitcherFrecencyStoreTests.swift | 136 ++++++++++ .../QuickSwitcherViewModelTests.swift | 68 ++++- 10 files changed, 660 insertions(+), 193 deletions(-) create mode 100644 TablePro/Core/Utilities/UI/QuickSwitcherFrecencyStore.swift create mode 100644 TableProTests/Utilities/QuickSwitcherFrecencyStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ab26208b9..4e3594048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509) +- Quick Switcher highlights the matched characters in each result, finds better alignments for camelCase and snake_case names, and ranks items you open often and recently higher. ### Fixed diff --git a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift index ceb9caf7c..780ddd19d 100644 --- a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift +++ b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift @@ -2,92 +2,199 @@ // 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 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) + } - // Record first match position - if firstMatchPosition < 0 { - firstMatchPosition = candidateIndex - } + 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 + } + } - // Consecutive match bonus - consecutiveBonus += 1 - if consecutiveBonus > 1 { - matchScore += consecutiveBonus * 4 - } + matchScores[cell] = matchScore - // 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 + 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) } + } + + 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) + } - // Exact case match bonus - if queryChar == candidateChar { - matchScore += 1 + 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..49f173113 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -18,12 +18,12 @@ internal enum QuickSwitcherItemKind: String, Hashable, Sendable { } /// 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] = [] /// SF Symbol name for this item's icon var iconName: String { diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 696387acc..91fa824a8 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -7,27 +7,31 @@ import Foundation import Observation import os +private enum QuickSwitcherRanking { + static let maxResults = 200 + static let subtitleMatchPenalty = 0.6 + static let frecencyBoost = 0.5 +} + @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 +43,7 @@ internal final class QuickSwitcherViewModel { var searchText = "" { didSet { guard oldValue != searchText else { return } - scheduleFilter() + scheduleFilter(debounced: true) } } @@ -57,7 +61,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()) { @@ -183,30 +187,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 = allItems + 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 @@ -215,57 +223,71 @@ internal final class QuickSwitcherViewModel { } private func buildEmptyQueryGroups() -> [Group] { - let mruList = loadMRU() - let mruIds = Set(mruList) - let mruOrder = Dictionary(uniqueKeysWithValues: mruList.enumerated().map { ($1, $0) }) + 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) } + .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) } + .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 boost = 1 + (frecencyScores[item.id] ?? 0) * QuickSwitcherRanking.frecencyBoost + return (matched, matchScore * item.kind.rankWeight * boost) } - 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, []) } } @@ -274,6 +296,17 @@ private extension QuickSwitcherItemKind { .table, .view, .systemTable, .database, .schema, .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 .queryHistory: return 0.7 + } + } + var sectionTitle: String { switch self { case .table: return String(localized: "Tables") diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index d57475024..d37282098 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -280,7 +280,6 @@ struct MainContentView: View { ) case .quickSwitcher: QuickSwitcherSheet( - isPresented: dismissBinding, schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connection.id), connectionId: connection.id, databaseType: connection.type, diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift index 7efbfdca5..d2849f6f1 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift @@ -6,7 +6,6 @@ import SwiftUI struct QuickSwitcherSheet: View { - @Binding var isPresented: Bool @Environment(\.dismiss) private var dismiss let schemaProvider: SQLSchemaProvider @@ -22,13 +21,11 @@ struct QuickSwitcherSheet: View { @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 @@ -140,7 +137,7 @@ struct QuickSwitcherSheet: View { .foregroundStyle(.secondary) .frame(width: 18) - Text(item.name) + Text(highlightedName(for: item)) .font(.body) .lineLimit(1) .truncationMode(.middle) @@ -215,6 +212,19 @@ struct QuickSwitcherSheet: View { .padding(12) } + 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.. 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/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 089daf48a..e5420ce3c 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,54 @@ 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("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 +193,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 +203,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 +218,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 +231,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 +244,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) } From 4004371dac0571710e5a94cddb57036af67801a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 10 Jun 2026 13:52:05 +0700 Subject: [PATCH 02/15] feat(toolbar): quick switcher searches saved queries, loads schema on demand, opens full history queries --- CHANGELOG.md | 3 ++ TablePro/Models/UI/QuickSwitcherItem.swift | 4 +++ .../ViewModels/QuickSwitcherViewModel.swift | 23 ++++++++++++-- ...MainContentCoordinator+QuickSwitcher.swift | 5 +++- .../QuickSwitcherViewModelTests.swift | 30 +++++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e3594048..ed1ae13ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ 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. ### Changed @@ -20,6 +21,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. ### Security diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 49f173113..12cded6bb 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -14,6 +14,7 @@ internal enum QuickSwitcherItemKind: String, Hashable, Sendable { case systemTable case database case schema + case savedQuery case queryHistory } @@ -24,6 +25,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { let kind: QuickSwitcherItemKind let subtitle: String var matchedIndices: [Int] = [] + var payload: String? /// SF Symbol name for this item's icon var iconName: String { @@ -33,6 +35,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { case .systemTable: return "gearshape" case .database: return "cylinder" case .schema: return "folder" + case .savedQuery: return "star" case .queryHistory: return "clock.arrow.circlepath" } } @@ -45,6 +48,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { 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/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 91fa824a8..2d718fcea 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -79,6 +79,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 @@ -149,6 +154,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 @@ -158,7 +174,8 @@ internal final class QuickSwitcherViewModel { id: "history_\(entry.id.uuidString)", name: entry.queryPreview, kind: .queryHistory, - subtitle: entry.databaseName + subtitle: entry.databaseName, + payload: entry.query )) } @@ -293,7 +310,7 @@ internal final class QuickSwitcherViewModel { private extension QuickSwitcherItemKind { static let displayOrder: [QuickSwitcherItemKind] = [ - .table, .view, .systemTable, .database, .schema, .queryHistory + .table, .view, .systemTable, .database, .schema, .savedQuery, .queryHistory ] var rankWeight: Double { @@ -303,6 +320,7 @@ private extension QuickSwitcherItemKind { 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 } } @@ -314,6 +332,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/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index ed6a6678a..29554bd1c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -30,8 +30,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/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index e5420ce3c..340596d85 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -175,6 +175,36 @@ struct QuickSwitcherViewModelTests { #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("Query matching only the subtitle still surfaces the item") func subtitleMatchSurfacesItem() async throws { let vm = makeViewModel(items: sampleItems()) From ec35850880eec00505a890582960a174c0ef9889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 10 Jun 2026 13:58:50 +0700 Subject: [PATCH 03/15] feat(toolbar): present quick switcher as a floating panel with glass material --- CHANGELOG.md | 1 + ...MainContentCoordinator+QuickSwitcher.swift | 14 +- .../Views/Main/MainContentCoordinator.swift | 5 +- TablePro/Views/Main/MainContentView.swift | 7 - .../QuickSwitcher/QuickSwitcherPanel.swift | 124 ++++++++++++++++++ ...eet.swift => QuickSwitcherPanelView.swift} | 79 +++++++---- .../QuickSwitcherPanelControllerTests.swift | 64 +++++++++ 7 files changed, 255 insertions(+), 39 deletions(-) create mode 100644 TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift rename TablePro/Views/QuickSwitcher/{QuickSwitcherSheet.swift => QuickSwitcherPanelView.swift} (76%) create mode 100644 TableProTests/Views/QuickSwitcherPanelControllerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1ae13ca..e9b57a8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 floating panel over the window instead of a modal sheet, with shortcut hints in the footer. On macOS 26 the panel uses Liquid Glass. ### Fixed diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 29554bd1c..6e4c1e6a4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -5,11 +5,23 @@ // Quick switcher navigation handler for MainContentCoordinator // +import AppKit import Foundation extension MainContentCoordinator { func showQuickSwitcher() { - activeSheet = .quickSwitcher + guard !quickSwitcherPanel.isPresented else { + quickSwitcherPanel.dismiss() + return + } + let panelView = QuickSwitcherPanelView( + schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connectionId), + connectionId: connectionId, + databaseType: connection.type, + onSelect: { [weak self] item in self?.handleQuickSwitcherSelection(item) }, + onDismiss: { [weak self] in self?.quickSwitcherPanel.dismiss() } + ) + quickSwitcherPanel.present(panelView, over: contentWindow) } func handleQuickSwitcherSelection(_ item: QuickSwitcherItem) { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 56b70220c..2e6c197b5 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 d37282098..6089c2716 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -278,13 +278,6 @@ struct MainContentView: View { databaseType: connection.type, onExecute: coordinator.executeMaintenance ) - case .quickSwitcher: - QuickSwitcherSheet( - 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..1f1270713 --- /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.18 + + 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/QuickSwitcherSheet.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift similarity index 76% rename from TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift rename to TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index d2849f6f1..50ed9864e 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -1,22 +1,24 @@ // -// QuickSwitcherSheet.swift +// QuickSwitcherPanelView.swift // TablePro // import SwiftUI -struct QuickSwitcherSheet: View { - @Environment(\.dismiss) private var dismiss +struct QuickSwitcherPanelView: View { + @Environment(\.colorSchemeContrast) private var colorSchemeContrast let schemaProvider: SQLSchemaProvider let connectionId: UUID let databaseType: DatabaseType let onSelect: (QuickSwitcherItem) -> Void + let onDismiss: () -> Void - private let sheetWidth: CGFloat = 460 + private let panelWidth: CGFloat = 640 + private let cornerRadius: CGFloat = 16 private let rowHeight: CGFloat = 30 private let sectionHeaderHeight: CGFloat = 28 - private let maxVisibleRows = 9 + private let maxVisibleRows = 12 @State private var viewModel: QuickSwitcherViewModel @@ -24,18 +26,20 @@ struct QuickSwitcherSheet: View { schemaProvider: SQLSchemaProvider, connectionId: UUID, databaseType: DatabaseType, - onSelect: @escaping (QuickSwitcherItem) -> Void + onSelect: @escaping (QuickSwitcherItem) -> Void, + onDismiss: @escaping () -> Void ) { self.schemaProvider = schemaProvider self.connectionId = connectionId self.databaseType = databaseType self.onSelect = onSelect + self.onDismiss = onDismiss self._viewModel = State(wrappedValue: QuickSwitcherViewModel(connectionId: connectionId)) } var body: some View { VStack(spacing: 0) { - toolbar + searchField Divider() @@ -56,16 +60,22 @@ struct QuickSwitcherSheet: View { footer } - .frame(width: sheetWidth) - .navigationTitle(String(localized: "Quick Switcher")) - .background(Color(nsColor: .windowBackgroundColor)) + .frame(width: panelWidth) + .background(QuickSwitcherPanelBackground(cornerRadius: cornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder( + colorSchemeContrast == .increased ? Color(nsColor: .separatorColor) : .clear, + lineWidth: 1 + ) + ) .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) @@ -78,16 +88,18 @@ struct QuickSwitcherSheet: View { } } - private var toolbar: some View { + private var searchField: some View { NativeSearchField( text: $viewModel.searchText, - placeholder: String(localized: "Search tables, views, databases..."), + placeholder: String(localized: "Search tables, views, databases, queries..."), + controlSize: .large, onMoveUp: { viewModel.moveSelection(by: -1) }, onMoveDown: { viewModel.moveSelection(by: 1) }, + onSubmit: { openSelectedItem() }, focusOnAppear: true ) - .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.horizontal, 14) + .padding(.vertical, 12) } private var itemList: some View { @@ -194,22 +206,31 @@ struct QuickSwitcherSheet: View { } private var footer: some View { - HStack { - Button("Cancel") { - dismiss() - } - .keyboardShortcut(.cancelAction) - + HStack(spacing: 16) { + shortcutHint(keys: "↑↓", label: String(localized: "Navigate")) + shortcutHint(keys: "↩", label: String(localized: "Open")) Spacer() + shortcutHint(keys: "esc", label: String(localized: "Close")) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } - Button("Open") { - openSelectedItem() - } - .buttonStyle(.borderedProminent) - .disabled(viewModel.selectedItemId == nil) - .keyboardShortcut(.defaultAction) + private func shortcutHint(keys: String, label: String) -> some View { + HStack(spacing: 5) { + Text(keys) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(nsColor: .quaternarySystemFill)) + ) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) } - .padding(12) } private func highlightedName(for item: QuickSwitcherItem) -> AttributedString { @@ -233,6 +254,6 @@ struct QuickSwitcherSheet: View { private func commit(_ item: QuickSwitcherItem) { viewModel.recordSelection(item) onSelect(item) - dismiss() + onDismiss() } } 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) + } +} From e3c312bd15487123cf0b71299c54e3cff9a4e8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 10 Jun 2026 14:04:52 +0700 Subject: [PATCH 04/15] feat(toolbar): quick switcher scopes, open in new tab, switch-to-tab boost, and row actions --- CHANGELOG.md | 3 + TablePro/Models/UI/QuickSwitcherItem.swift | 36 +++++++ TablePro/Resources/Localizable.xcstrings | 20 ++++ .../ViewModels/QuickSwitcherViewModel.swift | 31 ++++-- .../MainContentCoordinator+Navigation.swift | 10 +- ...MainContentCoordinator+QuickSwitcher.swift | 25 ++++- .../QuickSwitcherPanelView.swift | 98 +++++++++++++++++-- .../QuickSwitcherViewModelTests.swift | 31 ++++++ docs/features/keyboard-shortcuts.mdx | 6 +- 9 files changed, 233 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b57a8ce..043a3a16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. ### Changed diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 12cded6bb..dbfeab5fc 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -18,6 +18,41 @@ internal enum QuickSwitcherItemKind: String, Hashable, Sendable { 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, Sendable { let id: String @@ -26,6 +61,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { let subtitle: String var matchedIndices: [Int] = [] var payload: String? + var isOpenInTab: Bool = false /// SF Symbol name for this item's icon var iconName: String { 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/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 2d718fcea..5dc2abdce 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -11,6 +11,7 @@ private enum QuickSwitcherRanking { static let maxResults = 200 static let subtitleMatchPenalty = 0.6 static let frecencyBoost = 0.5 + static let openTabBoost = 1.2 } @MainActor @@ -47,6 +48,13 @@ internal final class QuickSwitcherViewModel { } } + var scope: QuickSwitcherScope = .all { + didSet { + guard oldValue != scope else { return } + scheduleFilter(debounced: false) + } + } + var flatItems: [QuickSwitcherItem] { groups.flatMap(\.items) } @@ -70,7 +78,8 @@ internal final class QuickSwitcherViewModel { func loadItems( schemaProvider: SQLSchemaProvider, - databaseType: DatabaseType + databaseType: DatabaseType, + openTableNames: Set = [] ) async { isLoading = true @@ -109,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) )) } @@ -217,7 +227,7 @@ internal final class QuickSwitcherViewModel { reconcileSelection() return } - let items = allItems + let items = scopedItems() let frecencyScores = frecencyStore.scores() filterTask = Task { @MainActor [weak self] in if debounced { @@ -239,14 +249,20 @@ 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 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 + let recent = scoped .filter { recentIdSet.contains($0.id) } .sorted { (recentOrder[$0.id] ?? 0) < (recentOrder[$1.id] ?? 0) } if !recent.isEmpty { @@ -254,7 +270,7 @@ internal final class QuickSwitcherViewModel { } for kind in QuickSwitcherItemKind.displayOrder { - let items = allItems + let items = scoped .filter { $0.kind == kind && !recentIdSet.contains($0.id) } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } guard !items.isEmpty else { continue } @@ -276,8 +292,9 @@ internal final class QuickSwitcherViewModel { guard let (matchScore, matchedIndices) = bestMatch(for: item, query: query) else { return nil } var matched = item matched.matchedIndices = matchedIndices - let boost = 1 + (frecencyScores[item.id] ?? 0) * QuickSwitcherRanking.frecencyBoost - return (matched, matchScore * item.kind.rankWeight * boost) + let frecency = 1 + (frecencyScores[item.id] ?? 0) * QuickSwitcherRanking.frecencyBoost + let openBoost = item.isOpenInTab ? QuickSwitcherRanking.openTabBoost : 1 + return (matched, matchScore * item.kind.rankWeight * frecency * openBoost) } ranked.sort { lhs, rhs in if lhs.rank != rhs.rank { return lhs.rank > rhs.rank } 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 6e4c1e6a4..a1959c098 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -14,23 +14,40 @@ extension MainContentCoordinator { 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, - onSelect: { [weak self] item in self?.handleQuickSwitcherSelection(item) }, + 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 { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 50ed9864e..86be8eec1 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -3,6 +3,7 @@ // TablePro // +import AppKit import SwiftUI struct QuickSwitcherPanelView: View { @@ -11,7 +12,8 @@ struct QuickSwitcherPanelView: View { let schemaProvider: SQLSchemaProvider let connectionId: UUID let databaseType: DatabaseType - let onSelect: (QuickSwitcherItem) -> Void + let openTableNames: Set + let onSelect: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void let onDismiss: () -> Void private let panelWidth: CGFloat = 640 @@ -26,12 +28,14 @@ struct QuickSwitcherPanelView: View { schemaProvider: SQLSchemaProvider, connectionId: UUID, databaseType: DatabaseType, - onSelect: @escaping (QuickSwitcherItem) -> Void, + 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)) @@ -41,6 +45,8 @@ struct QuickSwitcherPanelView: View { VStack(spacing: 0) { searchField + scopeBar + Divider() if viewModel.isLoading { @@ -73,9 +79,19 @@ struct QuickSwitcherPanelView: View { .task { await viewModel.loadItems( schemaProvider: schemaProvider, - databaseType: databaseType + databaseType: databaseType, + openTableNames: openTableNames ) } + .onKeyPress(characters: .init(charactersIn: "1234"), phases: [.down]) { keyPress in + guard keyPress.modifiers.contains(.command), + let digit = keyPress.characters.first, + let index = Int(String(digit)), + index >= 1, index <= QuickSwitcherScope.allCases.count + else { return .ignored } + viewModel.scope = QuickSwitcherScope.allCases[index - 1] + return .handled + } .onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in guard keyPress.modifiers.contains(.control) else { return .ignored } viewModel.moveSelection(by: 1) @@ -99,7 +115,21 @@ struct QuickSwitcherPanelView: View { focusOnAppear: true ) .padding(.horizontal, 14) - .padding(.vertical, 12) + .padding(.top, 12) + .padding(.bottom, 8) + } + + private var scopeBar: some View { + Picker(String(localized: "Scope"), selection: $viewModel.scope) { + ForEach(QuickSwitcherScope.allCases) { scope in + Text(scope.title).tag(scope) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .padding(.horizontal, 14) + .padding(.bottom, 10) } private var itemList: some View { @@ -123,14 +153,17 @@ struct QuickSwitcherPanelView: View { } .listStyle(.inset) .scrollContentBackground(.hidden) - .contextMenu(forSelectionType: String.self) { _ in - EmptyView() + .contextMenu(forSelectionType: String.self) { selection in + if let id = selection.first, + let item = viewModel.flatItems.first(where: { $0.id == id }) { + contextMenuActions(for: item) + } } primaryAction: { selection in guard let id = selection.first, let item = viewModel.flatItems.first(where: { $0.id == id }) else { return } viewModel.selectedItemId = id - commit(item) + commit(item, intent: .open) } .onChange(of: viewModel.selectedItemId) { _, newValue in if let id = newValue { @@ -156,6 +189,15 @@ struct QuickSwitcherPanelView: View { Spacer() + if item.isOpenInTab { + Text(String(localized: "Open")) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + } + if !item.subtitle.isEmpty { Text(item.subtitle) .font(.caption) @@ -209,6 +251,7 @@ struct QuickSwitcherPanelView: View { HStack(spacing: 16) { shortcutHint(keys: "↑↓", label: String(localized: "Navigate")) shortcutHint(keys: "↩", label: String(localized: "Open")) + shortcutHint(keys: "⌥↩", label: String(localized: "New Tab")) Spacer() shortcutHint(keys: "esc", label: String(localized: "Close")) } @@ -216,6 +259,38 @@ struct QuickSwitcherPanelView: View { .padding(.vertical, 8) } + @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 copyToPasteboard(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + private func shortcutHint(keys: String, label: String) -> some View { HStack(spacing: 5) { Text(keys) @@ -248,12 +323,15 @@ struct QuickSwitcherPanelView: View { private func openSelectedItem() { guard let item = viewModel.selectedItem() else { return } - commit(item) + let intent: QuickSwitcherCommitIntent = NSEvent.modifierFlags.contains(.option) + ? .openInNewWindowTab + : .open + commit(item, intent: intent) } - private func commit(_ item: QuickSwitcherItem) { + private func commit(_ item: QuickSwitcherItem, intent: QuickSwitcherCommitIntent) { viewModel.recordSelection(item) - onSelect(item) + onSelect(item, intent) onDismiss() } } diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 340596d85..823c1ea25 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -205,6 +205,37 @@ struct QuickSwitcherViewModelTests { #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()) 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 From 1871f30b4c02e7343d3fb7a2dc4b64c4ed5d65a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 10 Jun 2026 14:10:19 +0700 Subject: [PATCH 05/15] refactor(sidebar): unify sidebar and switcher filtering on the fuzzy matcher --- CHANGELOG.md | 1 + TablePro/Core/Utilities/UI/FuzzyMatcher.swift | 6 +++ .../DatabaseSwitcherViewModel.swift | 18 ++++--- TablePro/ViewModels/SidebarViewModel.swift | 6 +-- TablePro/Views/Sidebar/DatabaseTreeView.swift | 16 +++---- TablePro/Views/Sidebar/SidebarTreeView.swift | 6 +-- .../Toolbar/ConnectionSwitcherPopover.swift | 7 ++- .../DatabaseSwitcherFilterTests.swift | 48 +++++++++++++++++++ .../ViewModels/SidebarViewModelTests.swift | 13 +++++ .../ConnectionSwitcherFilterTests.swift | 6 +++ 10 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 TableProTests/ViewModels/DatabaseSwitcherFilterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 043a3a16f..9c35c16cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 floating panel over the window instead of a modal sheet, with shortcut hints in the footer. On macOS 26 the panel uses Liquid Glass. +- The sidebar filter, database switcher, and connection switcher now use the same fuzzy matching as the Quick Switcher, so abbreviations like `upv` find `user_profile_view`. ### Fixed diff --git a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift index 780ddd19d..0511ea818 100644 --- a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift +++ b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift @@ -29,6 +29,12 @@ internal enum FuzzyMatcher { private static let maxScoredQueryLength = 64 private static let invalid = Int.min / 4 + 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 + } + static func match(query: String, candidate: String) -> FuzzyMatch? { let queryChars = Array(query) let candidateChars = Array(candidate) 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/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/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/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/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/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") From 7f20daefd152c2ee9fc34012376709fa506d4d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 10 Jun 2026 15:32:51 +0700 Subject: [PATCH 06/15] refactor(toolbar): rebuild quick switcher panel to the measured Spotlight spec --- CHANGELOG.md | 2 +- .../QuickSwitcher/QuickSwitcherPanel.swift | 2 +- .../QuickSwitcherPanelView.swift | 340 +++++++++--------- .../QuickSwitcherSearchField.swift | 110 ++++++ 4 files changed, 285 insertions(+), 169 deletions(-) create mode 100644 TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c35c16cd..5963ab2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 floating panel over the window instead of a modal sheet, with shortcut hints in the footer. On macOS 26 the panel uses Liquid Glass. +- Quick Switcher now opens as a Spotlight-style floating panel over the window instead of a modal sheet: large borderless search field, scope chips, and rounded row selection. On macOS 26 the panel uses Liquid Glass. - The sidebar filter, database switcher, and connection switcher now use the same fuzzy matching as the Quick Switcher, so abbreviations like `upv` find `user_profile_view`. ### Fixed diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index 1f1270713..5d99d72a2 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -42,7 +42,7 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { let top: CGFloat } - private static let topOffsetRatio: CGFloat = 0.18 + private static let topOffsetRatio: CGFloat = 0.20 private var panel: QuickSwitcherPanel? private var anchor: Anchor? diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 86be8eec1..e21624b34 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -6,6 +6,23 @@ 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 @@ -16,13 +33,8 @@ struct QuickSwitcherPanelView: View { let onSelect: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void let onDismiss: () -> Void - private let panelWidth: CGFloat = 640 - private let cornerRadius: CGFloat = 16 - private let rowHeight: CGFloat = 30 - private let sectionHeaderHeight: CGFloat = 28 - private let maxVisibleRows = 12 - @State private var viewModel: QuickSwitcherViewModel + @State private var keyMonitor: Any? init( schemaProvider: SQLSchemaProvider, @@ -43,34 +55,23 @@ struct QuickSwitcherPanelView: View { var body: some View { VStack(spacing: 0) { - searchField - - scopeBar - - Divider() + inputRow - if viewModel.isLoading { - loadingView - } else if viewModel.flatItems.isEmpty { - emptyState - } else { - itemList - .frame(height: viewModel.listHeight( - rowHeight: rowHeight, - headerHeight: sectionHeaderHeight, - maxVisibleRows: maxVisibleRows - )) + if showsContent { + Divider() + scopeChips + if viewModel.flatItems.isEmpty { + noResultsRow + } else { + resultsList + } } - - Divider() - - footer } - .frame(width: panelWidth) - .background(QuickSwitcherPanelBackground(cornerRadius: cornerRadius)) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .frame(width: PanelMetrics.width) + .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.cornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: PanelMetrics.cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + RoundedRectangle(cornerRadius: PanelMetrics.cornerRadius, style: .continuous) .strokeBorder( colorSchemeContrast == .increased ? Color(nsColor: .separatorColor) : .clear, lineWidth: 1 @@ -83,115 +84,129 @@ struct QuickSwitcherPanelView: View { openTableNames: openTableNames ) } - .onKeyPress(characters: .init(charactersIn: "1234"), phases: [.down]) { keyPress in - guard keyPress.modifiers.contains(.command), - let digit = keyPress.characters.first, - let index = Int(String(digit)), - index >= 1, index <= QuickSwitcherScope.allCases.count - else { return .ignored } - viewModel.scope = QuickSwitcherScope.allCases[index - 1] - return .handled - } - .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 + .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 searchField: some View { - NativeSearchField( - text: $viewModel.searchText, - placeholder: String(localized: "Search tables, views, databases, queries..."), - controlSize: .large, - onMoveUp: { viewModel.moveSelection(by: -1) }, - onMoveDown: { viewModel.moveSelection(by: 1) }, - onSubmit: { openSelectedItem() }, - focusOnAppear: true - ) - .padding(.horizontal, 14) - .padding(.top, 12) - .padding(.bottom, 8) + 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 scopeBar: some View { - Picker(String(localized: "Scope"), selection: $viewModel.scope) { + private var scopeChips: some View { + HStack(spacing: 8) { ForEach(QuickSwitcherScope.allCases) { scope in - Text(scope.title).tag(scope) + scopeChip(scope) } } - .pickerStyle(.segmented) - .labelsHidden() - .controlSize(.small) .padding(.horizontal, 14) - .padding(.bottom, 10) + .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 itemList: some View { + private var resultsList: 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) + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewModel.groups) { group in + if let header = group.header { + sectionHeader(header) } - } else { ForEach(group.items) { item in itemRow(item) } } } + .padding(.vertical, PanelMetrics.listVerticalPadding) } - .listStyle(.inset) - .scrollContentBackground(.hidden) - .contextMenu(forSelectionType: String.self) { selection in - if let id = selection.first, - let item = viewModel.flatItems.first(where: { $0.id == id }) { - contextMenuActions(for: item) - } - } primaryAction: { selection in - guard let id = selection.first, - let item = viewModel.flatItems.first(where: { $0.id == id }) - else { return } - viewModel.selectedItemId = id - commit(item, intent: .open) - } + .frame(height: listHeight) .onChange(of: viewModel.selectedItemId) { _, newValue in if let id = newValue { - withAnimation(.easeInOut(duration: 0.15)) { - proxy.scrollTo(id, anchor: .center) - } + 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(.body) + .font(.system(size: 13)) .foregroundStyle(.secondary) - .frame(width: 18) + .frame(width: 16) Text(highlightedName(for: item)) .font(.body) .lineLimit(1) .truncationMode(.middle) - Spacer() + Spacer(minLength: 8) if item.isOpenInTab { Text(String(localized: "Open")) - .font(.caption2.weight(.medium)) + .font(.system(size: 10, weight: .medium)) .foregroundStyle(.secondary) .padding(.horizontal, 5) .padding(.vertical, 1) @@ -200,63 +215,36 @@ struct QuickSwitcherPanelView: View { if !item.subtitle.isEmpty { Text(item.subtitle) - .font(.caption) + .font(.subheadline) .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) + .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) } } - .frame(maxWidth: .infinity) - .padding(.vertical, 32) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.selectedItemId = item.id + commit(item, intent: .open) + } + .contextMenu { contextMenuActions(for: item) } + .id(item.id) } - private var footer: some View { - HStack(spacing: 16) { - shortcutHint(keys: "↑↓", label: String(localized: "Navigate")) - shortcutHint(keys: "↩", label: String(localized: "Open")) - shortcutHint(keys: "⌥↩", label: String(localized: "New Tab")) - Spacer() - shortcutHint(keys: "esc", label: String(localized: "Close")) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) + 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 @@ -286,26 +274,45 @@ struct QuickSwitcherPanelView: View { } } - private func copyToPasteboard(_ value: String) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) + 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 shortcutHint(keys: String, label: String) -> some View { - HStack(spacing: 5) { - Text(keys) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color(nsColor: .quaternarySystemFill)) - ) - Text(label) - .font(.caption) - .foregroundStyle(.secondary) + 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 { @@ -316,7 +323,6 @@ struct QuickSwitcherPanelView: View { 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) + } + } +} From 701f53ac3f1ee6b8734135897954512daecc0bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 08:42:07 +0700 Subject: [PATCH 07/15] refactor(toolbar): match quick switcher to real Spotlight anatomy and behavior --- CHANGELOG.md | 4 +- TablePro/Models/UI/QuickSwitcherItem.swift | 9 + TablePro/Resources/Localizable.xcstrings | 29 ++ .../ViewModels/QuickSwitcherViewModel.swift | 2 + .../QuickSwitcherPanelView.swift | 355 ++++++++++++------ .../QuickSwitcherViewModelTests.swift | 59 ++- 6 files changed, 327 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd8b7a888..b622fa408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Quick Switcher scopes: an empty search shows your recent items with round scope buttons beside the search bar; Cmd+1 to Cmd+4 (or the buttons) browse all tables, databases, or queries. - Option+Return in the Quick Switcher opens the table in a new tab; right-click a result to open its structure, copy the name, or copy the query. - Tables already open in a tab show an Open badge in the Quick Switcher, rank higher, and Return switches to the existing tab. - `.psql` and `.pgsql` files now open in the SQL editor like `.sql`: Finder double-click, the open and save panels, and linked SQL folders all accept them. (#1641) @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Quick Switcher now opens as a Spotlight-style floating panel over the window instead of a modal sheet: large borderless search field, rounded row selection that turns accent-colored while navigating with the keyboard, and an action hint on the selected row. On macOS 26 the panel uses Liquid Glass. - The sidebar filter, database switcher, and connection switcher now use the same fuzzy matching as the Quick Switcher, so abbreviations like `upv` find `user_profile_view`. - Refresh (Cmd+R) now acts only on the focused window's connection, instead of also reloading views and clearing autocomplete caches for every other open connection. - Holding Cmd+R no longer queues a backlog of refreshes that kept running after the key was released; refresh fires once per key press, and rapid presses collapse into a single reload. diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index dbfeab5fc..8a045973b 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -51,6 +51,15 @@ internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { case .queries: return String(localized: "Queries") } } + + var iconName: String { + switch self { + case .all: return "square.grid.2x2" + case .tables: return "tablecells" + case .containers: return "cylinder" + case .queries: return "doc.text" + } + } } /// A single item in the quick switcher results list diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 4dea37de2..ad0981588 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -6632,6 +6632,9 @@ } } } + }, + "All Types" : { + }, "Allow" : { "localizations" : { @@ -33000,6 +33003,12 @@ } } } + }, + "Key pattern" : { + + }, + "Key pattern, e.g. user:*" : { + }, "Key Prefix Root" : { "localizations" : { @@ -35092,6 +35101,7 @@ } }, "Loading..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39511,6 +39521,7 @@ } }, "No matching objects" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39669,6 +39680,7 @@ } }, "No objects found" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39691,6 +39703,7 @@ } }, "No objects match \"%@\"" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39867,6 +39880,9 @@ } } } + }, + "No results for \"%@\"" : { + }, "No rows" : { "localizations" : { @@ -41835,6 +41851,9 @@ } } } + }, + "Open in New Tab" : { + }, "Open in Window" : { "localizations" : { @@ -42082,6 +42101,9 @@ } } } + }, + "Open Structure" : { + }, "Open Table" : { "comment" : "A context menu option to open a table in the main view.", @@ -51815,6 +51837,9 @@ } } } + }, + "Saved Queries" : { + }, "Saved Query" : { "localizations" : { @@ -52508,8 +52533,12 @@ } } } + }, + "Search tables, views, databases, queries..." : { + }, "Search tables, views, databases..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 5dc2abdce..b94c5ac5f 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -269,6 +269,8 @@ internal final class QuickSwitcherViewModel { result.append(Group(id: "recent", header: String(localized: "Recent"), items: recent)) } + guard scope != .all else { return result } + for kind in QuickSwitcherItemKind.displayOrder { let items = scoped .filter { $0.kind == kind && !recentIdSet.contains($0.id) } diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index e21624b34..2c2acd53a 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -9,11 +9,14 @@ import SwiftUI private enum PanelMetrics { static let width: CGFloat = 640 static let inputRowHeight: CGFloat = 52 - static let rowHeight: CGFloat = 30 + static let rowHeight: CGFloat = 44 + static let rowSelectionInset: CGFloat = 8 + static let rowSelectionRadius: CGFloat = 12 + static let iconContainerSize: CGFloat = 26 static let sectionHeaderHeight: CGFloat = 34 - static let chipRowHeight: CGFloat = 40 - static let listVerticalPadding: CGFloat = 6 - static let maxVisibleRows = 12 + static let scopeButtonSize: CGFloat = 44 + static let listVerticalPadding: CGFloat = 8 + static let maxVisibleRows = 9 static var cornerRadius: CGFloat { if #available(macOS 26.0, *) { @@ -24,8 +27,6 @@ private enum PanelMetrics { } struct QuickSwitcherPanelView: View { - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - let schemaProvider: SQLSchemaProvider let connectionId: UUID let databaseType: DatabaseType @@ -34,7 +35,6 @@ struct QuickSwitcherPanelView: View { let onDismiss: () -> Void @State private var viewModel: QuickSwitcherViewModel - @State private var keyMonitor: Any? init( schemaProvider: SQLSchemaProvider, @@ -54,29 +54,11 @@ struct QuickSwitcherPanelView: View { } var body: some View { - VStack(spacing: 0) { - inputRow - - if showsContent { - Divider() - scopeChips - if viewModel.flatItems.isEmpty { - noResultsRow - } else { - resultsList - } - } + QuickSwitcherPanelContent(viewModel: viewModel) { item, intent in + viewModel.recordSelection(item) + onSelect(item, intent) + onDismiss() } - .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, @@ -84,68 +66,150 @@ struct QuickSwitcherPanelView: View { openTableNames: openTableNames ) } + } +} + +struct QuickSwitcherPanelContent: View { + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + @Bindable var viewModel: QuickSwitcherViewModel + let onCommit: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void + + @State private var isNavigating = false + @State private var keyMonitor: Any? + + var body: some View { + Group { + if showsResultSurface { + resultSurface + } else { + standaloneBar + } + } + .frame(width: PanelMetrics.width) + .onChange(of: viewModel.searchText) { _, _ in isNavigating = false } + .onChange(of: viewModel.scope) { _, _ in isNavigating = false } .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 + private var showsResultSurface: Bool { + !viewModel.flatItems.isEmpty || !trimmedQuery.isEmpty + } + + private var trimmedQuery: String { + viewModel.searchText.trimmingCharacters(in: .whitespaces) + } + + private var standaloneBar: some View { + HStack(spacing: 10) { + inputFields + .padding(.horizontal, 18) + .frame(height: PanelMetrics.inputRowHeight) + .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.inputRowHeight / 2)) + .clipShape(Capsule()) + .overlay(Capsule().strokeBorder(barStrokeColor, lineWidth: 0.5)) + + ForEach(QuickSwitcherScope.allCases.filter { $0 != .all }) { scope in + scopeButton(scope) + } + } + } + + private func scopeButton(_ scope: QuickSwitcherScope) -> some View { + Button { + viewModel.scope = scope + } label: { + Image(systemName: scope.iconName) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(viewModel.scope == scope ? Color.primary : Color.secondary) + .frame(width: PanelMetrics.scopeButtonSize, height: PanelMetrics.scopeButtonSize) + .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.scopeButtonSize / 2)) + .clipShape(Circle()) + .overlay(Circle().strokeBorder(barStrokeColor, lineWidth: 0.5)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help(scope.title) + } + + private var barStrokeColor: Color { + colorSchemeContrast == .increased + ? Color(nsColor: .separatorColor) + : Color(nsColor: .separatorColor).opacity(0.6) + } + + private var resultSurface: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + inputFields + if viewModel.scope != .all { + activeScopeBadge + } + } + .padding(.horizontal, 18) + .frame(height: PanelMetrics.inputRowHeight) + + Divider() + .padding(.horizontal, 10) + + if viewModel.flatItems.isEmpty { + noResultsRow + } else { + resultsList + } } - return true + .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 + ) + ) } - private var inputRow: some View { + private var inputFields: some View { HStack(spacing: 10) { Image(systemName: "magnifyingglass") - .font(.system(size: 20, weight: .medium)) + .font(.system(size: 19, 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) }, + onMoveUp: { + isNavigating = true + viewModel.moveSelection(by: -1) + }, + onMoveDown: { + isNavigating = true + 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 + private var activeScopeBadge: some View { + Button { + viewModel.scope = .all } 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()) + HStack(spacing: 4) { + Image(systemName: viewModel.scope.iconName) + .font(.system(size: 11, weight: .medium)) + Text(viewModel.scope.title) + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 9) + .frame(height: 24) + .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + .contentShape(Capsule()) } .buttonStyle(.plain) + .help(String(localized: "Show all results")) } private var resultsList: some View { @@ -185,65 +249,119 @@ struct QuickSwitcherPanelView: View { .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 20) + .padding(.leading, 24) .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) + let isSelected = item.id == viewModel.selectedItemId + let isEmphasized = isSelected && isNavigating + + return HStack(spacing: 12) { + iconView(for: item, isEmphasized: isEmphasized) Text(highlightedName(for: item)) - .font(.body) + .font(.system(size: 15)) + .foregroundStyle(isEmphasized ? Color.white : Color.primary) .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) - } + trailingAccessories(for: item, isSelected: isSelected, isEmphasized: isEmphasized) } - .padding(.horizontal, 14) + .padding(.horizontal, 18) .frame(height: PanelMetrics.rowHeight) .background { - if item.id == viewModel.selectedItemId { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(nsColor: .unemphasizedSelectedContentBackgroundColor)) - .padding(.horizontal, 6) + if isSelected { + RoundedRectangle(cornerRadius: PanelMetrics.rowSelectionRadius, style: .continuous) + .fill( + isEmphasized + ? Color(nsColor: .selectedContentBackgroundColor) + : Color(nsColor: .unemphasizedSelectedContentBackgroundColor) + ) + .padding(.horizontal, PanelMetrics.rowSelectionInset) } } .contentShape(Rectangle()) .onTapGesture { viewModel.selectedItemId = item.id - commit(item, intent: .open) + onCommit(item, .open) } .contextMenu { contextMenuActions(for: item) } .id(item.id) } + private func iconView(for item: QuickSwitcherItem, isEmphasized: Bool) -> some View { + Image(systemName: item.iconName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(isEmphasized ? Color.white : Color.secondary) + .frame(width: PanelMetrics.iconContainerSize, height: PanelMetrics.iconContainerSize) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + isEmphasized + ? Color.white.opacity(0.2) + : Color(nsColor: .quaternarySystemFill) + ) + ) + } + + @ViewBuilder + private func trailingAccessories(for item: QuickSwitcherItem, isSelected: Bool, isEmphasized: Bool) -> some View { + let secondaryColor = isEmphasized ? Color.white.opacity(0.85) : Color.secondary + + if item.isOpenInTab, !isSelected { + Text(String(localized: "Open")) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(secondaryColor) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + } + + if isSelected { + Text(commitHint(for: item)) + .font(.system(size: 12)) + .foregroundStyle(secondaryColor) + keycap("↩", isEmphasized: isEmphasized) + } else if !item.subtitle.isEmpty { + Text(item.subtitle) + .font(.system(size: 12)) + .foregroundStyle(secondaryColor) + .lineLimit(1) + } + } + + private func keycap(_ label: String, isEmphasized: Bool) -> some View { + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(isEmphasized ? Color.white : Color.secondary) + .frame(width: 24, height: 18) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isEmphasized ? Color.white.opacity(0.25) : Color(nsColor: .quaternarySystemFill)) + ) + } + + private func commitHint(for item: QuickSwitcherItem) -> String { + switch item.kind { + case .table, .view, .systemTable: + return item.isOpenInTab ? String(localized: "Switch to Tab") : String(localized: "Open") + case .database, .schema: + return String(localized: "Switch") + case .savedQuery, .queryHistory: + return String(localized: "Load Query") + } + } + private var noResultsRow: some View { Text(String(format: String(localized: "No results for \"%@\""), viewModel.searchText)) - .font(.body) + .font(.system(size: 15)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) + .padding(.horizontal, 24) .frame(height: PanelMetrics.rowHeight + PanelMetrics.listVerticalPadding * 2) } @@ -251,16 +369,16 @@ struct QuickSwitcherPanelView: View { private func contextMenuActions(for item: QuickSwitcherItem) -> some View { Button(String(localized: "Open")) { viewModel.selectedItemId = item.id - commit(item, intent: .open) + onCommit(item, .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) + onCommit(item, .openInNewWindowTab) } Button(String(localized: "Open Structure")) { viewModel.selectedItemId = item.id - commit(item, intent: .openStructure) + onCommit(item, .openStructure) } } Divider() @@ -290,9 +408,11 @@ struct QuickSwitcherPanelView: View { if modifiers == .control { switch characters { case "j", "n": + isNavigating = true viewModel.moveSelection(by: 1) return nil case "k", "p": + isNavigating = true viewModel.moveSelection(by: -1) return nil default: @@ -322,7 +442,7 @@ struct QuickSwitcherPanelView: View { for index in item.matchedIndices where index < characterIndices.count { let start = characterIndices[index] let end = attributed.characters.index(after: start) - attributed[start..= 2) #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) } From 142349c3c626c0faa2d2925d0ec737798925f0a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 08:50:17 +0700 Subject: [PATCH 08/15] fix(toolbar): single click selects a quick switcher row, double click opens it --- TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 2c2acd53a..fd68881f9 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -285,10 +285,14 @@ struct QuickSwitcherPanelContent: View { } } .contentShape(Rectangle()) - .onTapGesture { + .onTapGesture(count: 2) { viewModel.selectedItemId = item.id onCommit(item, .open) } + .onTapGesture { + isNavigating = true + viewModel.selectedItemId = item.id + } .contextMenu { contextMenuActions(for: item) } .id(item.id) } From ae5ebcd3c4096a1feee1e0cdfb1baa2673d73823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 08:53:51 +0700 Subject: [PATCH 09/15] fix(toolbar): remove single-click delay in quick switcher rows via clickCount --- TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index fd68881f9..2c93b3771 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -285,13 +285,12 @@ struct QuickSwitcherPanelContent: View { } } .contentShape(Rectangle()) - .onTapGesture(count: 2) { - viewModel.selectedItemId = item.id - onCommit(item, .open) - } .onTapGesture { isNavigating = true viewModel.selectedItemId = item.id + if NSApp.currentEvent?.clickCount == 2 { + onCommit(item, .open) + } } .contextMenu { contextMenuActions(for: item) } .id(item.id) From cbe451a9c57c45a04bf2832280178f3d71feda1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 09:04:34 +0700 Subject: [PATCH 10/15] refactor(toolbar): move quick switcher list to native List selection and scope bar --- CHANGELOG.md | 4 +- TablePro/Models/UI/QuickSwitcherItem.swift | 9 - TablePro/Resources/Localizable.xcstrings | 12 + .../QuickSwitcherPanelView.swift | 397 +++++------------- 4 files changed, 127 insertions(+), 295 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b622fa408..d5af887dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - BigQuery datasets can be switched from the toolbar, the Cmd+K switcher, and the File menu, including creating and dropping datasets. (#509) - Quick Switcher now searches saved queries alongside tables, views, databases, and history. -- Quick Switcher scopes: an empty search shows your recent items with round scope buttons beside the search bar; Cmd+1 to Cmd+4 (or the buttons) browse all tables, databases, or queries. +- Quick Switcher scopes: a scope bar under the search field filters to Tables, Databases, or Queries (Cmd+1 to Cmd+4). An empty search shows your recent items; picking a scope browses everything in it. - 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) @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Redis connections now filter with a key-pattern search field and a key-type scope instead of the SQL-style filter row. Patterns use glob syntax like `user:*`, are matched server-side across the whole keyspace, and the type scope narrows results by value type. The old filter row only matched one batch of keys and ignored any filter on Type, TTL, or Value. - Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509) - Quick Switcher highlights the matched characters in each result, finds better alignments for camelCase and snake_case names, and ranks items you open often and recently higher. -- Quick Switcher now opens as a Spotlight-style floating panel over the window instead of a modal sheet: large borderless search field, rounded row selection that turns accent-colored while navigating with the keyboard, and an action hint on the selected row. On macOS 26 the panel uses Liquid Glass. +- Quick Switcher now opens as a floating panel over the window instead of a modal sheet, with a large borderless search field and standard list 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. diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 8a045973b..dbfeab5fc 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -51,15 +51,6 @@ internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { case .queries: return String(localized: "Queries") } } - - var iconName: String { - switch self { - case .all: return "square.grid.2x2" - case .tables: return "tablecells" - case .containers: return "cylinder" - case .queries: return "doc.text" - } - } } /// A single item in the quick switcher results list diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ad0981588..a785be35a 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -34738,6 +34738,9 @@ } } } + }, + "Load Query" : { + }, "Load Table Template" : { "extractionState" : "stale", @@ -54340,6 +54343,9 @@ } } } + }, + "Show all results" : { + }, "Show All Rows" : { "localizations" : { @@ -57983,6 +57989,9 @@ } } } + }, + "Switch" : { + }, "switch %@" : { @@ -58284,6 +58293,9 @@ } } } + }, + "Switch to Tab" : { + }, "Switch to this database before executing" : { "localizations" : { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 2c93b3771..9a64cfca9 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -9,14 +9,10 @@ import SwiftUI private enum PanelMetrics { static let width: CGFloat = 640 static let inputRowHeight: CGFloat = 52 - static let rowHeight: CGFloat = 44 - static let rowSelectionInset: CGFloat = 8 - static let rowSelectionRadius: CGFloat = 12 - static let iconContainerSize: CGFloat = 26 - static let sectionHeaderHeight: CGFloat = 34 - static let scopeButtonSize: CGFloat = 44 - static let listVerticalPadding: CGFloat = 8 - static let maxVisibleRows = 9 + static let rowHeight: CGFloat = 30 + static let sectionHeaderHeight: CGFloat = 28 + static let noResultsHeight: CGFloat = 140 + static let maxVisibleRows = 12 static var cornerRadius: CGFloat { if #available(macOS 26.0, *) { @@ -75,90 +71,21 @@ struct QuickSwitcherPanelContent: View { @Bindable var viewModel: QuickSwitcherViewModel let onCommit: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void - @State private var isNavigating = false - @State private var keyMonitor: Any? - var body: some View { - Group { - if showsResultSurface { - resultSurface - } else { - standaloneBar - } - } - .frame(width: PanelMetrics.width) - .onChange(of: viewModel.searchText) { _, _ in isNavigating = false } - .onChange(of: viewModel.scope) { _, _ in isNavigating = false } - .onAppear { installKeyMonitor() } - .onDisappear { removeKeyMonitor() } - } - - private var showsResultSurface: Bool { - !viewModel.flatItems.isEmpty || !trimmedQuery.isEmpty - } - - private var trimmedQuery: String { - viewModel.searchText.trimmingCharacters(in: .whitespaces) - } - - private var standaloneBar: some View { - HStack(spacing: 10) { - inputFields - .padding(.horizontal, 18) - .frame(height: PanelMetrics.inputRowHeight) - .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.inputRowHeight / 2)) - .clipShape(Capsule()) - .overlay(Capsule().strokeBorder(barStrokeColor, lineWidth: 0.5)) - - ForEach(QuickSwitcherScope.allCases.filter { $0 != .all }) { scope in - scopeButton(scope) - } - } - } - - private func scopeButton(_ scope: QuickSwitcherScope) -> some View { - Button { - viewModel.scope = scope - } label: { - Image(systemName: scope.iconName) - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(viewModel.scope == scope ? Color.primary : Color.secondary) - .frame(width: PanelMetrics.scopeButtonSize, height: PanelMetrics.scopeButtonSize) - .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.scopeButtonSize / 2)) - .clipShape(Circle()) - .overlay(Circle().strokeBorder(barStrokeColor, lineWidth: 0.5)) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .help(scope.title) - } - - private var barStrokeColor: Color { - colorSchemeContrast == .increased - ? Color(nsColor: .separatorColor) - : Color(nsColor: .separatorColor).opacity(0.6) - } - - private var resultSurface: some View { VStack(spacing: 0) { - HStack(spacing: 10) { - inputFields - if viewModel.scope != .all { - activeScopeBadge - } - } - .padding(.horizontal, 18) - .frame(height: PanelMetrics.inputRowHeight) - - Divider() - .padding(.horizontal, 10) - - if viewModel.flatItems.isEmpty { - noResultsRow - } else { - resultsList + inputRow + scopeBar + + if !viewModel.flatItems.isEmpty { + Divider() + itemList + } else if !trimmedQuery.isEmpty { + Divider() + ContentUnavailableView.search(text: trimmedQuery) + .frame(height: PanelMetrics.noResultsHeight) } } + .frame(width: PanelMetrics.width) .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.cornerRadius)) .clipShape(RoundedRectangle(cornerRadius: PanelMetrics.cornerRadius, style: .continuous)) .overlay( @@ -168,9 +95,23 @@ struct QuickSwitcherPanelContent: View { lineWidth: 1 ) ) + .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 inputFields: some View { + private var trimmedQuery: String { + viewModel.searchText.trimmingCharacters(in: .whitespaces) + } + + private var inputRow: some View { HStack(spacing: 10) { Image(systemName: "magnifyingglass") .font(.system(size: 19, weight: .medium)) @@ -179,208 +120,134 @@ struct QuickSwitcherPanelContent: View { QuickSwitcherSearchField( text: $viewModel.searchText, placeholder: String(localized: "Search tables, views, databases, queries..."), - onMoveUp: { - isNavigating = true - viewModel.moveSelection(by: -1) - }, - onMoveDown: { - isNavigating = true - viewModel.moveSelection(by: 1) - }, + onMoveUp: { viewModel.moveSelection(by: -1) }, + onMoveDown: { viewModel.moveSelection(by: 1) }, onSubmit: { openSelectedItem() } ) } + .padding(.horizontal, 16) + .frame(height: PanelMetrics.inputRowHeight) } - private var activeScopeBadge: some View { - Button { - viewModel.scope = .all - } label: { - HStack(spacing: 4) { - Image(systemName: viewModel.scope.iconName) - .font(.system(size: 11, weight: .medium)) - Text(viewModel.scope.title) - .font(.system(size: 12, weight: .medium)) + private var scopeBar: some View { + HStack(spacing: 6) { + ForEach(Array(QuickSwitcherScope.allCases.enumerated()), id: \.element) { index, scope in + Toggle(scope.title, isOn: scopeBinding(for: scope)) + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) } - .foregroundStyle(.secondary) - .padding(.horizontal, 9) - .frame(height: 24) - .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) - .contentShape(Capsule()) + Spacer() } - .buttonStyle(.plain) - .help(String(localized: "Show all results")) + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + + private func scopeBinding(for scope: QuickSwitcherScope) -> Binding { + Binding( + get: { viewModel.scope == scope }, + set: { isOn in + viewModel.scope = isOn ? scope : .all + } + ) } - private var resultsList: some View { + private var itemList: some View { ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - ForEach(viewModel.groups) { group in - if let header = group.header { - sectionHeader(header) + 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) } } } - .padding(.vertical, PanelMetrics.listVerticalPadding) } - .frame(height: listHeight) + .listStyle(.inset) + .scrollContentBackground(.hidden) + .frame(height: viewModel.listHeight( + rowHeight: PanelMetrics.rowHeight, + headerHeight: PanelMetrics.sectionHeaderHeight, + maxVisibleRows: PanelMetrics.maxVisibleRows + )) + .contextMenu(forSelectionType: String.self) { selection in + if let id = selection.first, + let item = viewModel.flatItems.first(where: { $0.id == id }) { + contextMenuActions(for: item) + } + } primaryAction: { selection in + guard let id = selection.first, + let item = viewModel.flatItems.first(where: { $0.id == id }) + else { return } + viewModel.selectedItemId = id + onCommit(item, .open) + } .onChange(of: viewModel.selectedItemId) { _, newValue in if let id = newValue { - proxy.scrollTo(id) + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(id, anchor: .center) + } } } } } - 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, 24) - .padding(.top, 18) - .padding(.bottom, 4) - } - private func itemRow(_ item: QuickSwitcherItem) -> some View { - let isSelected = item.id == viewModel.selectedItemId - let isEmphasized = isSelected && isNavigating - - return HStack(spacing: 12) { - iconView(for: item, isEmphasized: isEmphasized) + HStack(spacing: 10) { + Image(systemName: item.iconName) + .font(.body) + .foregroundStyle(.secondary) + .frame(width: 18) Text(highlightedName(for: item)) - .font(.system(size: 15)) - .foregroundStyle(isEmphasized ? Color.white : Color.primary) + .font(.body) .lineLimit(1) .truncationMode(.middle) - Spacer(minLength: 8) + Spacer() - trailingAccessories(for: item, isSelected: isSelected, isEmphasized: isEmphasized) - } - .padding(.horizontal, 18) - .frame(height: PanelMetrics.rowHeight) - .background { - if isSelected { - RoundedRectangle(cornerRadius: PanelMetrics.rowSelectionRadius, style: .continuous) - .fill( - isEmphasized - ? Color(nsColor: .selectedContentBackgroundColor) - : Color(nsColor: .unemphasizedSelectedContentBackgroundColor) - ) - .padding(.horizontal, PanelMetrics.rowSelectionInset) + if item.isOpenInTab { + Text(String(localized: "Open")) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) } - } - .contentShape(Rectangle()) - .onTapGesture { - isNavigating = true - viewModel.selectedItemId = item.id - if NSApp.currentEvent?.clickCount == 2 { - onCommit(item, .open) + + if !item.subtitle.isEmpty { + Text(item.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } } - .contextMenu { contextMenuActions(for: item) } + .frame(height: PanelMetrics.rowHeight) + .contentShape(Rectangle()) + .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) + .listRowSeparator(.hidden) .id(item.id) - } - - private func iconView(for item: QuickSwitcherItem, isEmphasized: Bool) -> some View { - Image(systemName: item.iconName) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(isEmphasized ? Color.white : Color.secondary) - .frame(width: PanelMetrics.iconContainerSize, height: PanelMetrics.iconContainerSize) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill( - isEmphasized - ? Color.white.opacity(0.2) - : Color(nsColor: .quaternarySystemFill) - ) - ) - } - - @ViewBuilder - private func trailingAccessories(for item: QuickSwitcherItem, isSelected: Bool, isEmphasized: Bool) -> some View { - let secondaryColor = isEmphasized ? Color.white.opacity(0.85) : Color.secondary - - if item.isOpenInTab, !isSelected { - Text(String(localized: "Open")) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(secondaryColor) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) - } - - if isSelected { - Text(commitHint(for: item)) - .font(.system(size: 12)) - .foregroundStyle(secondaryColor) - keycap("↩", isEmphasized: isEmphasized) - } else if !item.subtitle.isEmpty { - Text(item.subtitle) - .font(.system(size: 12)) - .foregroundStyle(secondaryColor) - .lineLimit(1) - } - } - - private func keycap(_ label: String, isEmphasized: Bool) -> some View { - Text(label) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(isEmphasized ? Color.white : Color.secondary) - .frame(width: 24, height: 18) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(isEmphasized ? Color.white.opacity(0.25) : Color(nsColor: .quaternarySystemFill)) - ) - } - - private func commitHint(for item: QuickSwitcherItem) -> String { - switch item.kind { - case .table, .view, .systemTable: - return item.isOpenInTab ? String(localized: "Switch to Tab") : String(localized: "Open") - case .database, .schema: - return String(localized: "Switch") - case .savedQuery, .queryHistory: - return String(localized: "Load Query") - } - } - - private var noResultsRow: some View { - Text(String(format: String(localized: "No results for \"%@\""), viewModel.searchText)) - .font(.system(size: 15)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .frame(height: PanelMetrics.rowHeight + PanelMetrics.listVerticalPadding * 2) + .tag(item.id) } @ViewBuilder private func contextMenuActions(for item: QuickSwitcherItem) -> some View { Button(String(localized: "Open")) { - viewModel.selectedItemId = item.id onCommit(item, .open) } if item.kind == .table || item.kind == .view || item.kind == .systemTable { Button(String(localized: "Open in New Tab")) { - viewModel.selectedItemId = item.id onCommit(item, .openInNewWindowTab) } Button(String(localized: "Open Structure")) { - viewModel.selectedItemId = item.id onCommit(item, .openStructure) } } @@ -395,44 +262,6 @@ struct QuickSwitcherPanelContent: View { } } - 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": - isNavigating = true - viewModel.moveSelection(by: 1) - return nil - case "k", "p": - isNavigating = true - 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) @@ -445,7 +274,7 @@ struct QuickSwitcherPanelContent: View { for index in item.matchedIndices where index < characterIndices.count { let start = characterIndices[index] let end = attributed.characters.index(after: start) - attributed[start.. Date: Fri, 12 Jun 2026 09:07:57 +0700 Subject: [PATCH 11/15] chore(toolbar): sync string catalog for quick switcher strings --- TablePro/Resources/Localizable.xcstrings | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index a785be35a..98a656216 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -34738,9 +34738,6 @@ } } } - }, - "Load Query" : { - }, "Load Table Template" : { "extractionState" : "stale", @@ -39883,9 +39880,6 @@ } } } - }, - "No results for \"%@\"" : { - }, "No rows" : { "localizations" : { @@ -54343,9 +54337,6 @@ } } } - }, - "Show all results" : { - }, "Show All Rows" : { "localizations" : { @@ -57989,9 +57980,6 @@ } } } - }, - "Switch" : { - }, "switch %@" : { @@ -58293,9 +58281,6 @@ } } } - }, - "Switch to Tab" : { - }, "Switch to this database before executing" : { "localizations" : { From 999709f5abdcd901b07bf50a289a621d789bbaea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 09:07:57 +0700 Subject: [PATCH 12/15] Revert "refactor(toolbar): move quick switcher list to native List selection and scope bar" This reverts commit cbe451a9c57c45a04bf2832280178f3d71feda1c. --- CHANGELOG.md | 4 +- TablePro/Models/UI/QuickSwitcherItem.swift | 9 + .../QuickSwitcherPanelView.swift | 397 +++++++++++++----- 3 files changed, 295 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5af887dd..b622fa408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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: a scope bar under the search field filters to Tables, Databases, or Queries (Cmd+1 to Cmd+4). An empty search shows your recent items; picking a scope browses everything in it. +- Quick Switcher scopes: an empty search shows your recent items with round scope buttons beside the search bar; Cmd+1 to Cmd+4 (or the buttons) browse all tables, databases, or queries. - Option+Return in the Quick Switcher opens the table in a new tab; right-click a result to open its structure, copy the name, or copy the query. - Tables already open in a tab show an Open badge in the Quick Switcher, rank higher, and Return switches to the existing tab. - `.psql` and `.pgsql` files now open in the SQL editor like `.sql`: Finder double-click, the open and save panels, and linked SQL folders all accept them. (#1641) @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 floating panel over the window instead of a modal sheet, with a large borderless search field and standard list selection. On macOS 26 the panel uses Liquid Glass. +- Quick Switcher now opens as a Spotlight-style floating panel over the window instead of a modal sheet: large borderless search field, rounded row selection that turns accent-colored while navigating with the keyboard, and an action hint on the selected row. On macOS 26 the panel uses Liquid Glass. - The sidebar filter, database switcher, and connection switcher now use the same fuzzy matching as the Quick Switcher, so abbreviations like `upv` find `user_profile_view`. - Refresh (Cmd+R) now acts only on the focused window's connection, instead of also reloading views and clearing autocomplete caches for every other open connection. - Holding Cmd+R no longer queues a backlog of refreshes that kept running after the key was released; refresh fires once per key press, and rapid presses collapse into a single reload. diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index dbfeab5fc..8a045973b 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -51,6 +51,15 @@ internal enum QuickSwitcherScope: String, CaseIterable, Identifiable, Sendable { case .queries: return String(localized: "Queries") } } + + var iconName: String { + switch self { + case .all: return "square.grid.2x2" + case .tables: return "tablecells" + case .containers: return "cylinder" + case .queries: return "doc.text" + } + } } /// A single item in the quick switcher results list diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 9a64cfca9..2c93b3771 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -9,10 +9,14 @@ import SwiftUI private enum PanelMetrics { static let width: CGFloat = 640 static let inputRowHeight: CGFloat = 52 - static let rowHeight: CGFloat = 30 - static let sectionHeaderHeight: CGFloat = 28 - static let noResultsHeight: CGFloat = 140 - static let maxVisibleRows = 12 + static let rowHeight: CGFloat = 44 + static let rowSelectionInset: CGFloat = 8 + static let rowSelectionRadius: CGFloat = 12 + static let iconContainerSize: CGFloat = 26 + static let sectionHeaderHeight: CGFloat = 34 + static let scopeButtonSize: CGFloat = 44 + static let listVerticalPadding: CGFloat = 8 + static let maxVisibleRows = 9 static var cornerRadius: CGFloat { if #available(macOS 26.0, *) { @@ -71,21 +75,90 @@ struct QuickSwitcherPanelContent: View { @Bindable var viewModel: QuickSwitcherViewModel let onCommit: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void + @State private var isNavigating = false + @State private var keyMonitor: Any? + var body: some View { - VStack(spacing: 0) { - inputRow - scopeBar - - if !viewModel.flatItems.isEmpty { - Divider() - itemList - } else if !trimmedQuery.isEmpty { - Divider() - ContentUnavailableView.search(text: trimmedQuery) - .frame(height: PanelMetrics.noResultsHeight) + Group { + if showsResultSurface { + resultSurface + } else { + standaloneBar } } .frame(width: PanelMetrics.width) + .onChange(of: viewModel.searchText) { _, _ in isNavigating = false } + .onChange(of: viewModel.scope) { _, _ in isNavigating = false } + .onAppear { installKeyMonitor() } + .onDisappear { removeKeyMonitor() } + } + + private var showsResultSurface: Bool { + !viewModel.flatItems.isEmpty || !trimmedQuery.isEmpty + } + + private var trimmedQuery: String { + viewModel.searchText.trimmingCharacters(in: .whitespaces) + } + + private var standaloneBar: some View { + HStack(spacing: 10) { + inputFields + .padding(.horizontal, 18) + .frame(height: PanelMetrics.inputRowHeight) + .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.inputRowHeight / 2)) + .clipShape(Capsule()) + .overlay(Capsule().strokeBorder(barStrokeColor, lineWidth: 0.5)) + + ForEach(QuickSwitcherScope.allCases.filter { $0 != .all }) { scope in + scopeButton(scope) + } + } + } + + private func scopeButton(_ scope: QuickSwitcherScope) -> some View { + Button { + viewModel.scope = scope + } label: { + Image(systemName: scope.iconName) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(viewModel.scope == scope ? Color.primary : Color.secondary) + .frame(width: PanelMetrics.scopeButtonSize, height: PanelMetrics.scopeButtonSize) + .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.scopeButtonSize / 2)) + .clipShape(Circle()) + .overlay(Circle().strokeBorder(barStrokeColor, lineWidth: 0.5)) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help(scope.title) + } + + private var barStrokeColor: Color { + colorSchemeContrast == .increased + ? Color(nsColor: .separatorColor) + : Color(nsColor: .separatorColor).opacity(0.6) + } + + private var resultSurface: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + inputFields + if viewModel.scope != .all { + activeScopeBadge + } + } + .padding(.horizontal, 18) + .frame(height: PanelMetrics.inputRowHeight) + + Divider() + .padding(.horizontal, 10) + + if viewModel.flatItems.isEmpty { + noResultsRow + } else { + resultsList + } + } .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.cornerRadius)) .clipShape(RoundedRectangle(cornerRadius: PanelMetrics.cornerRadius, style: .continuous)) .overlay( @@ -95,23 +168,9 @@ struct QuickSwitcherPanelContent: View { lineWidth: 1 ) ) - .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 trimmedQuery: String { - viewModel.searchText.trimmingCharacters(in: .whitespaces) - } - - private var inputRow: some View { + private var inputFields: some View { HStack(spacing: 10) { Image(systemName: "magnifyingglass") .font(.system(size: 19, weight: .medium)) @@ -120,134 +179,208 @@ struct QuickSwitcherPanelContent: View { QuickSwitcherSearchField( text: $viewModel.searchText, placeholder: String(localized: "Search tables, views, databases, queries..."), - onMoveUp: { viewModel.moveSelection(by: -1) }, - onMoveDown: { viewModel.moveSelection(by: 1) }, + onMoveUp: { + isNavigating = true + viewModel.moveSelection(by: -1) + }, + onMoveDown: { + isNavigating = true + viewModel.moveSelection(by: 1) + }, onSubmit: { openSelectedItem() } ) } - .padding(.horizontal, 16) - .frame(height: PanelMetrics.inputRowHeight) } - private var scopeBar: some View { - HStack(spacing: 6) { - ForEach(Array(QuickSwitcherScope.allCases.enumerated()), id: \.element) { index, scope in - Toggle(scope.title, isOn: scopeBinding(for: scope)) - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) + private var activeScopeBadge: some View { + Button { + viewModel.scope = .all + } label: { + HStack(spacing: 4) { + Image(systemName: viewModel.scope.iconName) + .font(.system(size: 11, weight: .medium)) + Text(viewModel.scope.title) + .font(.system(size: 12, weight: .medium)) } - Spacer() + .foregroundStyle(.secondary) + .padding(.horizontal, 9) + .frame(height: 24) + .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + .contentShape(Capsule()) } - .padding(.horizontal, 14) - .padding(.bottom, 8) - } - - private func scopeBinding(for scope: QuickSwitcherScope) -> Binding { - Binding( - get: { viewModel.scope == scope }, - set: { isOn in - viewModel.scope = isOn ? scope : .all - } - ) + .buttonStyle(.plain) + .help(String(localized: "Show all results")) } - private var itemList: some View { + private var resultsList: 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) + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewModel.groups) { group in + if let header = group.header { + sectionHeader(header) } - } else { ForEach(group.items) { item in itemRow(item) } } } + .padding(.vertical, PanelMetrics.listVerticalPadding) } - .listStyle(.inset) - .scrollContentBackground(.hidden) - .frame(height: viewModel.listHeight( - rowHeight: PanelMetrics.rowHeight, - headerHeight: PanelMetrics.sectionHeaderHeight, - maxVisibleRows: PanelMetrics.maxVisibleRows - )) - .contextMenu(forSelectionType: String.self) { selection in - if let id = selection.first, - let item = viewModel.flatItems.first(where: { $0.id == id }) { - contextMenuActions(for: item) - } - } primaryAction: { selection in - guard let id = selection.first, - let item = viewModel.flatItems.first(where: { $0.id == id }) - else { return } - viewModel.selectedItemId = id - onCommit(item, .open) - } + .frame(height: listHeight) .onChange(of: viewModel.selectedItemId) { _, newValue in if let id = newValue { - withAnimation(.easeInOut(duration: 0.15)) { - proxy.scrollTo(id, anchor: .center) - } + 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, 24) + .padding(.top, 18) + .padding(.bottom, 4) + } + private func itemRow(_ item: QuickSwitcherItem) -> some View { - HStack(spacing: 10) { - Image(systemName: item.iconName) - .font(.body) - .foregroundStyle(.secondary) - .frame(width: 18) + let isSelected = item.id == viewModel.selectedItemId + let isEmphasized = isSelected && isNavigating + + return HStack(spacing: 12) { + iconView(for: item, isEmphasized: isEmphasized) Text(highlightedName(for: item)) - .font(.body) + .font(.system(size: 15)) + .foregroundStyle(isEmphasized ? Color.white : Color.primary) .lineLimit(1) .truncationMode(.middle) - Spacer() + Spacer(minLength: 8) - if item.isOpenInTab { - Text(String(localized: "Open")) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) - } - - if !item.subtitle.isEmpty { - Text(item.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + trailingAccessories(for: item, isSelected: isSelected, isEmphasized: isEmphasized) } + .padding(.horizontal, 18) .frame(height: PanelMetrics.rowHeight) + .background { + if isSelected { + RoundedRectangle(cornerRadius: PanelMetrics.rowSelectionRadius, style: .continuous) + .fill( + isEmphasized + ? Color(nsColor: .selectedContentBackgroundColor) + : Color(nsColor: .unemphasizedSelectedContentBackgroundColor) + ) + .padding(.horizontal, PanelMetrics.rowSelectionInset) + } + } .contentShape(Rectangle()) - .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) - .listRowSeparator(.hidden) + .onTapGesture { + isNavigating = true + viewModel.selectedItemId = item.id + if NSApp.currentEvent?.clickCount == 2 { + onCommit(item, .open) + } + } + .contextMenu { contextMenuActions(for: item) } .id(item.id) - .tag(item.id) + } + + private func iconView(for item: QuickSwitcherItem, isEmphasized: Bool) -> some View { + Image(systemName: item.iconName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(isEmphasized ? Color.white : Color.secondary) + .frame(width: PanelMetrics.iconContainerSize, height: PanelMetrics.iconContainerSize) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + isEmphasized + ? Color.white.opacity(0.2) + : Color(nsColor: .quaternarySystemFill) + ) + ) + } + + @ViewBuilder + private func trailingAccessories(for item: QuickSwitcherItem, isSelected: Bool, isEmphasized: Bool) -> some View { + let secondaryColor = isEmphasized ? Color.white.opacity(0.85) : Color.secondary + + if item.isOpenInTab, !isSelected { + Text(String(localized: "Open")) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(secondaryColor) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color(nsColor: .quaternarySystemFill))) + } + + if isSelected { + Text(commitHint(for: item)) + .font(.system(size: 12)) + .foregroundStyle(secondaryColor) + keycap("↩", isEmphasized: isEmphasized) + } else if !item.subtitle.isEmpty { + Text(item.subtitle) + .font(.system(size: 12)) + .foregroundStyle(secondaryColor) + .lineLimit(1) + } + } + + private func keycap(_ label: String, isEmphasized: Bool) -> some View { + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(isEmphasized ? Color.white : Color.secondary) + .frame(width: 24, height: 18) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isEmphasized ? Color.white.opacity(0.25) : Color(nsColor: .quaternarySystemFill)) + ) + } + + private func commitHint(for item: QuickSwitcherItem) -> String { + switch item.kind { + case .table, .view, .systemTable: + return item.isOpenInTab ? String(localized: "Switch to Tab") : String(localized: "Open") + case .database, .schema: + return String(localized: "Switch") + case .savedQuery, .queryHistory: + return String(localized: "Load Query") + } + } + + private var noResultsRow: some View { + Text(String(format: String(localized: "No results for \"%@\""), viewModel.searchText)) + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .frame(height: PanelMetrics.rowHeight + PanelMetrics.listVerticalPadding * 2) } @ViewBuilder private func contextMenuActions(for item: QuickSwitcherItem) -> some View { Button(String(localized: "Open")) { + viewModel.selectedItemId = item.id onCommit(item, .open) } if item.kind == .table || item.kind == .view || item.kind == .systemTable { Button(String(localized: "Open in New Tab")) { + viewModel.selectedItemId = item.id onCommit(item, .openInNewWindowTab) } Button(String(localized: "Open Structure")) { + viewModel.selectedItemId = item.id onCommit(item, .openStructure) } } @@ -262,6 +395,44 @@ struct QuickSwitcherPanelContent: View { } } + 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": + isNavigating = true + viewModel.moveSelection(by: 1) + return nil + case "k", "p": + isNavigating = true + 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) @@ -274,7 +445,7 @@ struct QuickSwitcherPanelContent: View { for index in item.matchedIndices where index < characterIndices.count { let start = characterIndices[index] let end = attributed.characters.index(after: start) - attributed[start.. Date: Fri, 12 Jun 2026 09:20:18 +0700 Subject: [PATCH 13/15] fix(coordinator): load queries into the empty window instead of opening a second window tab --- CHANGELOG.md | 1 + TablePro/Views/Main/MainContentCoordinator.swift | 2 ++ TableProTests/Views/Main/CoordinatorEditorLoadTests.swift | 7 ++++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b622fa408..b9dd833b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ 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. +- Loading a saved query or history entry from the no-tabs screen now opens it in the current window instead of creating a second window tab. - Opening a query from history in the Quick Switcher loads the full query instead of a 100-character preview. - Refreshing a table now reloads its data even when the previous load is still running; before, the refresh was silently dropped and the grid kept stale rows. (#1637) - Cmd+R on a table now reloads its rows instead of failing with a query error; the refresh was sending the database a stray cancel that aborted its own freshly-issued reload. diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index cf522105d..5ad22686f 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -930,6 +930,8 @@ final class MainContentCoordinator { $0.content.query = query $0.hasUserInteraction = true } + } else if tabManager.tabs.isEmpty { + tabManager.addTab(initialQuery: query, databaseName: activeDatabaseName) } else { let payload = EditorTabPayload( connectionId: connection.id, diff --git a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift index 7b11cd134..c7c1e41f1 100644 --- a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift +++ b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift @@ -78,7 +78,7 @@ struct CoordinatorEditorLoadTests { #expect(tabManager.tabs[0].content.query == originalQuery) } - @Test("loadQueryIntoEditor does nothing when no tabs exist") + @Test("loadQueryIntoEditor adds a tab in place when no tabs exist") @MainActor func loadQueryNoTabs() { let (coordinator, tabManager) = makeCoordinator() @@ -86,10 +86,11 @@ struct CoordinatorEditorLoadTests { #expect(tabManager.tabs.isEmpty) - // Falls through to WindowOpener path; no crash coordinator.loadQueryIntoEditor("SELECT 1") - #expect(tabManager.tabs.isEmpty) + #expect(tabManager.tabs.count == 1) + #expect(tabManager.tabs[0].tabType == .query) + #expect(tabManager.tabs[0].content.query == "SELECT 1") } // MARK: - insertQueryFromAI From c678440c0fb2b7dc9b497c534bb9d46b28e35e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 09:28:42 +0700 Subject: [PATCH 14/15] refactor(toolbar): apply review fixes to quick switcher field coordinator and schema load --- TablePro/ViewModels/QuickSwitcherViewModel.swift | 3 ++- TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index b94c5ac5f..90b0b8d87 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -90,7 +90,8 @@ internal final class QuickSwitcherViewModel { if await !schemaProvider.isSchemaLoaded(), let driver = services.databaseManager.driver(for: connectionId) { - await schemaProvider.loadSchema(using: driver) + let connection = services.databaseManager.session(for: connectionId)?.connection + await schemaProvider.loadSchema(using: driver, connection: connection) } let tables = await schemaProvider.getTables() diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift index 8bdf1ed4a..a30890acb 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherSearchField.swift @@ -31,6 +31,7 @@ internal struct QuickSwitcherSearchField: NSViewRepresentable { } func updateNSView(_ nsView: QuickSwitcherTextField, context: Context) { + context.coordinator.parent = self if nsView.stringValue != text { nsView.stringValue = text } @@ -42,7 +43,7 @@ internal struct QuickSwitcherSearchField: NSViewRepresentable { @MainActor final class Coordinator: NSObject, NSTextFieldDelegate { - private let parent: QuickSwitcherSearchField + fileprivate var parent: QuickSwitcherSearchField init(_ parent: QuickSwitcherSearchField) { self.parent = parent From ae6193f5f14cbd9cfe4a258c8f15aa7901cc61da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 09:35:51 +0700 Subject: [PATCH 15/15] fix(toolbar): keep search field identity stable so typing is not interrupted --- .../QuickSwitcher/QuickSwitcherPanel.swift | 10 +- .../QuickSwitcherPanelView.swift | 92 +++++++++---------- 2 files changed, 54 insertions(+), 48 deletions(-) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index 5d99d72a2..87e55a86d 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -120,5 +120,13 @@ internal struct QuickSwitcherPanelBackground: NSViewRepresentable { return effectView } - func updateNSView(_ nsView: NSView, context: Context) {} + func updateNSView(_ nsView: NSView, context: Context) { + if #available(macOS 26.0, *), let glassView = nsView as? NSGlassEffectView { + glassView.cornerRadius = cornerRadius + return + } + if let effectView = nsView as? NSVisualEffectView { + effectView.layer?.cornerRadius = cornerRadius + } + } } diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 2c93b3771..d0858a128 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -79,11 +79,13 @@ struct QuickSwitcherPanelContent: View { @State private var keyMonitor: Any? var body: some View { - Group { - if showsResultSurface { - resultSurface - } else { - standaloneBar + HStack(spacing: 10) { + mainSurface + + if !showsResultSurface { + ForEach(QuickSwitcherScope.allCases.filter { $0 != .all }) { scope in + scopeButton(scope) + } } } .frame(width: PanelMetrics.width) @@ -101,19 +103,46 @@ struct QuickSwitcherPanelContent: View { viewModel.searchText.trimmingCharacters(in: .whitespaces) } - private var standaloneBar: some View { - HStack(spacing: 10) { - inputFields - .padding(.horizontal, 18) - .frame(height: PanelMetrics.inputRowHeight) - .background(QuickSwitcherPanelBackground(cornerRadius: PanelMetrics.inputRowHeight / 2)) - .clipShape(Capsule()) - .overlay(Capsule().strokeBorder(barStrokeColor, lineWidth: 0.5)) - - ForEach(QuickSwitcherScope.allCases.filter { $0 != .all }) { scope in - scopeButton(scope) + private var surfaceCornerRadius: CGFloat { + showsResultSurface ? PanelMetrics.cornerRadius : PanelMetrics.inputRowHeight / 2 + } + + private var surfaceStrokeColor: Color { + if colorSchemeContrast == .increased { + return Color(nsColor: .separatorColor) + } + return showsResultSurface ? .clear : barStrokeColor + } + + private var mainSurface: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + inputFields + if viewModel.scope != .all { + activeScopeBadge + } + } + .padding(.horizontal, 18) + .frame(height: PanelMetrics.inputRowHeight) + + if showsResultSurface { + Divider() + .padding(.horizontal, 10) + + if viewModel.flatItems.isEmpty { + noResultsRow + } else { + resultsList + } } } + .frame(maxWidth: .infinity) + .background(QuickSwitcherPanelBackground(cornerRadius: surfaceCornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: surfaceCornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: surfaceCornerRadius, style: .continuous) + .strokeBorder(surfaceStrokeColor, lineWidth: showsResultSurface ? 1 : 0.5) + ) } private func scopeButton(_ scope: QuickSwitcherScope) -> some View { @@ -139,37 +168,6 @@ struct QuickSwitcherPanelContent: View { : Color(nsColor: .separatorColor).opacity(0.6) } - private var resultSurface: some View { - VStack(spacing: 0) { - HStack(spacing: 10) { - inputFields - if viewModel.scope != .all { - activeScopeBadge - } - } - .padding(.horizontal, 18) - .frame(height: PanelMetrics.inputRowHeight) - - Divider() - .padding(.horizontal, 10) - - if viewModel.flatItems.isEmpty { - noResultsRow - } else { - resultsList - } - } - .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 - ) - ) - } - private var inputFields: some View { HStack(spacing: 10) { Image(systemName: "magnifyingglass")