diff --git a/TablePro/Core/Utilities/UI/FuzzyMatcher.swift b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift new file mode 100644 index 00000000..ceb9caf7 --- /dev/null +++ b/TablePro/Core/Utilities/UI/FuzzyMatcher.swift @@ -0,0 +1,93 @@ +// +// FuzzyMatcher.swift +// TablePro +// +// Standalone fuzzy matching utility for quick switcher search +// + +import Foundation + +/// Namespace for fuzzy string matching operations +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 } + + 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 + } + + // Base match score + var matchScore = 1 + + // Record first match position + if firstMatchPosition < 0 { + firstMatchPosition = candidateIndex + } + + // Consecutive match bonus + consecutiveBonus += 1 + if consecutiveBonus > 1 { + matchScore += consecutiveBonus * 4 + } + + // 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 + } + } + + // Exact case match bonus + if queryChar == candidateChar { + matchScore += 1 + } + + score += matchScore + queryIndex += 1 + candidateIndex += 1 + } + + // All query characters must be matched + guard queryIndex == queryLen else { return 0 } + + // Position bonus + if firstMatchPosition >= 0 { + let positionBonus = max(0, 20 - firstMatchPosition * 2) + score += positionBonus + } + + // Length similarity bonus + 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..eee368f3 --- /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 +internal 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 +internal 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..e5e0cc84 --- /dev/null +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -0,0 +1,222 @@ +// +// 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 +internal 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? + @ObservationIgnored private var activeLoadId = UUID() + + /// 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 + let loadId = UUID() + activeLoadId = loadId + var items: [QuickSwitcherItem] = [] + + // Tables, views, system tables from cached schema + let tables = await schemaProvider.getTables() + for table in tables { + let kind: QuickSwitcherItemKind + let subtitle: String + switch table.type { + case .table: + kind = .table + subtitle = "" + case .view: + kind = .view + subtitle = String(localized: "View") + case .systemTable: + kind = .systemTable + subtitle = String(localized: "System") + } + items.append(QuickSwitcherItem( + id: "table_\(table.name)_\(table.type.rawValue)", + name: table.name, + kind: kind, + subtitle: subtitle + )) + } + + // Databases + if let driver = DatabaseManager.shared.driver(for: connectionId) { + do { + let databases = try await driver.fetchDatabases() + for db in databases { + items.append(QuickSwitcherItem( + id: "db_\(db)", + name: db, + kind: .database, + subtitle: String(localized: "Database") + )) + } + } catch { + Self.logger.debug("Failed to fetch databases for quick switcher: \(error.localizedDescription, privacy: .public)") + } + + // Schemas (only for databases that support them) + let supportsSchemas = [DatabaseType.postgresql, .redshift, .oracle, .mssql] + if supportsSchemas.contains(databaseType) { + do { + let schemas = try await driver.fetchSchemas() + for schema in schemas { + items.append(QuickSwitcherItem( + id: "schema_\(schema)", + name: schema, + kind: .schema, + subtitle: String(localized: "Schema") + )) + } + } catch { + Self.logger.debug("Failed to fetch schemas for quick switcher: \(error.localizedDescription, privacy: .public)") + } + } + } + + // Recent query history (last 50) + let historyEntries = await QueryHistoryStorage.shared.fetchHistory( + limit: 50, + connectionId: connectionId + ) + for entry in historyEntries { + items.append(QuickSwitcherItem( + id: "history_\(entry.id.uuidString)", + name: entry.queryPreview, + kind: .queryHistory, + subtitle: entry.databaseName + )) + } + + guard activeLoadId == loadId, !Task.isCancelled else { + isLoading = false + return + } + + 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 + 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)) + } + } else { + filteredItems = allItems.compactMap { item in + let matchScore = FuzzyMatcher.score(query: searchText, candidate: item.name) + guard matchScore > 0 else { return nil } + var scored = item + scored.score = matchScore + return scored + } + .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)) + } + } + + 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..fbbff23e --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -0,0 +1,37 @@ +// +// MainContentCoordinator+QuickSwitcher.swift +// TablePro +// +// Quick switcher navigation handler for MainContentCoordinator +// + +import Foundation + +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..498d188a --- /dev/null +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -0,0 +1,293 @@ +// +// 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. +internal 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) { + viewModel.moveUp() + return .handled + } + .onKeyPress(.downArrow) { + 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: DesignConstants.IconSize.default)) + .foregroundStyle(isSelected ? .white : .secondary) + + Text(item.name) + .font(.system(size: DesignConstants.FontSize.body)) + .foregroundStyle(isSelected ? .white : .primary) + .lineLimit(1) + .truncationMode(.tail) + + if !item.subtitle.isEmpty { + Text(item.subtitle) + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(isSelected ? Color.white.opacity(0.7) : Color.secondary) + .lineLimit(1) + } + + Spacer() + + Text(item.kindLabel) + .font(.system(size: DesignConstants.FontSize.caption, 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) + } + .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..fdba0a5a --- /dev/null +++ b/TableProTests/Utilities/FuzzyMatcherTests.swift @@ -0,0 +1,125 @@ +// +// FuzzyMatcherTests.swift +// TableProTests +// +// Tests for FuzzyMatcher fuzzy string matching +// + +@testable import TablePro +import Testing + +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) + } + + // 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") + } + + @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 new file mode 100644 index 00000000..9e21735e --- /dev/null +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -0,0 +1,178 @@ +// +// QuickSwitcherViewModelTests.swift +// TableProTests +// +// Tests for QuickSwitcherViewModel filtering and navigation +// + +@testable import TablePro +import Testing + +@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 = "" + 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") + 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) + } + + // 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) + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index f78838fb..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,6 +265,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 @@ -438,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 50290eb4..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,6 +265,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 @@ -440,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 0d0fed10..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,6 +265,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 @@ -438,6 +452,7 @@ Cmd+Shift+L 切换 AI 聊天 Cmd+L AI 解释 Cmd+Opt+L AI 优化 Cmd+, 设置 +Cmd+P Quick Switcher Cmd+W 关闭窗口 Cmd+Q 退出 ```