Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions TablePro/Core/Database/FilterSQLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ extension FilterSQLGenerator {
func generatePreviewSQL(
tableName: String,
filters: [TableFilter],
logicMode: FilterLogicMode = .and,
limit: Int = 1_000,
pluginDriver: (any PluginDatabaseDriver)? = nil
) -> String {
Expand All @@ -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
Expand All @@ -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)"
}
Expand Down
6 changes: 1 addition & 5 deletions TablePro/Core/Storage/FilterSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
8 changes: 5 additions & 3 deletions TablePro/Models/UI/FilterPreset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Models/UI/FilterState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down
11 changes: 11 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -34292,6 +34302,7 @@

},
"WHERE clause..." : {
"extractionState" : "stale",
"localizations" : {
"tr" : {
"stringUnit" : {
Expand Down
162 changes: 162 additions & 0 deletions TablePro/Views/Filter/CompletionTextField.swift
Original file line number Diff line number Diff line change
@@ -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<String>
var onSubmit: () -> Void
private var previousTextLength = 0

init(text: Binding<String>, 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<Int>
) -> [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
}
}
12 changes: 7 additions & 5 deletions TablePro/Views/Filter/FilterPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"))
}
}
}
Expand Down Expand Up @@ -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: {
Expand All @@ -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
}
Expand Down
57 changes: 34 additions & 23 deletions TablePro/Views/Filter/FilterRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -33,11 +46,6 @@ struct FilterRowView: View {
.padding(.vertical, 4)
.padding(.horizontal, 8)
.contextMenu { rowContextMenu }
.onAppear {
if shouldFocus {
isValueFocused = true
}
}
}

// MARK: - Column Picker
Expand Down Expand Up @@ -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")
Expand Down
Loading