Skip to content

Commit 589bea6

Browse files
authored
feat: add autocompletion and multi-line for filter fields (#525)
* feat: add autocompletion and multi-line support for filter fields (#521) * docs: add filter autocompletion to CHANGELOG * fix: address filter system bugs found in deep review - Add @mainactor to FilterPresetStorage for Swift 6 concurrency safety - Fix generatePreviewSQL hardcoded logicMode "and" — now respects AND/OR - Fix getFiltersForPreview ignoring isEnabled flag - Fix completion popup triggering on backspace (only trigger on text growth) - Fix renamePreset creating duplicates (deduplicate by id first, then name) - Fix sanitizeTableName key collisions (use percent-encoding) - Add tooltip to preset column mismatch warning icon * fix: reset previousTextLength on end editing and after Option+Enter
1 parent 5390a00 commit 589bea6

9 files changed

Lines changed: 227 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Autocompletion for filter fields: column names and SQL keywords suggested as you type (Raw SQL and Value fields)
13+
- Multi-line support for Raw SQL filter field (Option+Enter for newline)
1214
- Visual Create Table UI with multi-database support (sidebar → "Create New Table...")
1315
- Auto-fit column width: double-click column divider or right-click → "Size to Fit"
1416
- Collapsible results panel (`Cmd+Opt+R`), multiple result tabs for multi-statement queries, result pinning

TablePro/Core/Database/FilterSQLGenerator.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ extension FilterSQLGenerator {
254254
func generatePreviewSQL(
255255
tableName: String,
256256
filters: [TableFilter],
257+
logicMode: FilterLogicMode = .and,
257258
limit: Int = 1_000,
258259
pluginDriver: (any PluginDatabaseDriver)? = nil
259260
) -> String {
@@ -264,7 +265,8 @@ extension FilterSQLGenerator {
264265
.map { ($0.columnName, $0.filterOperator.rawValue, $0.value) }
265266
if let result = pluginDriver.buildFilteredQuery(
266267
table: tableName, filters: filterTuples,
267-
logicMode: "and", sortColumns: [], columns: [],
268+
logicMode: logicMode == .and ? "and" : "or",
269+
sortColumns: [], columns: [],
268270
limit: limit, offset: 0
269271
) {
270272
return result
@@ -274,7 +276,7 @@ extension FilterSQLGenerator {
274276
let quotedTable = quoteIdentifierFn(tableName)
275277
var sql = "SELECT * FROM \(quotedTable)"
276278

277-
let whereClause = generateWhereClause(from: filters)
279+
let whereClause = generateWhereClause(from: filters, logicMode: logicMode)
278280
if !whereClause.isEmpty {
279281
sql += "\n\(whereClause)"
280282
}

TablePro/Core/Storage/FilterSettingsStorage.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,6 @@ final class FilterSettingsStorage {
231231

232232
/// Sanitize table name for use as UserDefaults key
233233
private func sanitizeTableName(_ tableName: String) -> String {
234-
// Replace special characters that might cause issues in keys
235-
tableName
236-
.replacingOccurrences(of: ".", with: "_")
237-
.replacingOccurrences(of: "/", with: "_")
238-
.replacingOccurrences(of: "\\", with: "_")
234+
tableName.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? tableName
239235
}
240236
}

TablePro/Models/UI/FilterPreset.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct FilterPreset: Identifiable, Codable, Equatable {
1616
}
1717

1818
/// Storage manager for filter presets
19-
final class FilterPresetStorage {
19+
@MainActor final class FilterPresetStorage {
2020
static let shared = FilterPresetStorage()
2121

2222
private let presetsKey = "com.TablePro.filter.presets"
@@ -31,8 +31,10 @@ final class FilterPresetStorage {
3131
func savePreset(_ preset: FilterPreset) {
3232
var presets = loadAllPresets()
3333

34-
// Replace if preset with same name exists
35-
if let index = presets.firstIndex(where: { $0.name == preset.name }) {
34+
// Replace by id first, then by name
35+
if let index = presets.firstIndex(where: { $0.id == preset.id }) {
36+
presets[index] = preset
37+
} else if let index = presets.firstIndex(where: { $0.name == preset.name }) {
3638
presets[index] = preset
3739
} else {
3840
presets.append(preset)

TablePro/Models/UI/FilterState.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ final class FilterStateManager {
320320
private func getFiltersForPreview() -> [TableFilter] {
321321
var valid: [TableFilter] = []
322322
var selectedValid: [TableFilter] = []
323-
for filter in filters where filter.isValid {
323+
for filter in filters where filter.isEnabled && filter.isValid {
324324
valid.append(filter)
325325
if filter.isSelected { selectedValid.append(filter) }
326326
}

TablePro/Resources/Localizable.xcstrings

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22972,6 +22972,16 @@
2297222972
}
2297322973
}
2297422974
},
22975+
"Plugin was built with PluginKit version %lld, but version %lld is required. Please update the plugin." : {
22976+
"localizations" : {
22977+
"en" : {
22978+
"stringUnit" : {
22979+
"state" : "new",
22980+
"value" : "Plugin was built with PluginKit version %1$lld, but version %2$lld is required. Please update the plugin."
22981+
}
22982+
}
22983+
}
22984+
},
2297522985
"Plugins" : {
2297622986
"localizations" : {
2297722987
"tr" : {
@@ -34292,6 +34302,7 @@
3429234302

3429334303
},
3429434304
"WHERE clause..." : {
34305+
"extractionState" : "stale",
3429534306
"localizations" : {
3429634307
"tr" : {
3429734308
"stringUnit" : {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//
2+
// CompletionTextField.swift
3+
// TablePro
4+
//
5+
// NSTextField with native macOS autocompletion via custom field editor.
6+
//
7+
8+
import AppKit
9+
import SwiftUI
10+
11+
struct CompletionTextField: NSViewRepresentable {
12+
@Binding var text: String
13+
var placeholder: String = ""
14+
var completions: [String] = []
15+
var shouldFocus: Bool = false
16+
var allowsMultiLine: Bool = false
17+
var onSubmit: () -> Void = {}
18+
19+
func makeNSView(context: Context) -> CompletionNSTextField {
20+
let textField = CompletionNSTextField()
21+
textField.placeholderString = placeholder
22+
textField.bezelStyle = .roundedBezel
23+
textField.controlSize = .small
24+
textField.font = .systemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium)
25+
textField.delegate = context.coordinator
26+
textField.stringValue = text
27+
textField.completionItems = completions
28+
29+
if allowsMultiLine {
30+
textField.usesSingleLineMode = false
31+
textField.cell?.wraps = true
32+
textField.cell?.isScrollable = false
33+
textField.lineBreakMode = .byWordWrapping
34+
textField.maximumNumberOfLines = 0
35+
} else {
36+
textField.lineBreakMode = .byTruncatingTail
37+
}
38+
39+
if shouldFocus {
40+
DispatchQueue.main.async {
41+
textField.window?.makeFirstResponder(textField)
42+
}
43+
}
44+
45+
return textField
46+
}
47+
48+
func updateNSView(_ textField: CompletionNSTextField, context: Context) {
49+
if textField.stringValue != text {
50+
textField.stringValue = text
51+
}
52+
textField.completionItems = completions
53+
context.coordinator.onSubmit = onSubmit
54+
}
55+
56+
func makeCoordinator() -> Coordinator {
57+
Coordinator(text: $text, onSubmit: onSubmit)
58+
}
59+
60+
// MARK: - Coordinator
61+
62+
final class Coordinator: NSObject, NSTextFieldDelegate {
63+
var text: Binding<String>
64+
var onSubmit: () -> Void
65+
private var previousTextLength = 0
66+
67+
init(text: Binding<String>, onSubmit: @escaping () -> Void) {
68+
self.text = text
69+
self.onSubmit = onSubmit
70+
}
71+
72+
func controlTextDidChange(_ notification: Notification) {
73+
guard let textField = notification.object as? NSTextField else { return }
74+
let newValue = textField.stringValue
75+
let grew = newValue.count > previousTextLength
76+
previousTextLength = newValue.count
77+
text.wrappedValue = newValue
78+
79+
if grew, !newValue.isEmpty,
80+
let fieldEditor = textField.currentEditor() as? NSTextView
81+
{
82+
fieldEditor.complete(nil)
83+
}
84+
}
85+
86+
func controlTextDidEndEditing(_ notification: Notification) {
87+
previousTextLength = 0
88+
}
89+
90+
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
91+
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
92+
onSubmit()
93+
return true
94+
}
95+
// Option+Enter → insert newline (standard macOS behavior)
96+
if commandSelector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) {
97+
textView.insertNewlineIgnoringFieldEditor(nil)
98+
text.wrappedValue = textView.string
99+
previousTextLength = textView.string.count
100+
return true
101+
}
102+
return false
103+
}
104+
}
105+
}
106+
107+
// MARK: - NSTextField with Custom Cell
108+
109+
final class CompletionNSTextField: NSTextField {
110+
var completionItems: [String] = [] {
111+
didSet {
112+
(cell as? CompletionTextFieldCell)?.completionItems = completionItems
113+
}
114+
}
115+
116+
override class var cellClass: AnyClass? {
117+
get { CompletionTextFieldCell.self }
118+
set {}
119+
}
120+
}
121+
122+
// MARK: - Custom Cell (provides field editor)
123+
124+
private final class CompletionTextFieldCell: NSTextFieldCell {
125+
var completionItems: [String] = []
126+
private var customFieldEditor: CompletionFieldEditor?
127+
128+
override func fieldEditor(for controlView: NSView) -> NSTextView? {
129+
if customFieldEditor == nil {
130+
let editor = CompletionFieldEditor()
131+
editor.isFieldEditor = true
132+
customFieldEditor = editor
133+
}
134+
customFieldEditor?.completionItems = completionItems
135+
return customFieldEditor
136+
}
137+
}
138+
139+
// MARK: - Custom Field Editor (native completion)
140+
141+
private final class CompletionFieldEditor: NSTextView {
142+
var completionItems: [String] = []
143+
144+
override func completions(
145+
forPartialWordRange charRange: NSRange,
146+
indexOfSelectedItem index: UnsafeMutablePointer<Int>
147+
) -> [String]? {
148+
index.pointee = -1
149+
150+
guard charRange.length > 0 else { return nil }
151+
152+
let partial = (string as NSString).substring(with: charRange).lowercased()
153+
let matches = completionItems.filter { $0.lowercased().hasPrefix(partial) }
154+
155+
// Don't show popup when the only match is exactly what's typed
156+
if matches.count == 1, matches[0].lowercased() == partial {
157+
return nil
158+
}
159+
160+
return matches.isEmpty ? nil : matches
161+
}
162+
}

TablePro/Views/Filter/FilterPanelView.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ struct FilterPanelView: View {
2222
@State private var newPresetName = ""
2323
@State private var savedPresets: [FilterPreset] = []
2424

25-
private let filterRowHeight: CGFloat = 32
25+
private let estimatedFilterRowHeight: CGFloat = 32
2626

2727
var body: some View {
2828
VStack(spacing: 0) {
@@ -131,6 +131,7 @@ struct FilterPanelView: View {
131131
Spacer()
132132
Image(systemName: "exclamationmark.triangle.fill")
133133
.foregroundStyle(Color(nsColor: .systemYellow))
134+
.help(String(localized: "Some columns in this preset don't exist in the current table"))
134135
}
135136
}
136137
}
@@ -181,11 +182,12 @@ struct FilterPanelView: View {
181182
// MARK: - Filter List
182183

183184
private var filterRows: some View {
184-
LazyVStack(spacing: 0) {
185+
VStack(spacing: 0) {
185186
ForEach(filterState.filters) { filter in
186187
FilterRowView(
187188
filter: filterState.binding(for: filter),
188189
columns: columns,
190+
databaseType: databaseType,
189191
onAdd: { filterState.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) },
190192
onDuplicate: { filterState.duplicateFilter(filter) },
191193
onRemove: {
@@ -212,12 +214,12 @@ struct FilterPanelView: View {
212214

213215
@ViewBuilder
214216
private var filterList: some View {
215-
let contentHeight = CGFloat(filterState.filters.count) * filterRowHeight + 8
216-
if contentHeight > maxFilterListHeight {
217+
let estimatedHeight = CGFloat(filterState.filters.count) * estimatedFilterRowHeight + 8
218+
if estimatedHeight > maxFilterListHeight {
217219
ScrollView {
218220
filterRows
219221
}
220-
.frame(height: maxFilterListHeight)
222+
.frame(maxHeight: maxFilterListHeight)
221223
} else {
222224
filterRows
223225
}

TablePro/Views/Filter/FilterRowView.swift

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,26 @@ import SwiftUI
1010
struct FilterRowView: View {
1111
@Binding var filter: TableFilter
1212
let columns: [String]
13+
let databaseType: DatabaseType
1314
let onAdd: () -> Void
1415
let onDuplicate: () -> Void
1516
let onRemove: () -> Void
1617
let onSubmit: () -> Void
1718
var shouldFocus: Bool = false
1819

19-
@FocusState private var isValueFocused: Bool
20+
private static let sqlKeywords = [
21+
"AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
22+
"IS NULL", "IS NOT NULL", "EXISTS",
23+
"CASE", "WHEN", "THEN", "ELSE", "END",
24+
]
25+
26+
private var rawSQLCompletions: [String] {
27+
let langName = PluginManager.shared.queryLanguageName(for: databaseType)
28+
if langName == "SQL" || langName == "CQL" || langName == "PartiQL" {
29+
return columns + Self.sqlKeywords
30+
}
31+
return columns
32+
}
2033

2134
var body: some View {
2235
HStack(spacing: 4) {
@@ -33,11 +46,6 @@ struct FilterRowView: View {
3346
.padding(.vertical, 4)
3447
.padding(.horizontal, 8)
3548
.contextMenu { rowContextMenu }
36-
.onAppear {
37-
if shouldFocus {
38-
isValueFocused = true
39-
}
40-
}
4149
}
4250

4351
// MARK: - Column Picker
@@ -79,25 +87,28 @@ struct FilterRowView: View {
7987
@ViewBuilder
8088
private var valueFields: some View {
8189
if filter.isRawSQL {
82-
TextField("e.g. id = 1", text: Binding(
83-
get: { filter.rawSQL ?? "" },
84-
set: { filter.rawSQL = $0 }
85-
))
86-
.textFieldStyle(.roundedBorder)
87-
.controlSize(.small)
88-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.medium))
90+
CompletionTextField(
91+
text: Binding(
92+
get: { filter.rawSQL ?? "" },
93+
set: { filter.rawSQL = $0 }
94+
),
95+
placeholder: "e.g. id = 1",
96+
completions: rawSQLCompletions,
97+
shouldFocus: shouldFocus,
98+
allowsMultiLine: true,
99+
onSubmit: onSubmit
100+
)
89101
.accessibilityLabel(String(localized: "WHERE clause"))
90-
.focused($isValueFocused)
91-
.onSubmit { onSubmit() }
92102
} else if filter.filterOperator.requiresValue {
93-
TextField("Value", text: $filter.value)
94-
.textFieldStyle(.roundedBorder)
95-
.controlSize(.small)
96-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.medium))
97-
.frame(minWidth: 80)
98-
.accessibilityLabel(String(localized: "Filter value"))
99-
.focused($isValueFocused)
100-
.onSubmit { onSubmit() }
103+
CompletionTextField(
104+
text: $filter.value,
105+
placeholder: String(localized: "Value"),
106+
completions: columns,
107+
shouldFocus: shouldFocus,
108+
onSubmit: onSubmit
109+
)
110+
.frame(minWidth: 80)
111+
.accessibilityLabel(String(localized: "Filter value"))
101112

102113
if filter.filterOperator.requiresSecondValue {
103114
Text("and")

0 commit comments

Comments
 (0)