diff --git a/CHANGELOG.md b/CHANGELOG.md index ce1e2526..9ae0c1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Match highlighting in autocomplete suggestions (matched characters shown in bold) +- Loading spinner in autocomplete popup while fetching column metadata + +### Changed + +- Refactored autocomplete popup to native SwiftUI (visible selection highlight, native accent color, scroll-to-selection) +- Autocomplete now suppresses noisy empty-prefix suggestions in non-browseable contexts (e.g., after SELECT, WHERE) +- Autocomplete ranking stays consistent as you type (unified fuzzy scoring between initial display and live filtering) +- Increased autocomplete suggestion limit from 20 to 40 for schema-heavy contexts (FROM, SELECT, WHERE) + ## [0.20.4] - 2026-03-19 ### Fixed diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift index abf87398..670edea2 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift @@ -23,4 +23,11 @@ public protocol CodeSuggestionEntry { var imageColor: Color { get } var deprecated: Bool { get } + + /// Character index ranges in the label that matched the user's typed prefix. + var matchedRanges: [Range] { get } +} + +public extension CodeSuggestionEntry { + var matchedRanges: [Range] { [] } } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index 34e8f51d..e87e22d3 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -11,6 +11,10 @@ import AppKit final class SuggestionViewModel: ObservableObject { /// The items to be displayed in the window @Published var items: [CodeSuggestionEntry] = [] + @Published var selectedIndex: Int = 0 + @Published var themeBackground: NSColor = .windowBackgroundColor + @Published var themeTextColor: NSColor = .labelColor + var itemsRequestTask: Task? weak var activeTextView: TextViewController? @@ -19,6 +23,51 @@ final class SuggestionViewModel: ObservableObject { private var cursorPosition: CursorPosition? private var syntaxHighlightedCache: [Int: NSAttributedString] = [:] + var selectedItem: CodeSuggestionEntry? { + guard selectedIndex >= 0, selectedIndex < items.count else { return nil } + return items[selectedIndex] + } + + func moveUp() { + guard selectedIndex > 0 else { return } + selectedIndex -= 1 + notifySelection() + } + + func moveDown() { + guard selectedIndex < items.count - 1 else { return } + selectedIndex += 1 + notifySelection() + } + + private func notifySelection() { + if let item = selectedItem { + delegate?.completionWindowDidSelect(item: item) + } + } + + func updateTheme(from textView: TextViewController) { + themeTextColor = textView.theme.text.color + switch textView.systemAppearance { + case .aqua: + let color = textView.theme.background + if color != .clear { + themeBackground = NSColor( + red: color.redComponent * 0.95, + green: color.greenComponent * 0.95, + blue: color.blueComponent * 0.95, + alpha: 1.0 + ) + } else { + themeBackground = .windowBackgroundColor + } + case .darkAqua: + themeBackground = textView.theme.background + default: + break + } + } + func showCompletions( textView: TextViewController, delegate: CodeSuggestionDelegate, @@ -59,7 +108,9 @@ final class SuggestionViewModel: ObservableObject { } self.items = completionItems.items + self.selectedIndex = 0 self.syntaxHighlightedCache = [:] + self.notifySelection() showWindowOnParent(targetParentWindow, cursorRect) } } catch { @@ -91,6 +142,9 @@ final class SuggestionViewModel: ObservableObject { } items = newItems + selectedIndex = 0 + syntaxHighlightedCache = [:] + notifySelection() } func didSelect(item: CodeSuggestionEntry) { @@ -110,8 +164,12 @@ final class SuggestionViewModel: ObservableObject { } func willClose() { + itemsRequestTask?.cancel() + itemsRequestTask = nil items.removeAll() + selectedIndex = 0 activeTextView = nil + delegate = nil } func syntaxHighlights(forIndex index: Int) -> NSAttributedString? { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift deleted file mode 100644 index 7637bff5..00000000 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// CodeSuggestionLabelView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 7/24/25. -// - -import AppKit -import SwiftUI - -struct CodeSuggestionLabelView: View { - static let HORIZONTAL_PADDING: CGFloat = 13 - - let suggestion: CodeSuggestionEntry - let labelColor: NSColor - let secondaryLabelColor: NSColor - let font: NSFont - - var body: some View { - HStack(alignment: .center, spacing: 2) { - suggestion.image - .font(.system(size: font.pointSize + 2)) - .foregroundStyle( - .white, - suggestion.deprecated ? .gray : suggestion.imageColor - ) - - // Main label - HStack(spacing: font.charWidth) { - Text(suggestion.label) - .foregroundStyle(suggestion.deprecated ? Color(secondaryLabelColor) : Color(labelColor)) - - if let detail = suggestion.detail { - Text(detail) - .foregroundStyle(Color(secondaryLabelColor)) - } - } - .font(Font(font)) - - Spacer(minLength: 0) - - // Right side indicators - if suggestion.deprecated { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: font.pointSize + 2)) - .foregroundStyle(Color(labelColor), Color(secondaryLabelColor)) - } - } - .padding(.vertical, 3) - .padding(.horizontal, Self.HORIZONTAL_PADDING) - .buttonStyle(PlainButtonStyle()) - } -} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift deleted file mode 100644 index bcf0f4fa..00000000 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// CodeSuggestionPreviewView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 7/28/25. -// - -import SwiftUI - -final class CodeSuggestionPreviewView: NSVisualEffectView { - private let spacing: CGFloat = 5 - - var sourcePreview: NSAttributedString? { - didSet { - sourcePreviewLabel.attributedStringValue = sourcePreview ?? NSAttributedString(string: "") - sourcePreviewLabel.isHidden = sourcePreview == nil - } - } - - var documentation: String? { - didSet { - documentationLabel.stringValue = documentation ?? "" - documentationLabel.isHidden = documentation == nil - } - } - - var pathComponents: [String] = [] { - didSet { - configurePathComponentsLabel() - } - } - - var targetRange: CursorPosition? { - didSet { - configurePathComponentsLabel() - } - } - - var font: NSFont = .systemFont(ofSize: 12) { - didSet { - sourcePreviewLabel.font = font - pathComponentsLabel.font = .systemFont(ofSize: font.pointSize) - } - } - var documentationFont: NSFont = .systemFont(ofSize: 12) { - didSet { - documentationLabel.font = documentationFont - } - } - - var stackView: NSStackView = NSStackView() - var dividerView: NSView = NSView() - var sourcePreviewLabel: NSTextField = NSTextField() - var documentationLabel: NSTextField = NSTextField() - var pathComponentsLabel: NSTextField = NSTextField() - - init() { - super.init(frame: .zero) - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.spacing = spacing - stackView.orientation = .vertical - stackView.alignment = .leading - stackView.setContentCompressionResistancePriority(.required, for: .vertical) - stackView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - addSubview(stackView) - - dividerView.translatesAutoresizingMaskIntoConstraints = false - dividerView.wantsLayer = true - dividerView.layer?.backgroundColor = NSColor.separatorColor.cgColor - addSubview(dividerView) - - self.material = .windowBackground - self.blendingMode = .behindWindow - - styleStaticLabel(sourcePreviewLabel) - styleStaticLabel(documentationLabel) - styleStaticLabel(pathComponentsLabel) - - pathComponentsLabel.maximumNumberOfLines = 1 - pathComponentsLabel.lineBreakMode = .byTruncatingMiddle - pathComponentsLabel.usesSingleLineMode = true - - stackView.addArrangedSubview(sourcePreviewLabel) - stackView.addArrangedSubview(documentationLabel) - stackView.addArrangedSubview(pathComponentsLabel) - - NSLayoutConstraint.activate([ - dividerView.topAnchor.constraint(equalTo: topAnchor), - dividerView.leadingAnchor.constraint(equalTo: leadingAnchor), - dividerView.trailingAnchor.constraint(equalTo: trailingAnchor), - dividerView.heightAnchor.constraint(equalToConstant: 1), - - stackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: spacing), - stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -SuggestionController.WINDOW_PADDING), - stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 13), - stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -13) - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func hideIfEmpty() { - isHidden = sourcePreview == nil && documentation == nil && pathComponents.isEmpty - } - - func setPreferredMaxLayoutWidth(width: CGFloat) { - sourcePreviewLabel.preferredMaxLayoutWidth = width - documentationLabel.preferredMaxLayoutWidth = width - pathComponentsLabel.preferredMaxLayoutWidth = width - } - - private func styleStaticLabel(_ label: NSTextField) { - label.isEditable = false - label.isSelectable = true - label.allowsDefaultTighteningForTruncation = false - label.isBezeled = false - label.isBordered = false - label.backgroundColor = .clear - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - } - - private func configurePathComponentsLabel() { - pathComponentsLabel.isHidden = pathComponents.isEmpty - - let folder = NSTextAttachment() - folder.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)? - .withSymbolConfiguration( - .init(paletteColors: [NSColor.systemBlue]).applying(.init(pointSize: font.pointSize, weight: .regular)) - ) - - let string: NSMutableAttributedString = NSMutableAttributedString(attachment: folder) - string.append(NSAttributedString(string: " ")) - - let separator = NSTextAttachment() - separator.image = NSImage(systemSymbolName: "chevron.compact.right", accessibilityDescription: nil)? - .withSymbolConfiguration( - .init(paletteColors: [NSColor.labelColor]) - .applying(.init(pointSize: font.pointSize + 1, weight: .regular)) - ) - - for (idx, component) in pathComponents.enumerated() { - string.append(NSAttributedString(string: component, attributes: [.foregroundColor: NSColor.labelColor])) - if idx != pathComponents.count - 1 { - string.append(NSAttributedString(string: " ")) - string.append(NSAttributedString(attachment: separator)) - string.append(NSAttributedString(string: " ")) - } - } - - if let targetRange { - string.append(NSAttributedString(string: ":\(targetRange.start.line)")) - if targetRange.start.column > 1 { - string.append(NSAttributedString(string: ":\(targetRange.start.column)")) - } - } - if let paragraphStyle = NSMutableParagraphStyle.default.mutableCopy() as? NSMutableParagraphStyle { - paragraphStyle.lineBreakMode = .byTruncatingMiddle - string.addAttribute( - .paragraphStyle, - value: paragraphStyle, - range: NSRange(location: 0, length: string.length) - ) - } - - pathComponentsLabel.attributedStringValue = string - } -} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionRowView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionRowView.swift deleted file mode 100644 index 6e7d7e19..00000000 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionRowView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// CodeSuggestionRowView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 7/22/25. -// - -import AppKit - -/// Used to draw a custom selection highlight for the table row -final class CodeSuggestionRowView: NSTableRowView { - var getSelectionColor: (() -> NSColor)? - - init(getSelectionColor: (() -> NSColor)? = nil) { - self.getSelectionColor = getSelectionColor - super.init(frame: .zero) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func drawSelection(in dirtyRect: NSRect) { - guard isSelected else { return } - guard let context = NSGraphicsContext.current?.cgContext else { return } - - context.saveGState() - defer { context.restoreGState() } - - // Create a rect that's inset from the edges and has proper padding - // TODO: We create a new selectionRect instead of using dirtyRect - // because there is a visual bug when holding down the arrow keys - // to select the first or last item, which draws a clipped - // rectangular highlight shape instead of the whole rectangle. - // Replace this when it gets fixed. - let selectionRect = NSRect( - x: SuggestionController.WINDOW_PADDING, - y: 0, - width: bounds.width - (SuggestionController.WINDOW_PADDING * 2), - height: bounds.height - ) - let cornerRadius: CGFloat = 5 - let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) - let selectionColor = getSelectionColor?() ?? NSColor.controlBackgroundColor - - context.setFillColor(selectionColor.cgColor) - path.fill() - } -} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/NoSlotScroller.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/NoSlotScroller.swift deleted file mode 100644 index 9d194f28..00000000 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/NoSlotScroller.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// NoSlotScroller.swift -// CodeEditSourceEditor -// -// Created by Abe Malla on 12/26/24. -// - -import AppKit - -class NoSlotScroller: NSScroller { - override class var isCompatibleWithOverlayScrollers: Bool { true } - - override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { - // Don't draw the knob slot (the background track behind the knob) - } -} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift deleted file mode 100644 index c9b57610..00000000 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift +++ /dev/null @@ -1,346 +0,0 @@ -// -// SuggestionViewController.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 7/22/25. -// - -import AppKit -import SwiftUI -import Combine - -class SuggestionViewController: NSViewController { - var tintView: NSView = NSView() - var tableView: NSTableView = NSTableView() - var scrollView: NSScrollView = NSScrollView() - var noItemsLabel: NSTextField = NSTextField(labelWithString: "No Completions") - var previewView: CodeSuggestionPreviewView = CodeSuggestionPreviewView() - - var scrollViewHeightConstraint: NSLayoutConstraint? - var viewHeightConstraint: NSLayoutConstraint? - var viewWidthConstraint: NSLayoutConstraint? - - var itemObserver: AnyCancellable? - var cachedFont: NSFont? - - weak var model: SuggestionViewModel? { - didSet { - itemObserver?.cancel() - itemObserver = model?.$items.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.onItemsUpdated() - } - } - } - - /// An event monitor for keyboard events - private var localEventMonitor: Any? - - weak var windowController: SuggestionController? - - override func loadView() { - super.loadView() - view.wantsLayer = true - view.layer?.cornerRadius = 8.5 - view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor - - tintView.translatesAutoresizingMaskIntoConstraints = false - tintView.wantsLayer = true - tintView.layer?.cornerRadius = 8.5 - tintView.layer?.backgroundColor = .clear - view.addSubview(tintView) - - configureTableView() - configureScrollView() - - noItemsLabel.textColor = .secondaryLabelColor - noItemsLabel.alignment = .center - noItemsLabel.translatesAutoresizingMaskIntoConstraints = false - noItemsLabel.isHidden = false - - previewView.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(noItemsLabel) - view.addSubview(scrollView) - view.addSubview(previewView) - - NSLayoutConstraint.activate([ - tintView.topAnchor.constraint(equalTo: view.topAnchor), - tintView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - tintView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tintView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - - noItemsLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - noItemsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 10), - noItemsLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10), - - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: previewView.topAnchor), - - previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - } - - override func viewWillAppear() { - super.viewWillAppear() - resetScrollPosition() - tableView.reloadData() - if let controller = model?.activeTextView { - styleView(using: controller) - } - setupEventMonitors() - } - - override func viewWillDisappear() { - super.viewWillDisappear() - if let monitor = localEventMonitor { - NSEvent.removeMonitor(monitor) - localEventMonitor = nil - } - } - - private func setupEventMonitors() { - if let monitor = localEventMonitor { - NSEvent.removeMonitor(monitor) - localEventMonitor = nil - } - localEventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.keyDown] - ) { [weak self] event in - guard let self = self else { return event } - - switch event.type { - case .keyDown: - return checkKeyDownEvents(event) - default: - return event - } - } - } - - private func checkKeyDownEvents(_ event: NSEvent) -> NSEvent? { - switch event.keyCode { - case 53: // Escape - windowController?.close() - return nil - - case 125, 126: // Down/Up Arrow - tableView.keyDown(with: event) - return nil - - case 36, 48: // Return/Tab - self.applySelectedItem() - return nil - - default: - return event - } - } - - func styleView(using controller: TextViewController) { - noItemsLabel.font = controller.font - previewView.font = controller.font - previewView.documentationFont = controller.font - switch controller.systemAppearance { - case .aqua: - let color = controller.theme.background - if color != .clear { - let newColor = NSColor( - red: color.redComponent * 0.95, - green: color.greenComponent * 0.95, - blue: color.blueComponent * 0.95, - alpha: 1.0 - ) - tintView.layer?.backgroundColor = newColor.cgColor - } else { - tintView.layer?.backgroundColor = .clear - } - case .darkAqua: - tintView.layer?.backgroundColor = controller.theme.background.cgColor - default: - return - } - updateSize(using: controller) - } - - func updateSize(using controller: TextViewController?) { - guard model?.items.isEmpty == false && tableView.numberOfRows > 0 else { - let size = NSSize(width: 256, height: noItemsLabel.fittingSize.height + 20) - preferredContentSize = size - windowController?.updateWindowSize(newSize: size) - return - } - - if controller != nil { - cachedFont = controller?.font - } - - guard let rowView = tableView.view(atColumn: 0, row: 0, makeIfNecessary: true) else { - return - } - - let maxLength = min( - (model?.items.reduce(0, { max($0, $1.label.count + ($1.detail?.count ?? 0)) }) ?? 16) + 4, - 64 - ) - let newWidth = max( // minimum width = 256px, horizontal item padding = 13px - CGFloat(maxLength) * (controller?.font ?? cachedFont ?? NSFont.systemFont(ofSize: 12)).charWidth + 26, - 256 - ) - - let rowHeight = rowView.fittingSize.height - - let numberOfVisibleRows = min(CGFloat(model?.items.count ?? 0), SuggestionController.MAX_VISIBLE_ROWS) - previewView.setPreferredMaxLayoutWidth(width: newWidth) - var newHeight = rowHeight * numberOfVisibleRows + SuggestionController.WINDOW_PADDING * 2 - - viewHeightConstraint?.isActive = false - viewWidthConstraint?.isActive = false - scrollViewHeightConstraint?.isActive = false - - scrollViewHeightConstraint = scrollView.heightAnchor.constraint(equalToConstant: newHeight) - newHeight += previewView.fittingSize.height - viewHeightConstraint = view.heightAnchor.constraint(equalToConstant: newHeight) - viewWidthConstraint = view.widthAnchor.constraint(equalToConstant: newWidth) - - viewHeightConstraint?.isActive = true - viewWidthConstraint?.isActive = true - scrollViewHeightConstraint?.isActive = true - - view.updateConstraintsForSubtreeIfNeeded() - view.layoutSubtreeIfNeeded() - - let newSize = NSSize(width: newWidth, height: newHeight) - preferredContentSize = newSize - windowController?.updateWindowSize(newSize: newSize) - } - - func configureTableView() { - tableView.delegate = self - tableView.dataSource = self - tableView.headerView = nil - tableView.backgroundColor = .clear - tableView.intercellSpacing = .zero - tableView.allowsEmptySelection = false - tableView.selectionHighlightStyle = .regular - tableView.style = .plain - tableView.usesAutomaticRowHeights = true - tableView.gridStyleMask = [] - tableView.target = self - tableView.action = #selector(tableViewClicked(_:)) - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) - tableView.addTableColumn(column) - } - - func configureScrollView() { - scrollView.documentView = tableView - scrollView.hasVerticalScroller = true - scrollView.verticalScroller = NoSlotScroller() - scrollView.scrollerStyle = .overlay - scrollView.autohidesScrollers = true - scrollView.drawsBackground = false - scrollView.automaticallyAdjustsContentInsets = false - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.verticalScrollElasticity = .allowed - scrollView.contentInsets = NSEdgeInsets( - top: SuggestionController.WINDOW_PADDING, - left: 0, - bottom: SuggestionController.WINDOW_PADDING, - right: 0 - ) - } - - func onItemsUpdated() { - resetScrollPosition() - if let model { - noItemsLabel.isHidden = !model.items.isEmpty - scrollView.isHidden = model.items.isEmpty - previewView.isHidden = model.items.isEmpty - } - tableView.reloadData() - if let activeTextView = model?.activeTextView { - updateSize(using: activeTextView) - } - } - - @objc private func tableViewClicked(_ sender: Any?) { - if NSApp.currentEvent?.clickCount == 2 { - applySelectedItem() - } - } - - private func resetScrollPosition() { - let clipView = scrollView.contentView - - // Scroll to the top of the content - clipView.scroll(to: NSPoint(x: 0, y: -SuggestionController.WINDOW_PADDING)) - - // Select the first item - if model?.items.isEmpty == false { - tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) - } - } - - func applySelectedItem() { - let row = tableView.selectedRow - guard row >= 0, row < model?.items.count ?? 0 else { - return - } - if let model { - model.applySelectedItem(item: model.items[tableView.selectedRow], window: view.window) - } - } -} - -extension SuggestionViewController: NSTableViewDataSource, NSTableViewDelegate { - public func numberOfRows(in tableView: NSTableView) -> Int { - model?.items.count ?? 0 - } - - public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - guard let model = model, - row >= 0, row < model.items.count, - let textView = model.activeTextView else { - return nil - } - return NSHostingView( - rootView: CodeSuggestionLabelView( - suggestion: model.items[row], - labelColor: textView.theme.text.color, - secondaryLabelColor: textView.theme.text.color.withAlphaComponent(0.5), - font: textView.font - ) - ) - } - - public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { - CodeSuggestionRowView { [weak self] in - self?.model?.activeTextView?.theme.background ?? NSColor.controlBackgroundColor - } - } - - public func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { - // Only allow selection through keyboard navigation or single clicks - NSApp.currentEvent?.type != .leftMouseDragged - } - - public func tableViewSelectionDidChange(_ notification: Notification) { - guard tableView.selectedRow >= 0 else { return } - if let model { - // Update our preview view - let selectedItem = model.items[tableView.selectedRow] - - previewView.sourcePreview = model.syntaxHighlights(forIndex: tableView.selectedRow) - previewView.documentation = selectedItem.documentation - previewView.pathComponents = selectedItem.pathComponents ?? [] - previewView.targetRange = selectedItem.targetPosition - previewView.hideIfEmpty() - updateSize(using: nil) - - model.didSelect(item: selectedItem) - } - } -} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/CodeSuggestionLabelView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/CodeSuggestionLabelView.swift new file mode 100644 index 00000000..aa3457b9 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/CodeSuggestionLabelView.swift @@ -0,0 +1,101 @@ +// +// CodeSuggestionLabelView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/24/25. +// + +import AppKit +import SwiftUI + +struct CodeSuggestionLabelView: View { + static let HORIZONTAL_PADDING: CGFloat = 13 + + let suggestion: CodeSuggestionEntry + let labelColor: NSColor + let secondaryLabelColor: NSColor + let font: NSFont + var isSelected: Bool = false + + private var effectiveLabelColor: Color { + if isSelected { + return .white + } + return suggestion.deprecated ? Color(secondaryLabelColor) : Color(labelColor) + } + + private var effectiveSecondaryColor: Color { + if isSelected { + return Color.white.opacity(0.7) + } + return Color(secondaryLabelColor) + } + + // swiftlint:disable shorthand_operator + private func highlightedLabel() -> Text { + let nsLabel = suggestion.label as NSString + let ranges = suggestion.matchedRanges + let color = effectiveLabelColor + + guard !ranges.isEmpty else { + return Text(suggestion.label).foregroundColor(color) + } + + var result = Text("") + var currentIndex = 0 + + for range in ranges { + let clampedUpper = min(range.upperBound, nsLabel.length) + guard range.lowerBound < clampedUpper else { continue } + + if currentIndex < range.lowerBound { + let segment = nsLabel.substring(with: NSRange(location: currentIndex, length: range.lowerBound - currentIndex)) + result = result + Text(segment).foregroundColor(color) + } + + let segment = nsLabel.substring(with: NSRange(location: range.lowerBound, length: clampedUpper - range.lowerBound)) + result = result + Text(segment).foregroundColor(color).bold() + currentIndex = clampedUpper + } + + if currentIndex < nsLabel.length { + let segment = nsLabel.substring(from: currentIndex) + result = result + Text(segment).foregroundColor(color) + } + + return result + } + // swiftlint:enable shorthand_operator + + var body: some View { + HStack(alignment: .center, spacing: 2) { + suggestion.image + .font(.system(size: font.pointSize + 2)) + .foregroundStyle( + .white, + suggestion.deprecated ? .gray : suggestion.imageColor + ) + + HStack(spacing: font.charWidth) { + highlightedLabel() + + if let detail = suggestion.detail { + Text(detail) + .foregroundStyle(effectiveSecondaryColor) + } + } + .font(Font(font)) + + Spacer(minLength: 0) + + if suggestion.deprecated { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: font.pointSize + 2)) + .foregroundStyle(effectiveLabelColor, effectiveSecondaryColor) + } + } + .padding(.vertical, 3) + .padding(.horizontal, Self.HORIZONTAL_PADDING) + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift new file mode 100644 index 00000000..301e7e91 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift @@ -0,0 +1,114 @@ +// +// SuggestionContentView.swift +// CodeEditSourceEditor +// +// Created by Claude on 2026-03-19. +// + +import AppKit +import SwiftUI + +struct SuggestionContentView: View { + @ObservedObject var model: SuggestionViewModel + + var body: some View { + VStack(spacing: 0) { + if model.items.isEmpty { + noCompletionsView + } else { + suggestionList + if let item = model.selectedItem, + item.documentation != nil || item.sourcePreview != nil + || (item.pathComponents != nil && !(item.pathComponents?.isEmpty ?? true)) { + Divider() + SuggestionPreviewView( + item: item, + syntaxHighlight: model.syntaxHighlights(forIndex: model.selectedIndex), + font: model.activeTextView?.font ?? .systemFont(ofSize: 12) + ) + } + } + } + .frame(width: contentWidth) + .background(Color(nsColor: model.themeBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8.5)) + } + + private var suggestionList: some View { + ScrollViewReader { proxy in + List { + ForEach(Array(model.items.enumerated()), id: \.offset) { index, item in + CodeSuggestionLabelView( + suggestion: item, + labelColor: model.themeTextColor, + secondaryLabelColor: model.themeTextColor.withAlphaComponent(0.5), + font: model.activeTextView?.font ?? .systemFont(ofSize: 12), + isSelected: index == model.selectedIndex + ) + .lineLimit(1) + .truncationMode(.tail) + .contentShape(Rectangle()) + .onTapGesture(count: 1) { + model.selectedIndex = index + } + .onTapGesture(count: 2) { + model.selectedIndex = index + if let selectedItem = model.selectedItem { + model.applySelectedItem( + item: selectedItem, + window: model.activeTextView?.view.window + ) + } + } + .listRowInsets(EdgeInsets()) + .listRowBackground( + RoundedRectangle(cornerRadius: 5) + .fill(index == model.selectedIndex + ? Color(nsColor: .selectedContentBackgroundColor) + : Color.clear) + .padding(.horizontal, SuggestionController.WINDOW_PADDING) + ) + .listRowSeparator(.hidden) + .id(index) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .padding(.vertical, SuggestionController.WINDOW_PADDING) + .frame(height: listMaxHeight) + .onChange(of: model.selectedIndex) { newIndex in + withAnimation(.easeInOut(duration: 0.1)) { + proxy.scrollTo(newIndex, anchor: .center) + } + } + } + } + + private var contentWidth: CGFloat { + let font = model.activeTextView?.font ?? NSFont.systemFont(ofSize: 12) + let iconWidth = font.pointSize + 6 + let maxLabelLength = min( + model.items.reduce(0) { current, item in + let labelLen = (item.label as NSString).length + let detailLen = ((item.detail ?? "") as NSString).length + return max(current, labelLen + detailLen) + } + 2, + 64 + ) + let textWidth = CGFloat(maxLabelLength) * font.charWidth + return max(iconWidth + textWidth + CodeSuggestionLabelView.HORIZONTAL_PADDING * 2, 280) + } + + private var listMaxHeight: CGFloat { + let rowHeight: CGFloat = 26 + let visibleRows = min(CGFloat(model.items.count), SuggestionController.MAX_VISIBLE_ROWS) + return rowHeight * visibleRows + SuggestionController.WINDOW_PADDING * 2 + } + + private var noCompletionsView: some View { + Text("No Completions") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } +} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionPreviewView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionPreviewView.swift new file mode 100644 index 00000000..af6cfec4 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionPreviewView.swift @@ -0,0 +1,62 @@ +// +// SuggestionPreviewView.swift +// CodeEditSourceEditor +// +// Created by Claude on 2026-03-19. +// + +import AppKit +import SwiftUI + +struct SuggestionPreviewView: View { + let item: CodeSuggestionEntry + let syntaxHighlight: NSAttributedString? + let font: NSFont + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let highlighted = syntaxHighlight { + Text(AttributedString(highlighted)) + .textSelection(.enabled) + } + if let doc = item.documentation { + Text(doc) + .font(Font(font)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + if let components = item.pathComponents, !components.isEmpty { + pathBreadcrumb(components) + } + } + .padding(.horizontal, 13) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func pathBreadcrumb(_ components: [String]) -> some View { + HStack(spacing: 2) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + .font(.system(size: font.pointSize - 2)) + ForEach(Array(components.enumerated()), id: \.offset) { index, component in + if index > 0 { + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + .font(.system(size: font.pointSize - 4)) + } + Text(component) + .font(Font(font)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let pos = item.targetPosition { + Text(":\(pos.start.line):\(pos.start.column)") + .font(Font(font)) + .foregroundStyle(.tertiary) + } + } + } +} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index 48edf993..2dd51130 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -6,6 +6,7 @@ // import AppKit +import SwiftUI extension SuggestionController { /// Will constrain the window's frame to be within the visible screen @@ -79,6 +80,14 @@ extension SuggestionController { } } + func updateWindowSizeFromContent() { + guard let hostingView = window?.contentView as? NSHostingView else { return } + let fitting = hostingView.fittingSize + let minWidth: CGFloat = 256 + let newSize = NSSize(width: max(fitting.width, minWidth), height: fitting.height) + updateWindowSize(newSize: newSize) + } + // MARK: - Private Methods static func makeWindow() -> NSWindow { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index d8c32a51..91ee0de3 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -8,6 +8,7 @@ import AppKit import CodeEditTextView import Combine +import SwiftUI public final class SuggestionController: NSWindowController { static var shared: SuggestionController = SuggestionController() @@ -35,19 +36,43 @@ public final class SuggestionController: NSWindowController { /// Holds the observer for the window resign notifications private var windowResignObserver: NSObjectProtocol? + /// Closes autocomplete when first responder changes away from the active text view + private var firstResponderObserver: NSObjectProtocol? + private var localEventMonitor: Any? + private var sizeObservers: Set = [] // MARK: - Initialization public init() { let window = Self.makeWindow() - - let controller = SuggestionViewController() - controller.model = model - window.contentViewController = controller - super.init(window: window) - controller.windowController = self + let contentView = SuggestionContentView(model: model) + let hostingView = NSHostingView(rootView: contentView) + window.contentView = hostingView + + // Resize window when items change + model.$items + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateWindowSizeFromContent() + } + .store(in: &sizeObservers) + + // Resize window only when preview visibility changes (not every arrow key) + model.$selectedIndex + .receive(on: DispatchQueue.main) + .map { [weak self] index -> Bool in + guard let self, index >= 0, index < self.model.items.count else { return false } + let item = self.model.items[index] + return item.documentation != nil || item.sourcePreview != nil + || !(item.pathComponents?.isEmpty ?? true) + } + .removeDuplicates() + .sink { [weak self] _ in + self?.updateWindowSizeFromContent() + } + .store(in: &sizeObservers) if window.isVisible { window.close() @@ -71,6 +96,8 @@ public final class SuggestionController: NSWindowController { delegate: delegate, cursorPosition: cursorPosition ) { parentWindow, cursorRect in + self.model.updateTheme(from: textView) + if asPopover { self.popover?.close() self.popover = nil @@ -80,22 +107,13 @@ public final class SuggestionController: NSWindowController { let popover = NSPopover() popover.behavior = .transient - let controller = SuggestionViewController() - controller.model = self.model - controller.windowController = self - controller.tableView.reloadData() - controller.styleView(using: textView) - + let controller = NSHostingController(rootView: SuggestionContentView(model: self.model)) popover.contentViewController = controller popover.show(relativeTo: textViewPosition, of: textView.textView, preferredEdge: .maxY) self.popover = popover } else { self.showWindow(attachedTo: parentWindow) self.constrainWindowToScreenEdges(cursorRect: cursorRect, font: textView.font) - - if let controller = self.contentViewController as? SuggestionViewController { - controller.styleView(using: textView) - } } } } @@ -105,8 +123,6 @@ public final class SuggestionController: NSWindowController { guard let window = window else { return } parentWindow.addChildWindow(window, ordered: .above) - // Close on window switch observer - // Initialized outside of `setupEventMonitors` in order to grab the parent window if let existingObserver = windowResignObserver { NotificationCenter.default.removeObserver(existingObserver) } @@ -118,20 +134,52 @@ public final class SuggestionController: NSWindowController { self?.close() } + // Close when the active text view is removed (e.g., tab closed/switched) + if let existingObserver = firstResponderObserver { + NotificationCenter.default.removeObserver(existingObserver) + } + firstResponderObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: parentWindow, + queue: .main + ) { [weak self] _ in + guard let self else { return } + guard let textView = self.model.activeTextView else { + self.close() + return + } + // Close if text view removed from window or lost first responder + if textView.view.window == nil { + self.close() + } else if let firstResponder = textView.view.window?.firstResponder as? NSView, + !firstResponder.isDescendant(of: textView.view) { + self.close() + } + } + + setupEventMonitors() super.showWindow(nil) window.orderFront(nil) - window.contentViewController?.viewWillAppear() } /// Close the window public override func close() { model.willClose() + removeEventMonitors() + + if let observer = windowResignObserver { + NotificationCenter.default.removeObserver(observer) + windowResignObserver = nil + } + + if let observer = firstResponderObserver { + NotificationCenter.default.removeObserver(observer) + firstResponderObserver = nil + } if popover != nil { popover?.close() popover = nil - } else { - contentViewController?.viewWillDisappear() } super.close() @@ -163,4 +211,45 @@ public final class SuggestionController: NSWindowController { } } } + + // MARK: - Keyboard Event Monitoring + + private func setupEventMonitors() { + removeEventMonitors() + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + + // Close if the active text view was removed from its window (e.g., tab closed) + if self.model.activeTextView == nil || self.model.activeTextView?.view.window == nil { + self.close() + return event + } + + switch event.keyCode { + case 53: // Escape + self.close() + return nil + case 125: // Down Arrow + self.model.moveDown() + return nil + case 126: // Up Arrow + self.model.moveUp() + return nil + case 36, 48: // Return, Tab + if let item = self.model.selectedItem { + self.model.applySelectedItem(item: item, window: self.window) + } + return nil + default: + return event + } + } + } + + private func removeEventMonitors() { + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + } } diff --git a/TablePro/Core/Autocomplete/CompletionEngine.swift b/TablePro/Core/Autocomplete/CompletionEngine.swift index 8238c9a4..a4e7c860 100644 --- a/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -19,7 +19,7 @@ struct CompletionContext { final class CompletionEngine { // MARK: - Properties - private let provider: SQLCompletionProvider + let provider: SQLCompletionProvider /// Size threshold (in UTF-16 code units) above which we extract a local /// window around the cursor instead of passing the full document to the diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index 532ff756..8346cc19 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -76,6 +76,7 @@ struct SQLCompletionItem: Identifiable, Hashable { let documentation: String? // Tooltip/description var sortPriority: Int // For ranking (lower = higher priority) let filterText: String // Text used for matching + var matchedRanges: [Range] = [] init( label: String, diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 9d9673a9..a3ac78de 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -22,8 +22,20 @@ final class SQLCompletionProvider { /// Minimum prefix length to trigger suggestions private let minPrefixLength = 1 - /// Maximum number of suggestions to return - private let maxSuggestions = 20 + /// Default maximum number of suggestions to return + private let defaultMaxSuggestions = 20 + + /// Context-aware suggestion limit: schema-heavy clauses get more results + private func maxSuggestions(for clauseType: SQLClauseType) -> Int { + switch clauseType { + case .from, .join, .into, .dropObject, .createIndex, + .select, .where_, .and, .on, .having, .groupBy, .orderBy, + .set, .insertColumns, .returning, .using: + return 40 + default: + return defaultMaxSuggestions + } + } // MARK: - Init @@ -65,16 +77,17 @@ final class SQLCompletionProvider { // Get candidates based on context var candidates = await getCandidates(for: context) - // Filter by prefix + // Filter by prefix and compute match highlight ranges if !context.prefix.isEmpty { candidates = filterByPrefix(candidates, prefix: context.prefix) + populateMatchRanges(&candidates, prefix: context.prefix) } // Rank results candidates = rankResults(candidates, prefix: context.prefix, context: context) // Limit results - let limited = Array(candidates.prefix(maxSuggestions)) + let limited = Array(candidates.prefix(maxSuggestions(for: context.clauseType))) return (limited, context) } @@ -478,8 +491,17 @@ final class SQLCompletionProvider { // MARK: - Filtering + /// Filter and rank items by prefix, returning sorted results with match ranges + func filterAndRank(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { + var filtered = filterByPrefix(items, prefix: prefix) + // Clear stale match ranges before recomputing + for i in filtered.indices { filtered[i].matchedRanges = [] } + populateMatchRanges(&filtered, prefix: prefix) + return rankResults(filtered, prefix: prefix, context: context) + } + /// Filter candidates by prefix (case-insensitive) with fuzzy matching support - private func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { + func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { guard !prefix.isEmpty else { return items } let lowerPrefix = prefix.lowercased() @@ -503,7 +525,7 @@ final class SQLCompletionProvider { /// Fuzzy matching with scoring: returns penalty score (higher = worse), /// nil = no match. Uses NSString character-at-index for O(1) random /// access instead of Swift String indexing (LP-9). - private func fuzzyMatchScore(pattern: String, target: String) -> Int? { + func fuzzyMatchScore(pattern: String, target: String) -> Int? { let nsPattern = pattern as NSString let nsTarget = target as NSString let patternLen = nsPattern.length @@ -552,10 +574,98 @@ final class SQLCompletionProvider { fuzzyMatchScore(pattern: pattern, target: target) != nil } + /// Fuzzy matching that returns both score and matched character indices + private func fuzzyMatchWithIndices(pattern: String, target: String) -> (score: Int, indices: [Int])? { + let nsPattern = pattern as NSString + let nsTarget = target as NSString + let patternLen = nsPattern.length + let targetLen = nsTarget.length + + guard patternLen > 0, targetLen > 0 else { return nil } + + var patternIdx = 0 + var targetIdx = 0 + var gaps = 0 + var consecutiveMatches = 0 + var maxConsecutive = 0 + var lastMatchIdx = -1 + var matchedIndices: [Int] = [] + + while patternIdx < patternLen && targetIdx < targetLen { + let pChar = nsPattern.character(at: patternIdx) + let tChar = nsTarget.character(at: targetIdx) + + if pChar == tChar { + matchedIndices.append(targetIdx) + if lastMatchIdx == targetIdx - 1 { + consecutiveMatches += 1 + maxConsecutive = max(maxConsecutive, consecutiveMatches) + } else { + if lastMatchIdx >= 0 { + gaps += targetIdx - lastMatchIdx - 1 + } + consecutiveMatches = 1 + } + lastMatchIdx = targetIdx + patternIdx += 1 + } + targetIdx += 1 + } + + guard patternIdx == patternLen else { return nil } + + let basePenalty = 50 + let gapPenalty = gaps * 10 + let consecutiveBonus = maxConsecutive * 15 + let score = max(0, basePenalty + gapPenalty - consecutiveBonus) + return (score, matchedIndices) + } + + /// Populate matchedRanges on each item based on how it matched the prefix + private func populateMatchRanges(_ items: inout [SQLCompletionItem], prefix: String) { + guard !prefix.isEmpty else { return } + let lowerPrefix = prefix.lowercased() + let nsPrefix = lowerPrefix as NSString + + for i in items.indices { + let nsFilterText = items[i].filterText as NSString + let prefixRange = nsFilterText.range(of: lowerPrefix, options: .anchored) + if prefixRange.location != NSNotFound { + items[i].matchedRanges = [0.. [Range] { + guard !indices.isEmpty else { return [] } + var ranges: [Range] = [] + var start = indices[0] + var end = indices[0] + for i in 1.. [SQLCompletionItem] { + func rankResults(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { let lowerPrefix = prefix.lowercased() return items.sorted { a, b in @@ -566,7 +676,7 @@ final class SQLCompletionProvider { } /// Calculate ranking score for an item (lower = better) - private func calculateScore(for item: SQLCompletionItem, prefix: String, context: SQLContext) -> Int { + func calculateScore(for item: SQLCompletionItem, prefix: String, context: SQLContext) -> Int { var score = item.sortPriority // Exact prefix match bonus diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index b9c04e55..a922c448 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -101,6 +101,18 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { return nil } + // Suppress noisy completions when prefix is empty in contexts where + // browsing all items isn't useful (e.g., after "SELECT " or "WHERE ") + if context.sqlContext.prefix.isEmpty && context.sqlContext.dotPrefix == nil { + switch context.sqlContext.clauseType { + case .from, .join, .into, .set, .insertColumns, .on, + .alterTableColumn, .returning, .using, .dropObject, .createIndex: + break // Allow empty-prefix completions for these browseable contexts + default: + return nil + } + } + self.currentCompletionContext = context let entries: [CodeSuggestionEntry] = context.items.map { item in @@ -114,14 +126,13 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { textView: TextViewController, cursorPosition: CursorPosition ) -> [CodeSuggestionEntry]? { - // Filter existing completions based on new cursor position - guard let context = currentCompletionContext else { return nil } + guard let context = currentCompletionContext, + let provider = completionEngine?.provider else { return nil } let text = textView.text let offset = cursorPosition.range.location let nsText = text as NSString - // Extract current prefix from replacement range start to cursor let prefixStart = context.replacementRange.location guard offset >= prefixStart, offset <= nsText.length else { return nil } @@ -131,15 +142,9 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { guard !currentPrefix.isEmpty else { return nil } - let filtered = context.items.filter { item in - let filterText = item.filterText.lowercased() - // 3-tier matching: prefix > contains > fuzzy (consistent with initial trigger) - if filterText.hasPrefix(currentPrefix) { return true } - if filterText.contains(currentPrefix) { return true } - return Self.fuzzyMatch(pattern: currentPrefix, target: filterText) - } + let ranked = provider.filterAndRank(context.items, prefix: currentPrefix, context: context.sqlContext) - return filtered.isEmpty ? nil : filtered.map { SQLSuggestionEntry(item: $0) } + return ranked.isEmpty ? nil : ranked.map { SQLSuggestionEntry(item: $0) } } func completionWindowApplyCompletion( @@ -176,21 +181,6 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { textView.setCursorPositions([CursorPosition(range: NSRange(location: newPosition, length: 0))]) } - // MARK: - Fuzzy Matching - - nonisolated static func fuzzyMatch(pattern: String, target: String) -> Bool { - let nsPattern = pattern as NSString - let nsTarget = target as NSString - var patternIndex = 0 - var targetIndex = 0 - while patternIndex < nsPattern.length && targetIndex < nsTarget.length { - if nsPattern.character(at: patternIndex) == nsTarget.character(at: targetIndex) { - patternIndex += 1 - } - targetIndex += 1 - } - return patternIndex == nsPattern.length - } } // MARK: - SQLSuggestionEntry @@ -210,6 +200,7 @@ final class SQLSuggestionEntry: CodeSuggestionEntry { var targetPosition: CursorPosition? { nil } var sourcePreview: String? { nil } var deprecated: Bool { false } + var matchedRanges: [Range] { item.matchedRanges } var image: Image { Image(systemName: item.kind.iconName) diff --git a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift index 6c428063..1e685542 100644 --- a/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift +++ b/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift @@ -2,100 +2,114 @@ // SQLCompletionAdapterFuzzyTests.swift // TableProTests // -// Regression tests for SQLCompletionAdapter.fuzzyMatch() method. +// Regression tests for fuzzy matching used by autocomplete. // @testable import TablePro import Testing -@Suite("SQLCompletionAdapter Fuzzy Matching") +@Suite("SQL Completion Fuzzy Matching") struct SQLCompletionAdapterFuzzyTests { + /// Helper: wraps SQLCompletionProvider.fuzzyMatchScore as a bool match + /// to preserve existing test semantics after the fuzzy logic was unified. + private func fuzzyMatch(pattern: String, target: String) -> Bool { + let provider = makeDummyProvider() + // Empty pattern is a vacuous match + if pattern.isEmpty { return true } + return provider.fuzzyMatchScore(pattern: pattern, target: target) != nil + } + + private func makeDummyProvider() -> SQLCompletionProvider { + // Provider only needs schemaProvider for candidate generation, + // fuzzyMatchScore is pure and doesn't touch the schema. + let schema = SQLSchemaProvider() + return SQLCompletionProvider(schemaProvider: schema) + } + // MARK: - Exact Match @Test("Exact match returns true") func exactMatch() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "select", target: "select") == true) + #expect(fuzzyMatch(pattern: "select", target: "select") == true) } // MARK: - Prefix Match @Test("Prefix match returns true") func prefixMatch() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "sel", target: "select") == true) + #expect(fuzzyMatch(pattern: "sel", target: "select") == true) } // MARK: - Scattered Match @Test("Scattered characters in order returns true") func scatteredMatch() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "slc", target: "select") == true) + #expect(fuzzyMatch(pattern: "slc", target: "select") == true) } @Test("First and last character match") func firstAndLastMatch() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "st", target: "select") == true) + #expect(fuzzyMatch(pattern: "st", target: "select") == true) } @Test("Scattered match across longer string") func scatteredLongerString() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "usr", target: "users_table") == true) + #expect(fuzzyMatch(pattern: "usr", target: "users_table") == true) } // MARK: - No Match @Test("No matching characters returns false") func noMatch() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "xyz", target: "select") == false) + #expect(fuzzyMatch(pattern: "xyz", target: "select") == false) } @Test("Characters present but in wrong order returns false") func wrongOrderReturnsFalse() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "tces", target: "select") == false) + #expect(fuzzyMatch(pattern: "tces", target: "select") == false) } // MARK: - Empty Pattern @Test("Empty pattern matches anything") func emptyPatternMatchesAnything() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "", target: "anything") == true) + #expect(fuzzyMatch(pattern: "", target: "anything") == true) } @Test("Empty pattern matches empty target") func emptyPatternMatchesEmpty() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "", target: "") == true) + #expect(fuzzyMatch(pattern: "", target: "") == true) } // MARK: - Pattern Longer Than Target @Test("Pattern longer than target returns false") func patternLongerThanTarget() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "selectfromwhere", target: "select") == false) + #expect(fuzzyMatch(pattern: "selectfromwhere", target: "select") == false) } // MARK: - Case Sensitivity @Test("Matching is case-sensitive by default") func caseSensitive() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "SELECT", target: "select") == false) + #expect(fuzzyMatch(pattern: "SELECT", target: "select") == false) } @Test("Same case matches") func sameCaseMatches() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "select", target: "select") == true) + #expect(fuzzyMatch(pattern: "select", target: "select") == true) } // MARK: - Unicode @Test("ASCII pattern against accented target") func asciiPatternAccentedTarget() { - let result = SQLCompletionAdapter.fuzzyMatch(pattern: "tbl", target: "table") - #expect(result == true) + #expect(fuzzyMatch(pattern: "tbl", target: "table") == true) } @Test("Unicode characters in both pattern and target") func unicodeInBoth() { - let result = SQLCompletionAdapter.fuzzyMatch(pattern: "cafe", target: "cafe") - #expect(result == true) + #expect(fuzzyMatch(pattern: "cafe", target: "cafe") == true) } // MARK: - Large Strings @@ -103,30 +117,30 @@ struct SQLCompletionAdapterFuzzyTests { @Test("Fuzzy match with large target string") func largeTargetString() { let largeTarget = String(repeating: "a", count: 10_000) + "xyz" - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "xyz", target: largeTarget) == true) + #expect(fuzzyMatch(pattern: "xyz", target: largeTarget) == true) } @Test("No match in large target string") func noMatchLargeTarget() { let largeTarget = String(repeating: "a", count: 10_000) - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "xyz", target: largeTarget) == false) + #expect(fuzzyMatch(pattern: "xyz", target: largeTarget) == false) } @Test("Pattern at beginning of large target") func patternAtBeginningOfLargeTarget() { let largeTarget = "xyz" + String(repeating: "a", count: 10_000) - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "xyz", target: largeTarget) == true) + #expect(fuzzyMatch(pattern: "xyz", target: largeTarget) == true) } // MARK: - Single Characters @Test("Single character present returns true") func singleCharPresent() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "s", target: "select") == true) + #expect(fuzzyMatch(pattern: "s", target: "select") == true) } @Test("Single character absent returns false") func singleCharAbsent() { - #expect(SQLCompletionAdapter.fuzzyMatch(pattern: "z", target: "select") == false) + #expect(fuzzyMatch(pattern: "z", target: "select") == false) } }