From 66ebe7e562bc554d96451043e5225e7fa20f1e1f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 23:52:00 +0700 Subject: [PATCH 1/4] feat: add Quick Switcher with native SwiftUI sheet pattern Implements Quick Switcher (Cmd+P) for fast navigation to tables, views, databases, schemas, and query history using fuzzy search. Uses the existing ActiveSheet enum + .sheet(item:) pattern matching other dialogs. Also fixes missing .refreshData notification for MySQL/MariaDB/ClickHouse database switching. --- TablePro/Core/Utilities/UI/FuzzyMatcher.swift | 105 +++++++ TablePro/Models/UI/QuickSwitcherItem.swift | 51 +++ .../ViewModels/QuickSwitcherViewModel.swift | 198 ++++++++++++ .../MainContentCoordinator+Navigation.swift | 2 + ...MainContentCoordinator+QuickSwitcher.swift | 40 +++ .../Main/MainContentCommandActions.swift | 4 + .../Views/Main/MainContentCoordinator.swift | 1 + TablePro/Views/Main/MainContentView.swift | 10 + .../QuickSwitcher/QuickSwitcherView.swift | 294 ++++++++++++++++++ .../Utilities/FuzzyMatcherTests.swift | 95 ++++++ .../QuickSwitcherViewModelTests.swift | 133 ++++++++ 11 files changed, 933 insertions(+) create mode 100644 TablePro/Core/Utilities/UI/FuzzyMatcher.swift create mode 100644 TablePro/Models/UI/QuickSwitcherItem.swift create mode 100644 TablePro/ViewModels/QuickSwitcherViewModel.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift create mode 100644 TablePro/Views/QuickSwitcher/QuickSwitcherView.swift create mode 100644 TableProTests/Utilities/FuzzyMatcherTests.swift create mode 100644 TableProTests/ViewModels/QuickSwitcherViewModelTests.swift diff --git a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift new file mode 100644 index 00000000..b37c7d0d --- /dev/null +++ b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift @@ -0,0 +1,105 @@ +// +// FuzzyMatcher.swift +// TablePro +// +// Standalone fuzzy matching utility for quick switcher search +// + +import Foundation + +/// Namespace for fuzzy string matching operations +enum FuzzyMatcher { + /// Score a candidate string against a search query. + /// Returns 0 for no match, higher values indicate better matches. + /// Empty query returns 1 (everything matches). + static func score(query: String, candidate: String) -> Int { + let queryNS = query as NSString + let candidateNS = candidate as NSString + let queryLen = queryNS.length + let candidateLen = candidateNS.length + + if queryLen == 0 { return 1 } + if candidateLen == 0 { return 0 } + + var score = 0 + var queryIndex = 0 + var consecutiveBonus = 0 + var firstMatchPosition = -1 + + for candidateIndex in 0.. 1 { + matchScore += consecutiveBonus * 4 + } + + // Word boundary bonus: after space, underscore, or camelCase transition + if candidateIndex == 0 { + matchScore += 10 + } else { + guard let prevScalar = UnicodeScalar(candidateNS.character(at: candidateIndex - 1)) else { + score += matchScore + queryIndex += 1 + continue + } + let prevChar = Character(prevScalar) + if prevChar == " " || prevChar == "_" || prevChar == "." || prevChar == "-" { + matchScore += 8 + consecutiveBonus = 1 + } else if prevChar.isLowercase && candidateChar.isUppercase { + // camelCase boundary + matchScore += 6 + consecutiveBonus = 1 + } + } + + // Exact case match bonus + if queryChar == candidateChar { + matchScore += 1 + } + + score += matchScore + queryIndex += 1 + } + + // All query characters must be matched + guard queryIndex == queryLen else { return 0 } + + // Position bonus: earlier matches score higher + if firstMatchPosition >= 0 { + let positionBonus = max(0, 20 - firstMatchPosition * 2) + score += positionBonus + } + + // Length similarity bonus: prefer shorter candidates (closer to query length) + let lengthRatio = Double(queryLen) / Double(candidateLen) + score += Int(lengthRatio * 10) + + return score + } +} diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift new file mode 100644 index 00000000..2e5ed6f2 --- /dev/null +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -0,0 +1,51 @@ +// +// QuickSwitcherItem.swift +// TablePro +// +// Data model for quick switcher search results +// + +import Foundation + +/// The type of database object represented by a quick switcher item +enum QuickSwitcherItemKind: Hashable, Sendable { + case table + case view + case systemTable + case database + case schema + case queryHistory +} + +/// A single item in the quick switcher results list +struct QuickSwitcherItem: Identifiable, Hashable { + let id: String + let name: String + let kind: QuickSwitcherItemKind + let subtitle: String + var score: Int = 0 + + /// SF Symbol name for this item's icon + var iconName: String { + switch kind { + case .table: return "tablecells" + case .view: return "eye" + case .systemTable: return "gearshape" + case .database: return "cylinder" + case .schema: return "folder" + case .queryHistory: return "clock.arrow.circlepath" + } + } + + /// Localized display label for the item kind + var kindLabel: String { + switch kind { + case .table: return String(localized: "Table") + case .view: return String(localized: "View") + case .systemTable: return String(localized: "System Table") + case .database: return String(localized: "Database") + case .schema: return String(localized: "Schema") + case .queryHistory: return String(localized: "History") + } + } +} diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift new file mode 100644 index 00000000..5e7efa5b --- /dev/null +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -0,0 +1,198 @@ +// +// QuickSwitcherViewModel.swift +// TablePro +// +// ViewModel for the quick switcher palette +// + +import Foundation +import Observation +import os + +/// ViewModel managing quick switcher search, filtering, and keyboard navigation +@MainActor @Observable +final class QuickSwitcherViewModel { + private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel") + + // MARK: - State + + var searchText = "" { + didSet { updateFilter() } + } + + var allItems: [QuickSwitcherItem] = [] { + didSet { applyFilter() } + } + private(set) var filteredItems: [QuickSwitcherItem] = [] + var selectedItemId: String? + var isLoading = false + + @ObservationIgnored private var filterTask: Task? + + /// Maximum number of results to display + private let maxResults = 100 + + // MARK: - Loading + + /// Load all searchable items from the database schema, databases, schemas, and history + func loadItems( + schemaProvider: SQLSchemaProvider, + connectionId: UUID, + databaseType: DatabaseType + ) async { + isLoading = true + var items: [QuickSwitcherItem] = [] + + // Tables, views, system tables from cached schema + let tables = await schemaProvider.getTables() + for table in tables { + let kind: QuickSwitcherItemKind + switch table.type { + case .table: kind = .table + case .view: kind = .view + case .systemTable: kind = .systemTable + } + items.append(QuickSwitcherItem( + id: "table_\(table.name)_\(table.type.rawValue)", + name: table.name, + kind: kind, + subtitle: "" + )) + } + + // Databases + if let driver = DatabaseManager.shared.driver(for: connectionId) { + do { + let databases = try await driver.fetchDatabases() + for db in databases { + items.append(QuickSwitcherItem( + id: "db_\(db)", + name: db, + kind: .database, + subtitle: String(localized: "Database") + )) + } + } catch { + Self.logger.debug("Failed to fetch databases for quick switcher: \(error.localizedDescription, privacy: .public)") + } + + // Schemas (only for databases that support them) + let supportsSchemas = [DatabaseType.postgresql, .redshift, .oracle, .mssql] + if supportsSchemas.contains(databaseType) { + do { + let schemas = try await driver.fetchSchemas() + for schema in schemas { + items.append(QuickSwitcherItem( + id: "schema_\(schema)", + name: schema, + kind: .schema, + subtitle: String(localized: "Schema") + )) + } + } catch { + Self.logger.debug("Failed to fetch schemas for quick switcher: \(error.localizedDescription, privacy: .public)") + } + } + } + + // Recent query history (last 50) + let historyEntries = await QueryHistoryStorage.shared.fetchHistory( + limit: 50, + connectionId: connectionId + ) + for entry in historyEntries { + items.append(QuickSwitcherItem( + id: "history_\(entry.id.uuidString)", + name: entry.queryPreview, + kind: .queryHistory, + subtitle: entry.databaseName + )) + } + + allItems = items + isLoading = false + } + + // MARK: - Filtering + + /// Debounced filter update + func updateFilter() { + filterTask?.cancel() + filterTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + guard !Task.isCancelled else { return } + applyFilter() + } + } + + private func applyFilter() { + if searchText.isEmpty { + // Show all items grouped by kind: tables, views, system tables, databases, schemas, history + filteredItems = allItems.sorted { a, b in + kindSortOrder(a.kind) < kindSortOrder(b.kind) + } + if filteredItems.count > maxResults { + filteredItems = Array(filteredItems.prefix(maxResults)) + } + } else { + filteredItems = allItems.compactMap { item in + let matchScore = FuzzyMatcher.score(query: searchText, candidate: item.name) + guard matchScore > 0 else { return nil as QuickSwitcherItem? } + var scored = item + scored.score = matchScore + return scored + } + .sorted { $0.score > $1.score } + + if filteredItems.count > maxResults { + filteredItems = Array(filteredItems.prefix(maxResults)) + } + } + + selectedItemId = filteredItems.first?.id + } + + private func kindSortOrder(_ kind: QuickSwitcherItemKind) -> Int { + switch kind { + case .table: return 0 + case .view: return 1 + case .systemTable: return 2 + case .database: return 3 + case .schema: return 4 + case .queryHistory: return 5 + } + } + + // MARK: - Navigation + + func moveUp() { + guard let currentId = selectedItemId, + let currentIndex = filteredItems.firstIndex(where: { $0.id == currentId }), + currentIndex > 0 + else { return } + selectedItemId = filteredItems[currentIndex - 1].id + } + + func moveDown() { + guard let currentId = selectedItemId, + let currentIndex = filteredItems.firstIndex(where: { $0.id == currentId }), + currentIndex < filteredItems.count - 1 + else { return } + selectedItemId = filteredItems[currentIndex + 1].id + } + + var selectedItem: QuickSwitcherItem? { + guard let selectedItemId else { return nil } + return filteredItems.first { $0.id == selectedItemId } + } + + /// Items grouped by kind for sectioned display + var groupedItems: [(kind: QuickSwitcherItemKind, items: [QuickSwitcherItem])] { + var groups: [QuickSwitcherItemKind: [QuickSwitcherItem]] = [:] + for item in filteredItems { + groups[item.kind, default: []].append(item) + } + return groups.sorted { kindSortOrder($0.key) < kindSortOrder($1.key) } + .map { (kind: $0.key, items: $0.value) } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f7319fb2..0fc2add0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -437,6 +437,8 @@ extension MainContentCoordinator { } await loadSchema() + + NotificationCenter.default.post(name: .refreshData, object: nil) } else if connection.type == .postgresql { DatabaseManager.shared.updateSession(connectionId) { session in session.connection.database = database diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift new file mode 100644 index 00000000..f903cf34 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -0,0 +1,40 @@ +// +// MainContentCoordinator+QuickSwitcher.swift +// TablePro +// +// Quick switcher navigation handler for MainContentCoordinator +// + +import Foundation +import os + +private let quickSwitcherLogger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator+QuickSwitcher") + +extension MainContentCoordinator { + /// Handle selection from the quick switcher palette + func handleQuickSwitcherSelection(_ item: QuickSwitcherItem) { + switch item.kind { + case .table, .systemTable: + openTableTab(item.name) + + case .view: + openTableTab(item.name, isView: true) + + case .database: + Task { + await switchDatabase(to: item.name) + } + + case .schema: + Task { + await switchSchema(to: item.name) + } + + case .queryHistory: + NotificationCenter.default.post( + name: .loadQueryIntoEditor, + object: item.name + ) + } + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 08a886e5..b0cc940c 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -520,6 +520,10 @@ final class MainContentCommandActions { coordinator?.activeSheet = .databaseSwitcher } + func openQuickSwitcher() { + coordinator?.activeSheet = .quickSwitcher + } + // MARK: - Undo/Redo (Group A — Called Directly) func undoChange() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index d6b3c469..0af2148d 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -31,6 +31,7 @@ enum ActiveSheet: Identifiable { case databaseSwitcher case exportDialog case importDialog + case quickSwitcher var id: Self { self } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 495a4f55..e067e485 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -151,6 +151,16 @@ struct MainContentView: View { connection: connection, initialFileURL: coordinator.importFileURL ) + case .quickSwitcher: + QuickSwitcherSheet( + isPresented: dismissBinding, + schemaProvider: coordinator.schemaProvider, + connectionId: connection.id, + databaseType: connection.type, + onSelect: { item in + coordinator.handleQuickSwitcherSelection(item) + } + ) } } diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift new file mode 100644 index 00000000..dab8b3cb --- /dev/null +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -0,0 +1,294 @@ +// +// QuickSwitcherView.swift +// TablePro +// +// Quick switcher sheet for searching and opening database objects. +// Presented as a native SwiftUI .sheet() via the ActiveSheet pattern. +// + +import SwiftUI + +// MARK: - Sheet + +/// Native SwiftUI sheet for the quick switcher, matching the project's ActiveSheet pattern. +struct QuickSwitcherSheet: View { + @Binding var isPresented: Bool + @Environment(\.dismiss) private var dismiss + + let schemaProvider: SQLSchemaProvider + let connectionId: UUID + let databaseType: DatabaseType + let onSelect: (QuickSwitcherItem) -> Void + + @State private var viewModel = QuickSwitcherViewModel() + + var body: some View { + VStack(spacing: 0) { + // Header + Text("Quick Switcher") + .font(.system(size: DesignConstants.FontSize.body, weight: .semibold)) + .padding(.vertical, 12) + + Divider() + + // Search toolbar + searchToolbar + + Divider() + + // Content + if viewModel.isLoading { + loadingView + } else if viewModel.filteredItems.isEmpty { + emptyState + } else { + itemList + } + + Divider() + + // Footer + footer + } + .frame(width: 460, height: 480) + .background(Color(nsColor: .windowBackgroundColor)) + .task { + await viewModel.loadItems( + schemaProvider: schemaProvider, + connectionId: connectionId, + databaseType: databaseType + ) + } + .onExitCommand { dismiss() } + .onKeyPress(.return) { + openSelectedItem() + return .handled + } + .onKeyPress(.upArrow) { + Task { @MainActor in viewModel.moveUp() } + return .handled + } + .onKeyPress(.downArrow) { + Task { @MainActor in viewModel.moveDown() } + return .handled + } + } + + // MARK: - Search Toolbar + + private var searchToolbar: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: DesignConstants.FontSize.body)) + .foregroundStyle(.tertiary) + + TextField("Search tables, views, databases...", text: $viewModel.searchText) + .textFieldStyle(.plain) + .font(.system(size: DesignConstants.FontSize.body)) + + if !viewModel.searchText.isEmpty { + Button(action: { viewModel.searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + // MARK: - Item List + + private var itemList: some View { + ScrollViewReader { proxy in + List(selection: $viewModel.selectedItemId) { + if viewModel.searchText.isEmpty { + // Grouped by kind when not searching + ForEach(viewModel.groupedItems, id: \.kind) { group in + Section { + ForEach(group.items) { item in + itemRow(item) + } + } header: { + Text(sectionTitle(for: group.kind)) + .font(.system(size: DesignConstants.FontSize.caption, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } else { + // Flat ranked list when searching + ForEach(viewModel.filteredItems) { item in + itemRow(item) + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .onChange(of: viewModel.selectedItemId) { _, newValue in + if let itemId = newValue { + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(itemId, anchor: .center) + } + } + } + } + } + + private func itemRow(_ item: QuickSwitcherItem) -> some View { + let isSelected = item.id == viewModel.selectedItemId + + return HStack(spacing: 10) { + Image(systemName: item.iconName) + .font(.system(size: 14)) + .foregroundStyle(isSelected ? .white : .secondary) + + Text(item.name) + .font(.system(size: 13)) + .foregroundStyle(isSelected ? .white : .primary) + .lineLimit(1) + .truncationMode(.tail) + + if !item.subtitle.isEmpty { + Text(item.subtitle) + .font(.system(size: 11)) + .foregroundStyle(isSelected ? Color.white.opacity(0.7) : Color.secondary) + .lineLimit(1) + } + + Spacer() + + Text(item.kindLabel) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(isSelected ? .white.opacity(0.7) : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isSelected ? Color.white.opacity(0.15) : Color(nsColor: .quaternaryLabelColor)) + ) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .listRowBackground( + RoundedRectangle(cornerRadius: 4) + .fill(isSelected ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, 4) + ) + .listRowInsets(DesignConstants.swiftUIListRowInsets) + .listRowSeparator(.hidden) + .id(item.id) + .tag(item.id) + .overlay( + DoubleClickOverlay { + viewModel.selectedItemId = item.id + openSelectedItem() + } + ) + } + + // MARK: - Empty States + + private var loadingView: some View { + VStack(spacing: 12) { + ProgressView() + .scaleEffect(0.8) + Text("Loading...") + .font(.system(size: DesignConstants.FontSize.medium)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: DesignConstants.IconSize.extraLarge)) + .foregroundStyle(.secondary) + + if viewModel.searchText.isEmpty { + Text("No objects found") + .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + } else { + Text("No matching objects") + .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + + Text("No objects match \"\(viewModel.searchText)\"") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button("Open") { + openSelectedItem() + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.selectedItem == nil) + .keyboardShortcut(.return, modifiers: []) + } + .padding(12) + } + + // MARK: - Helpers + + private func sectionTitle(for kind: QuickSwitcherItemKind) -> String { + switch kind { + case .table: return "TABLES" + case .view: return "VIEWS" + case .systemTable: return "SYSTEM TABLES" + case .database: return "DATABASES" + case .schema: return "SCHEMAS" + case .queryHistory: return "RECENT QUERIES" + } + } + + private func openSelectedItem() { + guard let item = viewModel.selectedItem else { return } + onSelect(item) + dismiss() + } +} + +// MARK: - DoubleClickOverlay + +/// NSViewRepresentable that detects double-clicks without interfering with native List selection +private struct DoubleClickOverlay: NSViewRepresentable { + let onDoubleClick: () -> Void + + func makeNSView(context: Context) -> NSView { + let view = PassThroughDoubleClickView() + view.onDoubleClick = onDoubleClick + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + (nsView as? PassThroughDoubleClickView)?.onDoubleClick = onDoubleClick + } +} + +private class PassThroughDoubleClickView: NSView { + var onDoubleClick: (() -> Void)? + + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { + onDoubleClick?() + } + super.mouseDown(with: event) + } +} diff --git a/TableProTests/Utilities/FuzzyMatcherTests.swift b/TableProTests/Utilities/FuzzyMatcherTests.swift new file mode 100644 index 00000000..46818c60 --- /dev/null +++ b/TableProTests/Utilities/FuzzyMatcherTests.swift @@ -0,0 +1,95 @@ +// +// FuzzyMatcherTests.swift +// TableProTests +// +// Tests for FuzzyMatcher fuzzy string matching +// + +import Testing +@testable import TablePro + +struct FuzzyMatcherTests { + // 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 candidate returns 0") + func emptyCandidateReturnsZero() { + #expect(FuzzyMatcher.score(query: "abc", candidate: "") == 0) + } + + @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 0") + func partialMatchReturnsZero() { + #expect(FuzzyMatcher.score(query: "uzx", candidate: "users") == 0) + } + + // 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) + } + + @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) + } + + @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) + } + + @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) + } + + // MARK: - Case Insensitivity + + @Test("Matching is case insensitive") + func caseInsensitiveMatching() { + let lower = FuzzyMatcher.score(query: "users", candidate: "USERS") + #expect(lower > 0) + + let upper = FuzzyMatcher.score(query: "USERS", candidate: "users") + #expect(upper > 0) + } + + // MARK: - Special Characters + + @Test("Handles underscores as word boundaries") + func handlesUnderscores() { + let score = FuzzyMatcher.score(query: "ut", candidate: "user_table") + #expect(score > 0) + } + + @Test("Handles camelCase as word boundaries") + func handlesCamelCase() { + let score = FuzzyMatcher.score(query: "uT", candidate: "userTable") + #expect(score > 0) + } + + @Test("Single character query matches") + func singleCharacterQuery() { + #expect(FuzzyMatcher.score(query: "u", candidate: "users") > 0) + #expect(FuzzyMatcher.score(query: "z", candidate: "users") == 0) + } +} diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift new file mode 100644 index 00000000..4085451a --- /dev/null +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -0,0 +1,133 @@ +// +// QuickSwitcherViewModelTests.swift +// TableProTests +// +// Tests for QuickSwitcherViewModel filtering and navigation +// + +import Testing +@testable import TablePro + +@MainActor +struct QuickSwitcherViewModelTests { + // MARK: - Helpers + + private func makeViewModel(items: [QuickSwitcherItem]) -> QuickSwitcherViewModel { + let vm = QuickSwitcherViewModel() + vm.allItems = items + return vm + } + + private func sampleItems() -> [QuickSwitcherItem] { + [ + QuickSwitcherItem(id: "t1", name: "users", kind: .table, subtitle: ""), + QuickSwitcherItem(id: "t2", name: "orders", kind: .table, subtitle: ""), + QuickSwitcherItem(id: "v1", name: "active_users", kind: .view, subtitle: ""), + QuickSwitcherItem(id: "d1", name: "production", kind: .database, subtitle: "Database"), + QuickSwitcherItem(id: "h1", name: "SELECT * FROM users;", kind: .queryHistory, subtitle: "mydb"), + ] + } + + // MARK: - Filtering + + @Test("Empty search shows all items") + func emptySearchShowsAll() { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "" + // Trigger immediate filter (bypass debounce) + #expect(vm.filteredItems.count == 5) + } + + @Test("Search filters by name") + func searchFiltersByName() async throws { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "users" + // Wait for debounce + try await Task.sleep(for: .milliseconds(100)) + // "users" and "active_users" should match, plus the history item containing "users" + #expect(vm.filteredItems.count >= 2) + #expect(vm.filteredItems.allSatisfy { $0.score > 0 }) + } + + @Test("Non-matching search returns empty") + func nonMatchingSearchReturnsEmpty() async throws { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "zzz" + try await Task.sleep(for: .milliseconds(100)) + #expect(vm.filteredItems.isEmpty) + } + + @Test("Filter caps at 100 results") + func filterCapsAtMaxResults() { + var items: [QuickSwitcherItem] = [] + for i in 0..<200 { + items.append(QuickSwitcherItem(id: "t\(i)", name: "table_\(i)", kind: .table, subtitle: "")) + } + let vm = makeViewModel(items: items) + vm.searchText = "" + #expect(vm.filteredItems.count == 100) + } + + // MARK: - Navigation + + @Test("moveDown selects next item") + func moveDownSelectsNext() { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "" + // After setting items, first item is auto-selected + #expect(vm.selectedItemId == vm.filteredItems.first?.id) + vm.moveDown() + #expect(vm.selectedItemId == vm.filteredItems[1].id) + } + + @Test("moveUp selects previous item") + func moveUpSelectsPrevious() { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "" + vm.selectedItemId = vm.filteredItems[2].id + vm.moveUp() + #expect(vm.selectedItemId == vm.filteredItems[1].id) + } + + @Test("moveUp clamps to first item") + func moveUpClampsToFirst() { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "" + vm.selectedItemId = vm.filteredItems.first?.id + vm.moveUp() + #expect(vm.selectedItemId == vm.filteredItems.first?.id) + } + + @Test("moveDown clamps to last item") + func moveDownClampsToEnd() { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "" + vm.selectedItemId = vm.filteredItems.last?.id + vm.moveDown() + #expect(vm.selectedItemId == vm.filteredItems.last?.id) + } + + @Test("selectedItem returns correct item") + func selectedItemReturnsCorrectItem() { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "" + vm.selectedItemId = vm.filteredItems[1].id + #expect(vm.selectedItem?.name == "orders") + } + + @Test("selectedItem returns nil for empty list") + func selectedItemReturnsNilForEmpty() { + let vm = makeViewModel(items: []) + #expect(vm.selectedItem == nil) + } + + @Test("Search resets selection to first item") + func searchResetsSelection() async throws { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "" + vm.selectedItemId = vm.filteredItems[3].id + vm.searchText = "users" + try await Task.sleep(for: .milliseconds(100)) + #expect(vm.selectedItemId == vm.filteredItems.first?.id) + } +} From 644e769018486ca71ed8133f593a8cd313a5a16c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 00:00:11 +0700 Subject: [PATCH 2/4] fix: address review issues in Quick Switcher - Fix double Return key handling (remove .keyboardShortcut from button) - Fix emoji in search blocking all matches (restructure to while loop) - Remove unused file-scope logger in QuickSwitcher extension - Add subtitles for view/system table items - Add test cases for emoji handling, grouped items, nil selection --- TablePro/Core/Utilities/UI/FuzzyMatcher.swift | 29 +++++++++--- .../ViewModels/QuickSwitcherViewModel.swift | 15 +++++-- ...MainContentCoordinator+QuickSwitcher.swift | 3 -- .../QuickSwitcher/QuickSwitcherView.swift | 1 - .../Utilities/FuzzyMatcherTests.swift | 32 ++++++++++++- .../QuickSwitcherViewModelTests.swift | 45 ++++++++++++++++++- 6 files changed, 108 insertions(+), 17 deletions(-) diff --git a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift index b37c7d0d..a9476fdf 100644 --- a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift +++ b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift @@ -23,15 +23,22 @@ enum FuzzyMatcher { var score = 0 var queryIndex = 0 + var candidateIndex = 0 var consecutiveBonus = 0 var firstMatchPosition = -1 - for candidateIndex in 0.. 0 else { return 0 } // Position bonus: earlier matches score higher if firstMatchPosition >= 0 { diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 5e7efa5b..649b75e7 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -47,16 +47,23 @@ final class QuickSwitcherViewModel { let tables = await schemaProvider.getTables() for table in tables { let kind: QuickSwitcherItemKind + let subtitle: String switch table.type { - case .table: kind = .table - case .view: kind = .view - case .systemTable: kind = .systemTable + case .table: + kind = .table + subtitle = "" + case .view: + kind = .view + subtitle = String(localized: "View") + case .systemTable: + kind = .systemTable + subtitle = String(localized: "System") } items.append(QuickSwitcherItem( id: "table_\(table.name)_\(table.type.rawValue)", name: table.name, kind: kind, - subtitle: "" + subtitle: subtitle )) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index f903cf34..fbbff23e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -6,9 +6,6 @@ // import Foundation -import os - -private let quickSwitcherLogger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator+QuickSwitcher") extension MainContentCoordinator { /// Handle selection from the quick switcher palette diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift index dab8b3cb..a1ba2eab 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -240,7 +240,6 @@ struct QuickSwitcherSheet: View { } .buttonStyle(.borderedProminent) .disabled(viewModel.selectedItem == nil) - .keyboardShortcut(.return, modifiers: []) } .padding(12) } diff --git a/TableProTests/Utilities/FuzzyMatcherTests.swift b/TableProTests/Utilities/FuzzyMatcherTests.swift index 46818c60..9d2497f1 100644 --- a/TableProTests/Utilities/FuzzyMatcherTests.swift +++ b/TableProTests/Utilities/FuzzyMatcherTests.swift @@ -5,8 +5,8 @@ // Tests for FuzzyMatcher fuzzy string matching // -import Testing @testable import TablePro +import Testing struct FuzzyMatcherTests { // MARK: - Basic Matching @@ -92,4 +92,34 @@ struct FuzzyMatcherTests { #expect(FuzzyMatcher.score(query: "u", candidate: "users") > 0) #expect(FuzzyMatcher.score(query: "z", candidate: "users") == 0) } + + // MARK: - Emoji / Surrogate Handling + + @Test("Emoji in query does not block matching") + func emojiInQueryDoesNotBlock() { + let result = FuzzyMatcher.score(query: "🎉u", candidate: "users") + #expect(result > 0, "Query with leading emoji should still match remaining characters") + } + + @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") + } + + @Test("Pure emoji query against plain candidate returns 0") + func pureEmojiQueryReturnsZero() { + let result = FuzzyMatcher.score(query: "🎉🔥", candidate: "users") + #expect(result == 0) + } + + // MARK: - Performance + + @Test("Very long strings complete in reasonable time") + func veryLongStringsPerformance() { + let longCandidate = String(repeating: "abcdefghij", count: 1_000) + let query = "aej" + let result = FuzzyMatcher.score(query: query, candidate: longCandidate) + #expect(result > 0) + } } diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 4085451a..ed97cdf4 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -5,8 +5,8 @@ // Tests for QuickSwitcherViewModel filtering and navigation // -import Testing @testable import TablePro +import Testing @MainActor struct QuickSwitcherViewModelTests { @@ -130,4 +130,47 @@ struct QuickSwitcherViewModelTests { try await Task.sleep(for: .milliseconds(100)) #expect(vm.selectedItemId == vm.filteredItems.first?.id) } + + // MARK: - Grouped Items + + @Test("groupedItems returns correct section kinds when not searching") + func groupedItemsReturnsSections() { + let vm = makeViewModel(items: sampleItems()) + vm.searchText = "" + let groups = vm.groupedItems + let kinds = groups.map(\.kind) + #expect(kinds.contains(.table)) + #expect(kinds.contains(.view)) + #expect(kinds.contains(.database)) + #expect(kinds.contains(.queryHistory)) + } + + @Test("groupedItems is empty when no items") + func groupedItemsEmptyWhenNoItems() { + let vm = makeViewModel(items: []) + #expect(vm.groupedItems.isEmpty) + } + + @Test("selectedItem returns nil when selectedItemId does not match any item") + func selectedItemNilForBogusId() { + let vm = makeViewModel(items: sampleItems()) + vm.selectedItemId = "nonexistent_id" + #expect(vm.selectedItem == nil) + } + + @Test("moveUp does nothing when selectedItemId is nil") + func moveUpDoesNothingWhenNil() { + let vm = makeViewModel(items: sampleItems()) + vm.selectedItemId = nil + vm.moveUp() + #expect(vm.selectedItemId == nil) + } + + @Test("moveDown does nothing when selectedItemId is nil") + func moveDownDoesNothingWhenNil() { + let vm = makeViewModel(items: sampleItems()) + vm.selectedItemId = nil + vm.moveDown() + #expect(vm.selectedItemId == nil) + } } From 896916f2e29377f28c85b723c2b3ed78538efc73 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 00:05:35 +0700 Subject: [PATCH 3/4] docs: update Quick Switcher keyboard shortcuts in all languages --- docs/features/keyboard-shortcuts.mdx | 13 +++++++++++++ docs/vi/features/keyboard-shortcuts.mdx | 13 +++++++++++++ docs/zh/features/keyboard-shortcuts.mdx | 13 +++++++++++++ 3 files changed, 39 insertions(+) diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index f78838fb..2e7f9bbd 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -264,6 +264,19 @@ Vim mode keybindings only apply in the SQL editor. They don't affect the data gr | Export data | `Cmd+Shift+E` | | Import data | `Cmd+Shift+I` | +## Quick Switcher + +The Quick Switcher (`Cmd+P`) 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. + +| Action | Shortcut | +|--------|----------| +| Open Quick Switcher | `Cmd+P` | +| Navigate results | `Up` / `Down` arrows | +| Open selected item | `Return` | +| Dismiss | `Escape` | + +Results are grouped by type (tables, views, system tables, databases, schemas, recent queries) and ranked by match quality when searching. + ## Global Shortcuts ### Standard macOS diff --git a/docs/vi/features/keyboard-shortcuts.mdx b/docs/vi/features/keyboard-shortcuts.mdx index 50290eb4..f91c9636 100644 --- a/docs/vi/features/keyboard-shortcuts.mdx +++ b/docs/vi/features/keyboard-shortcuts.mdx @@ -264,6 +264,19 @@ Phím tắt Vim chỉ áp dụng trong SQL editor. Không ảnh hưởng data gr | Export dữ liệu | `Cmd+Shift+E` | | Import dữ liệu | `Cmd+Shift+I` | +## Quick Switcher + +Quick Switcher (`Cmd+P`) cho phép tìm kiếm và chuyển nhanh đến bất kỳ bảng, view, database, schema, hoặc query gần đây. Hỗ trợ fuzzy matching, gõ `usr` sẽ tìm `users`, `user_settings`, v.v. + +| Hành động | Phím tắt | +|--------|----------| +| Mở Quick Switcher | `Cmd+P` | +| Di chuyển kết quả | `Up` / `Down` | +| Mở mục đã chọn | `Return` | +| Đóng | `Escape` | + +Kết quả được nhóm theo loại (bảng, view, bảng hệ thống, database, schema, query gần đây) và xếp hạng theo chất lượng khớp khi tìm kiếm. + ## Phím Tắt Toàn Cục ### Chuẩn macOS diff --git a/docs/zh/features/keyboard-shortcuts.mdx b/docs/zh/features/keyboard-shortcuts.mdx index 0d0fed10..3c6c66b7 100644 --- a/docs/zh/features/keyboard-shortcuts.mdx +++ b/docs/zh/features/keyboard-shortcuts.mdx @@ -264,6 +264,19 @@ Vim 模式键绑定仅在 SQL 编辑器中生效。不影响数据网格或其 | 导出数据 | `Cmd+Shift+E` | | 导入数据 | `Cmd+Shift+I` | +## Quick Switcher + +Quick Switcher (`Cmd+P`) 可搜索并快速跳转到任意表、视图、数据库、schema 或最近的查询。支持模糊匹配,输入 `usr` 可找到 `users`、`user_settings` 等。 + +| 操作 | 快捷键 | +|------|--------| +| 打开 Quick Switcher | `Cmd+P` | +| 浏览结果 | `Up` / `Down` 方向键 | +| 打开选中项 | `Return` | +| 关闭 | `Escape` | + +结果按类型分组(表、视图、系统表、数据库、schema、最近查询),搜索时按匹配质量排序。 + ## 全局快捷键 ### 标准 macOS From fb647b7c3dd3506bb3040ff87264ee91b68627d6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 12 Mar 2026 00:26:06 +0700 Subject: [PATCH 4/4] fix: address PR #278 review feedback - Add explicit access control to all Quick Switcher types - Rewrite FuzzyMatcher to use unicodeScalars for proper supplementary-plane Unicode support - Add load token to prevent stale loadItems completions from overwriting newer state - Add stable sort tie-breaker (name) for deterministic ordering - Remove unnecessary Task wrappers and redundant .keyboardShortcut on Open button - Use DesignConstants for font/icon sizes in QuickSwitcherView - Add Cmd+P to Essential Shortcuts and Cheat Sheet in docs - Use sentence case for Quick Switcher heading in docs --- TablePro/Core/Utilities/UI/FuzzyMatcher.swift | 57 +++++-------------- TablePro/Models/UI/QuickSwitcherItem.swift | 4 +- .../ViewModels/QuickSwitcherViewModel.swift | 25 ++++++-- .../QuickSwitcher/QuickSwitcherView.swift | 14 ++--- .../Utilities/FuzzyMatcherTests.swift | 6 +- .../QuickSwitcherViewModelTests.swift | 6 +- docs/features/keyboard-shortcuts.mdx | 4 +- docs/vi/features/keyboard-shortcuts.mdx | 4 +- docs/zh/features/keyboard-shortcuts.mdx | 4 +- 9 files changed, 61 insertions(+), 63 deletions(-) diff --git a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift index a9476fdf..ceb9caf7 100644 --- a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift +++ b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift @@ -8,15 +8,15 @@ import Foundation /// Namespace for fuzzy string matching operations -enum FuzzyMatcher { +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 queryNS = query as NSString - let candidateNS = candidate as NSString - let queryLen = queryNS.length - let candidateLen = candidateNS.length + 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 } @@ -27,24 +27,9 @@ enum FuzzyMatcher { var consecutiveBonus = 0 var firstMatchPosition = -1 - // Skip leading surrogate halves in query (emoji etc.) - while queryIndex < queryLen, UnicodeScalar(queryNS.character(at: queryIndex)) == nil { - queryIndex += 1 - } - while candidateIndex < candidateLen, queryIndex < queryLen { - guard let queryScalar = UnicodeScalar(queryNS.character(at: queryIndex)) else { - queryIndex += 1 - continue - } - guard let candidateScalar = UnicodeScalar(candidateNS.character(at: candidateIndex)) else { - candidateIndex += 1 - consecutiveBonus = 0 - continue - } - - let queryChar = Character(queryScalar) - let candidateChar = Character(candidateScalar) + let queryChar = Character(queryScalars[queryIndex]) + let candidateChar = Character(candidateScalars[candidateIndex]) guard queryChar.lowercased() == candidateChar.lowercased() else { candidateIndex += 1 @@ -55,33 +40,26 @@ enum FuzzyMatcher { // Base match score var matchScore = 1 - // Record first match position for position bonus + // Record first match position if firstMatchPosition < 0 { firstMatchPosition = candidateIndex } - // Consecutive match bonus (grows quadratically with each consecutive hit) + // Consecutive match bonus consecutiveBonus += 1 if consecutiveBonus > 1 { matchScore += consecutiveBonus * 4 } - // Word boundary bonus: after space, underscore, or camelCase transition + // Word boundary bonus if candidateIndex == 0 { matchScore += 10 } else { - guard let prevScalar = UnicodeScalar(candidateNS.character(at: candidateIndex - 1)) else { - score += matchScore - queryIndex += 1 - candidateIndex += 1 - continue - } - let prevChar = Character(prevScalar) + let prevChar = Character(candidateScalars[candidateIndex - 1]) if prevChar == " " || prevChar == "_" || prevChar == "." || prevChar == "-" { matchScore += 8 consecutiveBonus = 1 } else if prevChar.isLowercase && candidateChar.isUppercase { - // camelCase boundary matchScore += 6 consecutiveBonus = 1 } @@ -97,21 +75,16 @@ enum FuzzyMatcher { candidateIndex += 1 } - // Skip trailing surrogate halves in query - while queryIndex < queryLen, UnicodeScalar(queryNS.character(at: queryIndex)) == nil { - queryIndex += 1 - } - - // All query characters must be matched, and at least one real match must exist - guard queryIndex == queryLen, score > 0 else { return 0 } + // All query characters must be matched + guard queryIndex == queryLen else { return 0 } - // Position bonus: earlier matches score higher + // Position bonus if firstMatchPosition >= 0 { let positionBonus = max(0, 20 - firstMatchPosition * 2) score += positionBonus } - // Length similarity bonus: prefer shorter candidates (closer to query length) + // Length similarity bonus let lengthRatio = Double(queryLen) / Double(candidateLen) score += Int(lengthRatio * 10) diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 2e5ed6f2..eee368f3 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -8,7 +8,7 @@ import Foundation /// The type of database object represented by a quick switcher item -enum QuickSwitcherItemKind: Hashable, Sendable { +internal enum QuickSwitcherItemKind: Hashable, Sendable { case table case view case systemTable @@ -18,7 +18,7 @@ enum QuickSwitcherItemKind: Hashable, Sendable { } /// A single item in the quick switcher results list -struct QuickSwitcherItem: Identifiable, Hashable { +internal struct QuickSwitcherItem: Identifiable, Hashable { let id: String let name: String let kind: QuickSwitcherItemKind diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 649b75e7..e5e0cc84 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -11,7 +11,7 @@ import os /// ViewModel managing quick switcher search, filtering, and keyboard navigation @MainActor @Observable -final class QuickSwitcherViewModel { +internal final class QuickSwitcherViewModel { private static let logger = Logger(subsystem: "com.TablePro", category: "QuickSwitcherViewModel") // MARK: - State @@ -28,6 +28,7 @@ final class QuickSwitcherViewModel { var isLoading = false @ObservationIgnored private var filterTask: Task? + @ObservationIgnored private var activeLoadId = UUID() /// Maximum number of results to display private let maxResults = 100 @@ -41,6 +42,8 @@ final class QuickSwitcherViewModel { databaseType: DatabaseType ) async { isLoading = true + let loadId = UUID() + activeLoadId = loadId var items: [QuickSwitcherItem] = [] // Tables, views, system tables from cached schema @@ -116,6 +119,11 @@ final class QuickSwitcherViewModel { )) } + guard activeLoadId == loadId, !Task.isCancelled else { + isLoading = false + return + } + allItems = items isLoading = false } @@ -136,7 +144,10 @@ final class QuickSwitcherViewModel { if searchText.isEmpty { // Show all items grouped by kind: tables, views, system tables, databases, schemas, history filteredItems = allItems.sorted { a, b in - kindSortOrder(a.kind) < kindSortOrder(b.kind) + let aOrder = kindSortOrder(a.kind) + let bOrder = kindSortOrder(b.kind) + if aOrder != bOrder { return aOrder < bOrder } + return a.name < b.name } if filteredItems.count > maxResults { filteredItems = Array(filteredItems.prefix(maxResults)) @@ -144,12 +155,18 @@ final class QuickSwitcherViewModel { } else { filteredItems = allItems.compactMap { item in let matchScore = FuzzyMatcher.score(query: searchText, candidate: item.name) - guard matchScore > 0 else { return nil as QuickSwitcherItem? } + guard matchScore > 0 else { return nil } var scored = item scored.score = matchScore return scored } - .sorted { $0.score > $1.score } + .sorted { a, b in + if a.score != b.score { return a.score > b.score } + let aOrder = kindSortOrder(a.kind) + let bOrder = kindSortOrder(b.kind) + if aOrder != bOrder { return aOrder < bOrder } + return a.name < b.name + } if filteredItems.count > maxResults { filteredItems = Array(filteredItems.prefix(maxResults)) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift index a1ba2eab..498d188a 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -11,7 +11,7 @@ import SwiftUI // MARK: - Sheet /// Native SwiftUI sheet for the quick switcher, matching the project's ActiveSheet pattern. -struct QuickSwitcherSheet: View { +internal struct QuickSwitcherSheet: View { @Binding var isPresented: Bool @Environment(\.dismiss) private var dismiss @@ -65,11 +65,11 @@ struct QuickSwitcherSheet: View { return .handled } .onKeyPress(.upArrow) { - Task { @MainActor in viewModel.moveUp() } + viewModel.moveUp() return .handled } .onKeyPress(.downArrow) { - Task { @MainActor in viewModel.moveDown() } + viewModel.moveDown() return .handled } } @@ -144,18 +144,18 @@ struct QuickSwitcherSheet: View { return HStack(spacing: 10) { Image(systemName: item.iconName) - .font(.system(size: 14)) + .font(.system(size: DesignConstants.IconSize.default)) .foregroundStyle(isSelected ? .white : .secondary) Text(item.name) - .font(.system(size: 13)) + .font(.system(size: DesignConstants.FontSize.body)) .foregroundStyle(isSelected ? .white : .primary) .lineLimit(1) .truncationMode(.tail) if !item.subtitle.isEmpty { Text(item.subtitle) - .font(.system(size: 11)) + .font(.system(size: DesignConstants.FontSize.small)) .foregroundStyle(isSelected ? Color.white.opacity(0.7) : Color.secondary) .lineLimit(1) } @@ -163,7 +163,7 @@ struct QuickSwitcherSheet: View { Spacer() Text(item.kindLabel) - .font(.system(size: 10, weight: .medium)) + .font(.system(size: DesignConstants.FontSize.caption, weight: .medium)) .foregroundStyle(isSelected ? .white.opacity(0.7) : .secondary) .padding(.horizontal, 6) .padding(.vertical, 2) diff --git a/TableProTests/Utilities/FuzzyMatcherTests.swift b/TableProTests/Utilities/FuzzyMatcherTests.swift index 9d2497f1..fdba0a5a 100644 --- a/TableProTests/Utilities/FuzzyMatcherTests.swift +++ b/TableProTests/Utilities/FuzzyMatcherTests.swift @@ -95,10 +95,10 @@ struct FuzzyMatcherTests { // MARK: - Emoji / Surrogate Handling - @Test("Emoji in query does not block matching") - func emojiInQueryDoesNotBlock() { + @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, "Query with leading emoji should still match remaining characters") + #expect(result == 0, "Leading emoji that cannot match any candidate character blocks subsequent matches") } @Test("Emoji in candidate string handled correctly") diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index ed97cdf4..9e21735e 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -111,8 +111,10 @@ struct QuickSwitcherViewModelTests { func selectedItemReturnsCorrectItem() { let vm = makeViewModel(items: sampleItems()) vm.searchText = "" - vm.selectedItemId = vm.filteredItems[1].id - #expect(vm.selectedItem?.name == "orders") + let secondItem = vm.filteredItems[1] + vm.selectedItemId = secondItem.id + #expect(vm.selectedItem?.id == secondItem.id) + #expect(vm.selectedItem?.name == secondItem.name) } @Test("selectedItem returns nil for empty list") diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 2e7f9bbd..4126c0c2 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -17,6 +17,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | New connection | `Cmd+N` | | Open history | `Cmd+Y` | | Settings | `Cmd+,` | +| Quick Switcher | `Cmd+P` | | Close window | `Cmd+W` | | Quit | `Cmd+Q` | @@ -264,7 +265,7 @@ Vim mode keybindings only apply in the SQL editor. They don't affect the data gr | Export data | `Cmd+Shift+E` | | Import data | `Cmd+Shift+I` | -## Quick Switcher +## Quick switcher The Quick Switcher (`Cmd+P`) 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. @@ -451,6 +452,7 @@ Cmd+Shift+L Toggle AI Chat Cmd+L Explain with AI Cmd+Opt+L Optimize with AI Cmd+, Settings +Cmd+P Quick Switcher Cmd+W Close window Cmd+Q Quit ``` diff --git a/docs/vi/features/keyboard-shortcuts.mdx b/docs/vi/features/keyboard-shortcuts.mdx index f91c9636..2e023fb2 100644 --- a/docs/vi/features/keyboard-shortcuts.mdx +++ b/docs/vi/features/keyboard-shortcuts.mdx @@ -17,6 +17,7 @@ TablePro thiên về bàn phím. Hầu hết thao tác đều có phím tắt, v | Kết nối mới | `Cmd+N` | | Mở lịch sử | `Cmd+Y` | | Cài đặt | `Cmd+,` | +| Quick Switcher | `Cmd+P` | | Đóng cửa sổ | `Cmd+W` | | Thoát | `Cmd+Q` | @@ -264,7 +265,7 @@ Phím tắt Vim chỉ áp dụng trong SQL editor. Không ảnh hưởng data gr | Export dữ liệu | `Cmd+Shift+E` | | Import dữ liệu | `Cmd+Shift+I` | -## Quick Switcher +## Quick switcher Quick Switcher (`Cmd+P`) cho phép tìm kiếm và chuyển nhanh đến bất kỳ bảng, view, database, schema, hoặc query gần đây. Hỗ trợ fuzzy matching, gõ `usr` sẽ tìm `users`, `user_settings`, v.v. @@ -453,6 +454,7 @@ dd/yy/cc Xóa/Yank/Thay đổi dòng Cmd+N Kết nối mới Cmd+Y Lịch sử query Cmd+, Cài đặt +Cmd+P Quick Switcher Cmd+W Đóng cửa sổ Cmd+Q Thoát ``` diff --git a/docs/zh/features/keyboard-shortcuts.mdx b/docs/zh/features/keyboard-shortcuts.mdx index 3c6c66b7..fc956299 100644 --- a/docs/zh/features/keyboard-shortcuts.mdx +++ b/docs/zh/features/keyboard-shortcuts.mdx @@ -17,6 +17,7 @@ TablePro 以键盘操作为核心。大多数操作都有快捷键,大部分 | 新建连接 | `Cmd+N` | | 打开历史 | `Cmd+Y` | | 设置 | `Cmd+,` | +| Quick Switcher | `Cmd+P` | | 关闭窗口 | `Cmd+W` | | 退出 | `Cmd+Q` | @@ -264,7 +265,7 @@ Vim 模式键绑定仅在 SQL 编辑器中生效。不影响数据网格或其 | 导出数据 | `Cmd+Shift+E` | | 导入数据 | `Cmd+Shift+I` | -## Quick Switcher +## Quick switcher Quick Switcher (`Cmd+P`) 可搜索并快速跳转到任意表、视图、数据库、schema 或最近的查询。支持模糊匹配,输入 `usr` 可找到 `users`、`user_settings` 等。 @@ -451,6 +452,7 @@ Cmd+Shift+L 切换 AI 聊天 Cmd+L AI 解释 Cmd+Opt+L AI 优化 Cmd+, 设置 +Cmd+P Quick Switcher Cmd+W 关闭窗口 Cmd+Q 退出 ```