diff --git a/CHANGELOG.md b/CHANGELOG.md index d750cbd3..8e91eda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Autocompletion for filter fields: column names and SQL keywords suggested as you type (Raw SQL and Value fields) +- Multi-line support for Raw SQL filter field (Option+Enter for newline) - Visual Create Table UI with multi-database support (sidebar → "Create New Table...") - Auto-fit column width: double-click column divider or right-click → "Size to Fit" - Collapsible results panel (`Cmd+Opt+R`), multiple result tabs for multi-statement queries, result pinning diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index 64e30a65..7f4c1f2d 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -254,6 +254,7 @@ extension FilterSQLGenerator { func generatePreviewSQL( tableName: String, filters: [TableFilter], + logicMode: FilterLogicMode = .and, limit: Int = 1_000, pluginDriver: (any PluginDatabaseDriver)? = nil ) -> String { @@ -264,7 +265,8 @@ extension FilterSQLGenerator { .map { ($0.columnName, $0.filterOperator.rawValue, $0.value) } if let result = pluginDriver.buildFilteredQuery( table: tableName, filters: filterTuples, - logicMode: "and", sortColumns: [], columns: [], + logicMode: logicMode == .and ? "and" : "or", + sortColumns: [], columns: [], limit: limit, offset: 0 ) { return result @@ -274,7 +276,7 @@ extension FilterSQLGenerator { let quotedTable = quoteIdentifierFn(tableName) var sql = "SELECT * FROM \(quotedTable)" - let whereClause = generateWhereClause(from: filters) + let whereClause = generateWhereClause(from: filters, logicMode: logicMode) if !whereClause.isEmpty { sql += "\n\(whereClause)" } diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 6ff071c7..979efc68 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -231,10 +231,6 @@ final class FilterSettingsStorage { /// Sanitize table name for use as UserDefaults key private func sanitizeTableName(_ tableName: String) -> String { - // Replace special characters that might cause issues in keys - tableName - .replacingOccurrences(of: ".", with: "_") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "\\", with: "_") + tableName.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? tableName } } diff --git a/TablePro/Models/UI/FilterPreset.swift b/TablePro/Models/UI/FilterPreset.swift index 5fc682cf..47bf913f 100644 --- a/TablePro/Models/UI/FilterPreset.swift +++ b/TablePro/Models/UI/FilterPreset.swift @@ -16,7 +16,7 @@ struct FilterPreset: Identifiable, Codable, Equatable { } /// Storage manager for filter presets -final class FilterPresetStorage { +@MainActor final class FilterPresetStorage { static let shared = FilterPresetStorage() private let presetsKey = "com.TablePro.filter.presets" @@ -31,8 +31,10 @@ final class FilterPresetStorage { func savePreset(_ preset: FilterPreset) { var presets = loadAllPresets() - // Replace if preset with same name exists - if let index = presets.firstIndex(where: { $0.name == preset.name }) { + // Replace by id first, then by name + if let index = presets.firstIndex(where: { $0.id == preset.id }) { + presets[index] = preset + } else if let index = presets.firstIndex(where: { $0.name == preset.name }) { presets[index] = preset } else { presets.append(preset) diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index 0960b57f..eae6db12 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -320,7 +320,7 @@ final class FilterStateManager { private func getFiltersForPreview() -> [TableFilter] { var valid: [TableFilter] = [] var selectedValid: [TableFilter] = [] - for filter in filters where filter.isValid { + for filter in filters where filter.isEnabled && filter.isValid { valid.append(filter) if filter.isSelected { selectedValid.append(filter) } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 084d5854..5f837399 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -22972,6 +22972,16 @@ } } }, + "Plugin was built with PluginKit version %lld, but version %lld is required. Please update the plugin." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Plugin was built with PluginKit version %1$lld, but version %2$lld is required. Please update the plugin." + } + } + } + }, "Plugins" : { "localizations" : { "tr" : { @@ -34292,6 +34302,7 @@ }, "WHERE clause..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { diff --git a/TablePro/Views/Filter/CompletionTextField.swift b/TablePro/Views/Filter/CompletionTextField.swift new file mode 100644 index 00000000..e2c2d03e --- /dev/null +++ b/TablePro/Views/Filter/CompletionTextField.swift @@ -0,0 +1,162 @@ +// +// CompletionTextField.swift +// TablePro +// +// NSTextField with native macOS autocompletion via custom field editor. +// + +import AppKit +import SwiftUI + +struct CompletionTextField: NSViewRepresentable { + @Binding var text: String + var placeholder: String = "" + var completions: [String] = [] + var shouldFocus: Bool = false + var allowsMultiLine: Bool = false + var onSubmit: () -> Void = {} + + func makeNSView(context: Context) -> CompletionNSTextField { + let textField = CompletionNSTextField() + textField.placeholderString = placeholder + textField.bezelStyle = .roundedBezel + textField.controlSize = .small + textField.font = .systemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium) + textField.delegate = context.coordinator + textField.stringValue = text + textField.completionItems = completions + + if allowsMultiLine { + textField.usesSingleLineMode = false + textField.cell?.wraps = true + textField.cell?.isScrollable = false + textField.lineBreakMode = .byWordWrapping + textField.maximumNumberOfLines = 0 + } else { + textField.lineBreakMode = .byTruncatingTail + } + + if shouldFocus { + DispatchQueue.main.async { + textField.window?.makeFirstResponder(textField) + } + } + + return textField + } + + func updateNSView(_ textField: CompletionNSTextField, context: Context) { + if textField.stringValue != text { + textField.stringValue = text + } + textField.completionItems = completions + context.coordinator.onSubmit = onSubmit + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, onSubmit: onSubmit) + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, NSTextFieldDelegate { + var text: Binding + var onSubmit: () -> Void + private var previousTextLength = 0 + + init(text: Binding, onSubmit: @escaping () -> Void) { + self.text = text + self.onSubmit = onSubmit + } + + func controlTextDidChange(_ notification: Notification) { + guard let textField = notification.object as? NSTextField else { return } + let newValue = textField.stringValue + let grew = newValue.count > previousTextLength + previousTextLength = newValue.count + text.wrappedValue = newValue + + if grew, !newValue.isEmpty, + let fieldEditor = textField.currentEditor() as? NSTextView + { + fieldEditor.complete(nil) + } + } + + func controlTextDidEndEditing(_ notification: Notification) { + previousTextLength = 0 + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + onSubmit() + return true + } + // Option+Enter → insert newline (standard macOS behavior) + if commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) { + textView.insertNewlineIgnoringFieldEditor(nil) + text.wrappedValue = textView.string + previousTextLength = textView.string.count + return true + } + return false + } + } +} + +// MARK: - NSTextField with Custom Cell + +final class CompletionNSTextField: NSTextField { + var completionItems: [String] = [] { + didSet { + (cell as? CompletionTextFieldCell)?.completionItems = completionItems + } + } + + override class var cellClass: AnyClass? { + get { CompletionTextFieldCell.self } + set {} + } +} + +// MARK: - Custom Cell (provides field editor) + +private final class CompletionTextFieldCell: NSTextFieldCell { + var completionItems: [String] = [] + private var customFieldEditor: CompletionFieldEditor? + + override func fieldEditor(for controlView: NSView) -> NSTextView? { + if customFieldEditor == nil { + let editor = CompletionFieldEditor() + editor.isFieldEditor = true + customFieldEditor = editor + } + customFieldEditor?.completionItems = completionItems + return customFieldEditor + } +} + +// MARK: - Custom Field Editor (native completion) + +private final class CompletionFieldEditor: NSTextView { + var completionItems: [String] = [] + + override func completions( + forPartialWordRange charRange: NSRange, + indexOfSelectedItem index: UnsafeMutablePointer + ) -> [String]? { + index.pointee = -1 + + guard charRange.length > 0 else { return nil } + + let partial = (string as NSString).substring(with: charRange).lowercased() + let matches = completionItems.filter { $0.lowercased().hasPrefix(partial) } + + // Don't show popup when the only match is exactly what's typed + if matches.count == 1, matches[0].lowercased() == partial { + return nil + } + + return matches.isEmpty ? nil : matches + } +} diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index d0379b9b..7bb04494 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -22,7 +22,7 @@ struct FilterPanelView: View { @State private var newPresetName = "" @State private var savedPresets: [FilterPreset] = [] - private let filterRowHeight: CGFloat = 32 + private let estimatedFilterRowHeight: CGFloat = 32 var body: some View { VStack(spacing: 0) { @@ -131,6 +131,7 @@ struct FilterPanelView: View { Spacer() Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(Color(nsColor: .systemYellow)) + .help(String(localized: "Some columns in this preset don't exist in the current table")) } } } @@ -181,11 +182,12 @@ struct FilterPanelView: View { // MARK: - Filter List private var filterRows: some View { - LazyVStack(spacing: 0) { + VStack(spacing: 0) { ForEach(filterState.filters) { filter in FilterRowView( filter: filterState.binding(for: filter), columns: columns, + databaseType: databaseType, onAdd: { filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) }, onDuplicate: { filterState.duplicateFilter(filter) }, onRemove: { @@ -212,12 +214,12 @@ struct FilterPanelView: View { @ViewBuilder private var filterList: some View { - let contentHeight = CGFloat(filterState.filters.count) * filterRowHeight + 8 - if contentHeight > maxFilterListHeight { + let estimatedHeight = CGFloat(filterState.filters.count) * estimatedFilterRowHeight + 8 + if estimatedHeight > maxFilterListHeight { ScrollView { filterRows } - .frame(height: maxFilterListHeight) + .frame(maxHeight: maxFilterListHeight) } else { filterRows } diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index 6a636465..a0096977 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -10,13 +10,26 @@ import SwiftUI struct FilterRowView: View { @Binding var filter: TableFilter let columns: [String] + let databaseType: DatabaseType let onAdd: () -> Void let onDuplicate: () -> Void let onRemove: () -> Void let onSubmit: () -> Void var shouldFocus: Bool = false - @FocusState private var isValueFocused: Bool + private static let sqlKeywords = [ + "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", + "IS NULL", "IS NOT NULL", "EXISTS", + "CASE", "WHEN", "THEN", "ELSE", "END", + ] + + private var rawSQLCompletions: [String] { + let langName = PluginManager.shared.queryLanguageName(for: databaseType) + if langName == "SQL" || langName == "CQL" || langName == "PartiQL" { + return columns + Self.sqlKeywords + } + return columns + } var body: some View { HStack(spacing: 4) { @@ -33,11 +46,6 @@ struct FilterRowView: View { .padding(.vertical, 4) .padding(.horizontal, 8) .contextMenu { rowContextMenu } - .onAppear { - if shouldFocus { - isValueFocused = true - } - } } // MARK: - Column Picker @@ -79,25 +87,28 @@ struct FilterRowView: View { @ViewBuilder private var valueFields: some View { if filter.isRawSQL { - TextField("e.g. id = 1", text: Binding( - get: { filter.rawSQL ?? "" }, - set: { filter.rawSQL = $0 } - )) - .textFieldStyle(.roundedBorder) - .controlSize(.small) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) + CompletionTextField( + text: Binding( + get: { filter.rawSQL ?? "" }, + set: { filter.rawSQL = $0 } + ), + placeholder: "e.g. id = 1", + completions: rawSQLCompletions, + shouldFocus: shouldFocus, + allowsMultiLine: true, + onSubmit: onSubmit + ) .accessibilityLabel(String(localized: "WHERE clause")) - .focused($isValueFocused) - .onSubmit { onSubmit() } } else if filter.filterOperator.requiresValue { - TextField("Value", text: $filter.value) - .textFieldStyle(.roundedBorder) - .controlSize(.small) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) - .frame(minWidth: 80) - .accessibilityLabel(String(localized: "Filter value")) - .focused($isValueFocused) - .onSubmit { onSubmit() } + CompletionTextField( + text: $filter.value, + placeholder: String(localized: "Value"), + completions: columns, + shouldFocus: shouldFocus, + onSubmit: onSubmit + ) + .frame(minWidth: 80) + .accessibilityLabel(String(localized: "Filter value")) if filter.filterOperator.requiresSecondValue { Text("and")