diff --git a/CHANGELOG.md b/CHANGELOG.md index c60a3fd6e..f8169f9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 - Inline error banner for query errors +- JSON syntax highlighting and brace matching in Details sidebar and JSON editor popover +- Database-aware SQL functions in field menu (MySQL, PostgreSQL, SQLite, SQL Server, ClickHouse) ### Changed - Replace GCD dispatch patterns with Swift structured concurrency +- Refactor Details sidebar into modular field editor architecture with extracted editor components ### Fixed diff --git a/TablePro/Core/Services/Query/SQLFunctionProvider.swift b/TablePro/Core/Services/Query/SQLFunctionProvider.swift new file mode 100644 index 000000000..22e2185ee --- /dev/null +++ b/TablePro/Core/Services/Query/SQLFunctionProvider.swift @@ -0,0 +1,58 @@ +// +// SQLFunctionProvider.swift +// TablePro + +internal enum SQLFunctionProvider { + internal struct SQLFunction { + let label: String + let expression: String + } + + static func functions(for databaseType: DatabaseType) -> [SQLFunction] { + if databaseType == .mysql || databaseType == .mariadb { + return [ + SQLFunction(label: "NOW()", expression: "NOW()"), + SQLFunction(label: "CURRENT_TIMESTAMP()", expression: "CURRENT_TIMESTAMP()"), + SQLFunction(label: "CURDATE()", expression: "CURDATE()"), + SQLFunction(label: "CURTIME()", expression: "CURTIME()"), + SQLFunction(label: "UTC_TIMESTAMP()", expression: "UTC_TIMESTAMP()"), + SQLFunction(label: "UUID()", expression: "UUID()") + ] + } else if databaseType == .postgresql || databaseType == .redshift { + return [ + SQLFunction(label: "now()", expression: "now()"), + SQLFunction(label: "CURRENT_TIMESTAMP", expression: "CURRENT_TIMESTAMP"), + SQLFunction(label: "CURRENT_DATE", expression: "CURRENT_DATE"), + SQLFunction(label: "CURRENT_TIME", expression: "CURRENT_TIME"), + SQLFunction(label: "gen_random_uuid()", expression: "gen_random_uuid()") + ] + } else if databaseType == .sqlite || databaseType == .duckdb || databaseType == .cloudflareD1 { + return [ + SQLFunction(label: "datetime('now')", expression: "datetime('now')"), + SQLFunction(label: "date('now')", expression: "date('now')"), + SQLFunction(label: "time('now')", expression: "time('now')"), + SQLFunction(label: "datetime('now','localtime')", expression: "datetime('now','localtime')") + ] + } else if databaseType == .mssql { + return [ + SQLFunction(label: "GETDATE()", expression: "GETDATE()"), + SQLFunction(label: "GETUTCDATE()", expression: "GETUTCDATE()"), + SQLFunction(label: "SYSDATETIME()", expression: "SYSDATETIME()"), + SQLFunction(label: "NEWID()", expression: "NEWID()") + ] + } else if databaseType == .clickhouse { + return [ + SQLFunction(label: "now()", expression: "now()"), + SQLFunction(label: "today()", expression: "today()"), + SQLFunction(label: "yesterday()", expression: "yesterday()"), + SQLFunction(label: "generateUUIDv4()", expression: "generateUUIDv4()") + ] + } else { + return [ + SQLFunction(label: "CURRENT_TIMESTAMP", expression: "CURRENT_TIMESTAMP"), + SQLFunction(label: "CURRENT_DATE", expression: "CURRENT_DATE"), + SQLFunction(label: "CURRENT_TIME", expression: "CURRENT_TIME") + ] + } + } +} diff --git a/TablePro/Extensions/String+JSON.swift b/TablePro/Extensions/String+JSON.swift index 0ebd2f861..5ae3d738d 100644 --- a/TablePro/Extensions/String+JSON.swift +++ b/TablePro/Extensions/String+JSON.swift @@ -8,6 +8,15 @@ import Foundation extension String { + /// Returns true if this string looks like a JSON object or array (starts with `{`/`[` and parses successfully). + /// Only checks objects and arrays to avoid false positives with bare primitives like `"hello"`, `123`, `true`. + var looksLikeJson: Bool { + let trimmed = unicodeScalars.first + guard trimmed == "{" || trimmed == "[" else { return false } + guard let data = data(using: .utf8) else { return false } + return (try? JSONSerialization.jsonObject(with: data)) != nil + } + /// Returns a pretty-printed version of this string if it contains valid JSON, or nil otherwise. func prettyPrintedAsJson() -> String? { guard let data = data(using: .utf8), diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index 3b0b2228b..82efcd22d 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -10,7 +10,8 @@ import Foundation import Observation /// Represents the edit state for a single field across multiple rows -struct FieldEditState { +struct FieldEditState: Identifiable { + var id = UUID() let columnIndex: Int let columnName: String let columnTypeEnum: ColumnType @@ -101,8 +102,8 @@ final class MultiRowEditState { } // Check if all values are the same - let uniqueValues = Set(values.map { $0 ?? "__NULL__" }) - let hasMultipleValues = uniqueValues.count > 1 + let allSame = values.dropFirst().allSatisfy { $0 == values.first } + let hasMultipleValues = !allSame let originalValue: String? if hasMultipleValues { @@ -113,6 +114,7 @@ final class MultiRowEditState { } // Preserve pending edits if data hasn't changed + var preservedId: UUID? var pendingValue: String? var isPendingNull = false var isPendingDefault = false @@ -126,6 +128,7 @@ final class MultiRowEditState { let oldField = fields[colIndex] // Preserve pending edits when original data matches if oldField.originalValue == originalValue && oldField.hasMultipleValues == hasMultipleValues { + preservedId = oldField.id pendingValue = oldField.pendingValue isPendingNull = oldField.isPendingNull isPendingDefault = oldField.isPendingDefault @@ -143,7 +146,7 @@ final class MultiRowEditState { pendingValue = originalValue ?? "" } - newFields.append(FieldEditState( + var newField = FieldEditState( columnIndex: colIndex, columnName: columnName, columnTypeEnum: columnTypeEnum, @@ -155,7 +158,11 @@ final class MultiRowEditState { isPendingDefault: isPendingDefault, isTruncated: preservedIsTruncated, isLoadingFullValue: preservedIsLoadingFullValue - )) + ) + if let preservedId { + newField.id = preservedId + } + newFields.append(newField) } self.fields = newFields diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 5f8373995..6f95b1595 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3386,6 +3386,9 @@ } } } + }, + "AI-Powered Assistant" : { + }, "AI-powered SQL completions appear as ghost text while typing. Press Tab to accept, Escape to dismiss." : { "localizations" : { @@ -3994,6 +3997,9 @@ } } } + }, + "API token" : { + }, "API Token" : { "localizations" : { @@ -4022,6 +4028,9 @@ } } } + }, + "API Token Required" : { + }, "Appearance" : { "localizations" : { @@ -4407,6 +4416,12 @@ } } } + }, + "Ask for API token on every connection" : { + + }, + "Ask for password on every connection" : { + }, "Auth" : { "localizations" : { @@ -5153,6 +5168,9 @@ } } } + }, + "Browse, edit, and manage your data with ease" : { + }, "Browse..." : { "localizations" : { @@ -6807,6 +6825,9 @@ } } } + }, + "Comma-separated values. Compatible with Excel and most tools." : { + }, "Command Preview" : { "extractionState" : "stale", @@ -7047,6 +7068,9 @@ } } } + }, + "Connect to popular databases with full feature support" : { + }, "Connect to the internet to verify your license." : { "localizations" : { @@ -7905,6 +7929,9 @@ } } } + }, + "Copy error message" : { + }, "Copy Name" : { "localizations" : { @@ -8518,6 +8545,7 @@ } }, "CURDATE()" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -8674,6 +8702,7 @@ } }, "CURRENT_TIMESTAMP()" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -8746,6 +8775,7 @@ } }, "CURTIME()" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -11680,6 +11710,16 @@ }, "Enter table name" : { + }, + "Enter the %@ for \"%@\"" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter the %1$@ for \"%2$@\"" + } + } + } }, "Enter the passphrase to decrypt and import connections." : { "localizations" : { @@ -11966,6 +12006,9 @@ } } } + }, + "Excel spreadsheet with formatting support." : { + }, "Execute" : { "localizations" : { @@ -12960,6 +13003,7 @@ } }, "Failed to decompress file: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13273,6 +13317,7 @@ } }, "Failed to load preview using encoding: %@. Try selecting a different text encoding." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13295,6 +13340,7 @@ } }, "Failed to load preview: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -14322,6 +14368,9 @@ } } } + }, + "Format SQL" : { + }, "Format:" : { "localizations" : { @@ -14498,6 +14547,9 @@ } } } + }, + "Get intelligent SQL suggestions and query assistance" : { + }, "Get Started" : { "localizations" : { @@ -16417,6 +16469,9 @@ } } } + }, + "Interactive Data Grid" : { + }, "Interface" : { "localizations" : { @@ -18745,6 +18800,9 @@ } } } + }, + "MongoDB query language. Use to import into MongoDB." : { + }, "Move Down" : { "extractionState" : "stale", @@ -18999,6 +19057,9 @@ } } } + }, + "MySQL, PostgreSQL & SQLite" : { + }, "Name" : { "localizations" : { @@ -20712,6 +20773,9 @@ } } } + }, + "No rows returned" : { + }, "No saved connection named \"%@\"." : { "localizations" : { @@ -21311,6 +21375,7 @@ } }, "NOW()" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -22414,6 +22479,9 @@ } } } + }, + "password" : { + }, "Password" : { "localizations" : { @@ -22458,6 +22526,9 @@ } } } + }, + "Password Required" : { + }, "Passwords will be encrypted with the passphrase you provide." : { "localizations" : { @@ -24272,6 +24343,7 @@ } }, "Query executing..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -25583,6 +25655,15 @@ } } } + }, + "Reset" : { + + }, + "Reset All Settings" : { + + }, + "Reset All Settings to Defaults" : { + }, "Reset to Defaults" : { "localizations" : { @@ -27176,6 +27257,9 @@ } } } + }, + "Secure Connections" : { + }, "SELECT * FROM users WHERE id = 1;" : { "extractionState" : "stale", @@ -28543,6 +28627,9 @@ } } } + }, + "Smart SQL Editor" : { + }, "Smooth" : { "localizations" : { @@ -28587,6 +28674,9 @@ } } } + }, + "Some columns in this preset don't exist in the current table" : { + }, "Something went wrong (error %lld). Try again in a moment." : { "localizations" : { @@ -28854,6 +28944,9 @@ } } } + }, + "SQL INSERT statements. Use to recreate data in another database." : { + }, "SQL Preview" : { "localizations" : { @@ -29243,6 +29336,9 @@ } } } + }, + "SSH tunneling and SSL/TLS encryption support" : { + }, "SSH User" : { "localizations" : { @@ -29742,6 +29838,9 @@ } } } + }, + "Structured data format. Ideal for APIs and web applications." : { + }, "Success" : { "localizations" : { @@ -29950,6 +30049,7 @@ } }, "Sync" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -29970,6 +30070,9 @@ } } } + }, + "Sync (Pro)" : { + }, "Sync Categories" : { "localizations" : { @@ -30288,6 +30391,9 @@ } } } + }, + "Syntax highlighting, autocomplete, and multi-tab editing" : { + }, "System" : { "localizations" : { @@ -31806,6 +31912,9 @@ } } } + }, + "This will reset all settings across every section to their default values." : { + }, "Tier:" : { "localizations" : { @@ -33528,6 +33637,7 @@ } }, "UTC_TIMESTAMP()" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -34744,6 +34854,12 @@ } } } + }, + "Zoom In" : { + + }, + "Zoom Out" : { + } }, "version" : "1.0" diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 80cd5db6c..72567508b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -97,13 +97,19 @@ extension TableViewCoordinator { return } - // JSON columns use JSON editor popover + // JSON columns (or text columns containing JSON) use JSON editor popover if columnIndex < rowProvider.columnTypes.count, rowProvider.columnTypes[columnIndex].isJsonType { showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) return } + if let cellValue = rowProvider.value(atRow: row, column: columnIndex), + cellValue.looksLikeJson { + showJSONEditorPopover(tableView: sender, row: row, column: column, columnIndex: columnIndex) + return + } + // BLOB columns use hex editor popover if columnIndex < rowProvider.columnTypes.count, rowProvider.columnTypes[columnIndex].isBlobType { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index b58daf380..19dcc8adc 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -43,6 +43,12 @@ extension TableViewCoordinator { return false } + // Text columns containing JSON use JSON editor popover + if let value = rowProvider.value(atRow: row, column: columnIndex), + value.looksLikeJson { + return false + } + // Multiline values use overlay editor — block inline field editor if let value = rowProvider.value(atRow: row, column: columnIndex), value.containsLineBreak { diff --git a/TablePro/Views/Results/JSONBraceMatchingHelper.swift b/TablePro/Views/Results/JSONBraceMatchingHelper.swift new file mode 100644 index 000000000..ac73d4b2a --- /dev/null +++ b/TablePro/Views/Results/JSONBraceMatchingHelper.swift @@ -0,0 +1,178 @@ +// +// JSONBraceMatchingHelper.swift +// TablePro +// +// Highlights matching {}/[] braces when the cursor is adjacent to one. +// + +import AppKit + +final class JSONBraceMatchingHelper { + private weak var textView: NSTextView? + private var lastHighlightedRanges: [NSRange] = [] + private static let highlightColor = NSColor.systemYellow.withAlphaComponent(0.3) + private static let maxScanLength = 10_000 + + init(textView: NSTextView) { + self.textView = textView + } + + func updateBraceHighlight() { + clearHighlights() + + guard let textView else { return } + guard let layoutManager = textView.layoutManager else { return } + + let text = textView.string as NSString + let length = text.length + guard length > 0 else { return } + + let cursor = textView.selectedRange().location + guard cursor != NSNotFound else { return } + + var bracePosition: Int? + + if let pos = braceAt(position: cursor, in: text) { + bracePosition = pos + } else if cursor > 0, let pos = braceAt(position: cursor - 1, in: text) { + bracePosition = pos + } + + guard let position = bracePosition else { return } + guard let matchPosition = findMatchingBrace(from: position, in: text) else { return } + + let ranges = [ + NSRange(location: position, length: 1), + NSRange(location: matchPosition, length: 1) + ] + + for range in ranges { + layoutManager.addTemporaryAttribute( + .backgroundColor, + value: Self.highlightColor, + forCharacterRange: range + ) + } + + lastHighlightedRanges = ranges + } + + private func clearHighlights() { + guard let layoutManager = textView?.layoutManager else { return } + for range in lastHighlightedRanges { + layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: range) + } + lastHighlightedRanges = [] + } + + private func findMatchingBrace(from position: Int, in text: NSString) -> Int? { + let length = text.length + guard position >= 0, position < length else { return nil } + + let char = text.character(at: position) + let openBrace: unichar + let closeBrace: unichar + let forward: Bool + + switch char { + case leftCurly: + openBrace = leftCurly; closeBrace = rightCurly; forward = true + case leftSquare: + openBrace = leftSquare; closeBrace = rightSquare; forward = true + case rightCurly: + openBrace = leftCurly; closeBrace = rightCurly; forward = false + case rightSquare: + openBrace = leftSquare; closeBrace = rightSquare; forward = false + default: + return nil + } + + var depth = 1 + var inString = false + let maxScan = Self.maxScanLength + + if forward { + var i = position + 1 + var scanned = 0 + while i < length, scanned < maxScan { + let ch = text.character(at: i) + + if ch == quote, !isEscaped(at: i, in: text) { + inString.toggle() + } else if !inString { + if ch == openBrace { + depth += 1 + } else if ch == closeBrace { + depth -= 1 + if depth == 0 { return i } + } + } + + i += 1 + scanned += 1 + } + // Backward scan: first determine string-state at each position via forward pass, + // then walk backward using the precomputed state. + } else { + // Build in-string map from start to target position via forward scan + var stringState = [Bool](repeating: false, count: min(position + 1, length)) + var fwdInString = false + for j in 0..= 0, scanned < maxScan { + if !stringState[i] { + let ch = text.character(at: i) + if ch == closeBrace { + depth += 1 + } else if ch == openBrace { + depth -= 1 + if depth == 0 { return i } + } + } + i -= 1 + scanned += 1 + } + } + + return nil + } + + private func braceAt(position: Int, in text: NSString) -> Int? { + guard position >= 0, position < text.length else { return nil } + let ch = text.character(at: position) + if ch == leftCurly || ch == rightCurly || ch == leftSquare || ch == rightSquare { + return position + } + return nil + } + + // Checks if the quote at `position` is preceded by an odd number of backslashes + private func isEscaped(at position: Int, in text: NSString) -> Bool { + var backslashCount = 0 + var i = position - 1 + while i >= 0, text.character(at: i) == backslash { + backslashCount += 1 + i -= 1 + } + return backslashCount % 2 != 0 + } +} + +// MARK: - Character Constants + +private extension JSONBraceMatchingHelper { + var leftCurly: unichar { 0x7B } // { + var rightCurly: unichar { 0x7D } // } + var leftSquare: unichar { 0x5B } // [ + var rightSquare: unichar { 0x5D } // ] + var quote: unichar { 0x22 } // " + var backslash: unichar { 0x5C } // \ +} diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift index af03c547f..e0f069609 100644 --- a/TablePro/Views/Results/JSONEditorContentView.swift +++ b/TablePro/Views/Results/JSONEditorContentView.swift @@ -5,7 +5,6 @@ // SwiftUI popover content for editing JSON/JSONB column values with formatting and validation. // -import AppKit import SwiftUI struct JSONEditorContentView: View { @@ -24,12 +23,12 @@ struct JSONEditorContentView: View { self.initialValue = initialValue self.onCommit = onCommit self.onDismiss = onDismiss - self._text = State(initialValue: initialValue?.prettyPrintedAsJson() ?? initialValue ?? "") + self._text = State(initialValue: initialValue ?? "") } var body: some View { VStack(spacing: 0) { - JSONSyntaxTextView(text: $text) + JSONSyntaxTextView(text: $text, wordWrap: true) Divider() @@ -92,133 +91,3 @@ struct JSONEditorContentView: View { return compactString } } - -// MARK: - JSON Syntax Highlighted Text View - -private struct JSONSyntaxTextView: NSViewRepresentable { - @Binding var text: String - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - guard let textView = scrollView.documentView as? NSTextView else { - return scrollView - } - - textView.isEditable = true - textView.isSelectable = true - textView.font = NSFont.monospacedSystemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium, weight: .regular) - textView.textContainerInset = NSSize(width: 8, height: 8) - textView.backgroundColor = NSColor.textBackgroundColor - textView.textColor = NSColor.labelColor - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.isGrammarCheckingEnabled = false - textView.allowsUndo = true - - textView.textContainer?.widthTracksTextView = true - textView.isHorizontallyResizable = false - - textView.delegate = context.coordinator - textView.string = text - Self.applyHighlighting(to: textView) - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { return } - if textView.string != text, !context.coordinator.isUpdating { - textView.string = text - Self.applyHighlighting(to: textView) - } - } - - // MARK: - Syntax Highlighting - - static func applyHighlighting(to textView: NSTextView) { - guard let textStorage = textView.textStorage else { return } - let length = textStorage.length - guard length > 0 else { return } - - let fullRange = NSRange(location: 0, length: length) - let font = textView.font ?? NSFont.monospacedSystemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium, weight: .regular) - let content = textStorage.string - let maxHighlightLength = 10_000 - let highlightRange: NSRange - if length > maxHighlightLength { - highlightRange = NSRange(location: 0, length: maxHighlightLength) - } else { - highlightRange = fullRange - } - - textStorage.beginEditing() - - // Reset to base style - textStorage.addAttribute(.font, value: font, range: fullRange) - textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) - - applyPattern(JSONHighlightPatterns.string, color: .systemRed, in: textStorage, content: content, range: highlightRange) - - for match in JSONHighlightPatterns.key.matches(in: content, range: highlightRange) { - let captureRange = match.range(at: 1) - if captureRange.location != NSNotFound { - textStorage.addAttribute(.foregroundColor, value: NSColor.systemBlue, range: captureRange) - } - } - - applyPattern(JSONHighlightPatterns.number, color: .systemPurple, in: textStorage, content: content, range: highlightRange) - applyPattern(JSONHighlightPatterns.booleanNull, color: .systemOrange, in: textStorage, content: content, range: highlightRange) - - textStorage.endEditing() - } - - private static func applyPattern( - _ regex: NSRegularExpression, - color: NSColor, - in textStorage: NSTextStorage, - content: String, - range: NSRange - ) { - for match in regex.matches(in: content, range: range) { - textStorage.addAttribute(.foregroundColor, value: color, range: match.range) - } - } - - // MARK: - Coordinator - - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: JSONSyntaxTextView - var isUpdating = false - private var highlightWorkItem: DispatchWorkItem? - - init(_ parent: JSONSyntaxTextView) { - self.parent = parent - } - - deinit { - highlightWorkItem?.cancel() - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { return } - isUpdating = true - parent.text = textView.string - isUpdating = false - - // Debounce syntax highlighting to avoid 4 regex passes per keystroke - highlightWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak textView] in - guard let textView else { return } - JSONSyntaxTextView.applyHighlighting(to: textView) - } - highlightWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) - } - } -} diff --git a/TablePro/Views/Results/JSONHighlightPatterns.swift b/TablePro/Views/Results/JSONHighlightPatterns.swift index 6d74adbeb..bc30d0dc2 100644 --- a/TablePro/Views/Results/JSONHighlightPatterns.swift +++ b/TablePro/Views/Results/JSONHighlightPatterns.swift @@ -5,7 +5,7 @@ import Foundation // swiftlint:disable force_try -enum JSONHighlightPatterns { +internal enum JSONHighlightPatterns { static let string = try! NSRegularExpression(pattern: "\"(?:[^\"\\\\]|\\\\.)*\"") static let key = try! NSRegularExpression(pattern: "(\"(?:[^\"\\\\]|\\\\.)*\")\\s*:") static let number = try! NSRegularExpression(pattern: "(?<=[\\s,:\\[{])-?\\d+\\.?\\d*(?:[eE][+-]?\\d+)?(?=[\\s,\\]}])") diff --git a/TablePro/Views/Results/JSONSyntaxTextView.swift b/TablePro/Views/Results/JSONSyntaxTextView.swift new file mode 100644 index 000000000..d0c0a4c6b --- /dev/null +++ b/TablePro/Views/Results/JSONSyntaxTextView.swift @@ -0,0 +1,155 @@ +// +// JSONSyntaxTextView.swift +// TablePro +// +// Reusable NSTextView-backed JSON viewer with syntax highlighting. +// Supports editable and read-only modes with brace matching. +// + +import AppKit +import SwiftUI + +internal struct JSONSyntaxTextView: NSViewRepresentable { + @Binding var text: String + var isEditable: Bool = true + var wordWrap: Bool = false + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + guard let textView = scrollView.documentView as? NSTextView else { + return scrollView + } + + textView.isEditable = isEditable + textView.isSelectable = true + textView.font = NSFont.monospacedSystemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium, weight: .regular) + textView.textContainerInset = NSSize(width: 4, height: 4) + textView.backgroundColor = .textBackgroundColor + textView.textColor = NSColor.labelColor + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isGrammarCheckingEnabled = false + textView.allowsUndo = isEditable + + if wordWrap { + textView.textContainer?.widthTracksTextView = true + textView.isHorizontallyResizable = false + } else { + textView.textContainer?.widthTracksTextView = false + textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isHorizontallyResizable = true + scrollView.hasHorizontalScroller = true + } + + textView.delegate = context.coordinator + textView.string = text + Self.applyHighlighting(to: textView) + + context.coordinator.braceHelper = JSONBraceMatchingHelper(textView: textView) + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + if textView.string != text, !context.coordinator.isUpdating { + textView.string = text + Self.applyHighlighting(to: textView) + } + } + + // MARK: - Syntax Highlighting + + static func applyHighlighting(to textView: NSTextView) { + guard let textStorage = textView.textStorage else { return } + let length = textStorage.length + guard length > 0 else { return } + + let fullRange = NSRange(location: 0, length: length) + let font = textView.font ?? NSFont.monospacedSystemFont(ofSize: ThemeEngine.shared.activeTheme.typography.medium, weight: .regular) + let content = textStorage.string + let maxHighlightLength = 10_000 + let highlightRange: NSRange + if length > maxHighlightLength { + highlightRange = NSRange(location: 0, length: maxHighlightLength) + } else { + highlightRange = fullRange + } + + textStorage.beginEditing() + + textStorage.addAttribute(.font, value: font, range: fullRange) + textStorage.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) + + // Apply in order: strings (red) first, then keys (blue) override string ranges, + // then numbers and booleans. Key highlighting depends on overriding string ranges. + applyPattern(JSONHighlightPatterns.string, color: .systemRed, in: textStorage, content: content, range: highlightRange) + + for match in JSONHighlightPatterns.key.matches(in: content, range: highlightRange) { + let captureRange = match.range(at: 1) + if captureRange.location != NSNotFound { + textStorage.addAttribute(.foregroundColor, value: NSColor.systemBlue, range: captureRange) + } + } + + applyPattern(JSONHighlightPatterns.number, color: .systemPurple, in: textStorage, content: content, range: highlightRange) + applyPattern(JSONHighlightPatterns.booleanNull, color: .systemOrange, in: textStorage, content: content, range: highlightRange) + + textStorage.endEditing() + } + + private static func applyPattern( + _ regex: NSRegularExpression, + color: NSColor, + in textStorage: NSTextStorage, + content: String, + range: NSRange + ) { + for match in regex.matches(in: content, range: range) { + textStorage.addAttribute(.foregroundColor, value: color, range: match.range) + } + } + + // MARK: - Coordinator + + internal final class Coordinator: NSObject, NSTextViewDelegate { + var parent: JSONSyntaxTextView + var isUpdating = false + var braceHelper: JSONBraceMatchingHelper? + private var highlightWorkItem: DispatchWorkItem? + + init(_ parent: JSONSyntaxTextView) { + self.parent = parent + } + + deinit { + highlightWorkItem?.cancel() + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + isUpdating = true + parent.text = textView.string + isUpdating = false + + highlightWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak textView] in + guard let textView else { return } + JSONSyntaxTextView.applyHighlighting(to: textView) + } + highlightWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) + } + + func textViewDidChangeSelection(_ notification: Notification) { + braceHelper?.updateBraceHighlight() + } + } +} diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index cfa7fe9bc..026ad5ada 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -1,420 +1,135 @@ // -// EditableFieldView.swift +// FieldDetailView.swift // TablePro // -// Compact, type-aware field editor for right sidebar. -// Two-line layout: field name + type badge, then native editor + menu. +// Thin orchestrator for field detail display in the right sidebar. +// Delegates to extracted editor views via FieldEditorResolver. // import SwiftUI -/// Compact editable field view using native macOS components -struct EditableFieldView: View { - let columnName: String - let columnTypeEnum: ColumnType - let isLongText: Bool - @Binding var value: String - let originalValue: String? - let hasMultipleValues: Bool +internal struct FieldDetailView: View { + let context: FieldEditorContext let isPendingNull: Bool let isPendingDefault: Bool let isModified: Bool let isTruncated: Bool let isLoadingFullValue: Bool - + let databaseType: DatabaseType let onSetNull: () -> Void let onSetDefault: () -> Void let onSetEmpty: () -> Void let onSetFunction: (String) -> Void - @FocusState private var isFocused: Bool @State private var isHovered = false - @State private var isSetPopoverPresented = false - @State private var hexEditText = "" - - private var placeholderText: String { - if hasMultipleValues { - return String(localized: "Multiple values") - } else if let original = originalValue { - return original - } else { - return "NULL" - } - } var body: some View { - VStack(alignment: .leading, spacing: 4) { - // Line 1: modified indicator + field name + type badge - HStack(spacing: 4) { - if isModified { - Circle() - .fill(Color.accentColor) - .frame(width: 6, height: 6) - } - - Text(columnName) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .lineLimit(1) - - Spacer() - - Text(columnTypeEnum.badgeLabel) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, weight: .medium)) - .foregroundStyle(.tertiary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(.quaternary) - .clipShape(Capsule()) + let kind = FieldEditorResolver.resolve( + for: context.columnType, + isLongText: context.isLongText, + originalValue: context.originalValue + ) - if isTruncated && !isLoadingFullValue { - Text("truncated") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, weight: .medium)) - .foregroundStyle(.orange) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(.orange.opacity(0.15)) - .clipShape(Capsule()) - } + VStack(alignment: .leading, spacing: 4) { + fieldHeader + + PendingStateOverlay( + isPendingNull: isPendingNull, + isPendingDefault: isPendingDefault, + isLoadingFullValue: isLoadingFullValue, + isTruncated: isTruncated, + minHeight: editorMinHeight(for: kind) + ) { + resolvedEditor(for: kind) } - - // Line 2: full-width editor with inline menu overlay - typeAwareEditor - .overlay(alignment: .topTrailing) { - fieldMenu - .opacity(isHovered ? 1 : 0) - .padding(.trailing, 4) - } - } - .onHover { isHovered = $0 } - } - - // MARK: - Type-Aware Editor - - @ViewBuilder - private var typeAwareEditor: some View { - if isLoadingFullValue { - TextField("", text: .constant("")) - .textFieldStyle(.roundedBorder) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .disabled(true) - .overlay { - ProgressView() - .controlSize(.small) - } - } else if isTruncated { - Text("Failed to load full value") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 4) - } else if isPendingNull || isPendingDefault { - TextField(isPendingNull ? "NULL" : "DEFAULT", text: .constant("")) - .textFieldStyle(.roundedBorder) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .disabled(true) - } else if columnTypeEnum.isEnumType, - let values = columnTypeEnum.enumValues, !values.isEmpty { - enumPicker(values: values) - } else if columnTypeEnum.isSetType, - let values = columnTypeEnum.enumValues, !values.isEmpty { - setPicker(values: values) - } else if columnTypeEnum.isBooleanType { - booleanPicker - } else if BlobFormattingService.shared.requiresFormatting(columnType: columnTypeEnum) { - blobHexEditor - } else if isLongText || columnTypeEnum.isJsonType { - multiLineEditor - } else { - singleLineEditor - } - } - - private var blobHexEditor: some View { - VStack(alignment: .leading, spacing: 2) { - TextField("Hex bytes", text: $hexEditText, axis: .vertical) - .textFieldStyle(.roundedBorder) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, design: .monospaced)) - .lineLimit(3...8) - .focused($isFocused) - .onAppear { - hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? "" - } - .onChange(of: value) { - if !isFocused { - hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? "" - } - } - .onChange(of: isFocused) { - if !isFocused { - commitHexEdit() - } - } - - HStack(spacing: 4) { - if let byteCount = value.data(using: .isoLatin1)?.count, byteCount > 0 { - Text("\(byteCount) bytes") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) - .foregroundStyle(.tertiary) - } - - if BlobFormattingService.shared.parseHex(hexEditText) == nil, !hexEditText.isEmpty { - Text("Invalid hex") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) - .foregroundStyle(.red) + .overlay(alignment: .topTrailing) { + if !context.isReadOnly { + FieldMenuView( + value: context.value.wrappedValue, + columnType: context.columnType, + sqlFunctions: SQLFunctionProvider.functions(for: databaseType), + isPendingNull: isPendingNull, + isPendingDefault: isPendingDefault, + onSetNull: onSetNull, + onSetDefault: onSetDefault, + onSetEmpty: onSetEmpty, + onSetFunction: onSetFunction, + onClear: { context.value.wrappedValue = context.originalValue ?? "" } + ) + .opacity(isHovered ? 1 : 0) + .padding(.trailing, 4) } } } + .onHover { isHovered = $0 } } - private func commitHexEdit() { - if let raw = BlobFormattingService.shared.parseHex(hexEditText) { - value = raw - } else { - hexEditText = BlobFormattingService.shared.format(value, for: .edit) ?? "" - } - } - - private var booleanPicker: some View { - dropdownField(label: normalizeBooleanValue(value) == "1" ? "true" : "false") { - Button("true") { value = "1" } - Button("false") { value = "0" } - } - } + // MARK: - Header - private func enumPicker(values: [String]) -> some View { - let label = value.isEmpty ? (values.first ?? "") : value - return dropdownField(label: label) { - ForEach(values, id: \.self) { val in - Button(val) { value = val } + private var fieldHeader: some View { + HStack(spacing: 4) { + if isModified { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) } - } - } - private func setPicker(values: [String]) -> some View { - let displayLabel = value.isEmpty ? String(localized: "No selection") : value - return Button { - isSetPopoverPresented = true - } label: { - Text(displayLabel) + Text(context.columnName) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(.horizontal, 4) - .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) - .background(.quinary, in: RoundedRectangle(cornerRadius: 5)) - .popover(isPresented: $isSetPopoverPresented) { - SetPopoverContentView( - allowedValues: values, - initialSelections: parseSetSelections(from: value, allowed: values), - onCommit: { result in - value = result ?? "" - }, - onDismiss: { - isSetPopoverPresented = false - } - ) - } - } + .lineLimit(1) - private func parseSetSelections(from value: String, allowed: [String]) -> [String: Bool] { - let selected = Set(value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }) - var dict: [String: Bool] = [:] - for val in allowed { - dict[val] = selected.contains(val) - } - return dict - } + Spacer() - private func dropdownField( - label: String, - @ViewBuilder content: @escaping () -> Content - ) -> some View { - Menu { - content() - } label: { - Text(label) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .padding(.horizontal, 4) - .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) - .background(.quinary, in: RoundedRectangle(cornerRadius: 5)) - } - - private var multiLineEditor: some View { - TextField(placeholderText, text: $value, axis: .vertical) - .textFieldStyle(.roundedBorder) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .lineLimit(3...6) - .focused($isFocused) - } - - private var singleLineEditor: some View { - TextField(placeholderText, text: $value) - .textFieldStyle(.roundedBorder) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .focused($isFocused) - } - - // MARK: - Field Menu - - private var fieldMenu: some View { - Menu { - Button("Set NULL") { - onSetNull() - } - - Button("Set DEFAULT") { - onSetDefault() - } - - Button("Set EMPTY") { - onSetEmpty() - } - - Divider() - - if columnTypeEnum.isJsonType { - Button("Pretty Print") { - if let formatted = value.prettyPrintedAsJson() { - value = formatted - } - } - } - - if BlobFormattingService.shared.requiresFormatting(columnType: columnTypeEnum) { - Button("Copy as Hex") { - if let hex = BlobFormattingService.shared.format(value, for: .detail) { - ClipboardService.shared.writeText(hex) - } - } - } - - Button("Copy Value") { - ClipboardService.shared.writeText(value) - } - - Divider() - - Menu("SQL Functions") { - Button("NOW()") { onSetFunction("NOW()") } - Button("CURRENT_TIMESTAMP()") { onSetFunction("CURRENT_TIMESTAMP()") } - Button("CURDATE()") { onSetFunction("CURDATE()") } - Button("CURTIME()") { onSetFunction("CURTIME()") } - Button("UTC_TIMESTAMP()") { onSetFunction("UTC_TIMESTAMP()") } - } + Text(context.columnType.badgeLabel) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, weight: .medium)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary) + .clipShape(Capsule()) - if isPendingNull || isPendingDefault { - Divider() - Button("Clear") { - value = originalValue ?? "" - } - } - } label: { - Image(systemName: "chevron.down") - .font(.system(size: 10)) - .frame(width: 20, height: 20) - .contentShape(Rectangle()) - } - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize() - } - - // MARK: - Helpers - - private func normalizeBooleanValue(_ val: String) -> String { - let lower = val.lowercased() - if lower == "true" || lower == "1" || lower == "t" || lower == "yes" { - return "1" - } - return "0" - } -} - -/// Read-only field view using native macOS components -struct ReadOnlyFieldView: View { - let columnName: String - let columnTypeEnum: ColumnType - let isLongText: Bool - let value: String? - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - // Line 1: field name + type badge - HStack(spacing: 4) { - Text(columnName) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .lineLimit(1) - - Spacer() - - Text(columnTypeEnum.badgeLabel) + if isTruncated && !isLoadingFullValue { + Text("truncated") .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, weight: .medium)) - .foregroundStyle(.tertiary) + .foregroundStyle(.orange) .padding(.horizontal, 5) .padding(.vertical, 1) - .background(.quaternary) + .background(.orange.opacity(0.15)) .clipShape(Capsule()) } + } + } - // Line 2: value in disabled native text field - if let value { - if BlobFormattingService.shared.requiresFormatting(columnType: columnTypeEnum) { - ScrollView { - Text(BlobFormattingService.shared.formatIfNeeded(value, columnType: columnTypeEnum, for: .detail)) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(maxHeight: 120) - } else if columnTypeEnum.isJsonType { - ScrollView { - Text(value) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(maxHeight: 200) - } else if isLongText { - Text(value) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, maxHeight: 80, alignment: .topLeading) - } else { - TextField("", text: .constant(value)) - .textFieldStyle(.roundedBorder) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .disabled(true) - } - } else { - TextField("NULL", text: .constant("")) - .textFieldStyle(.roundedBorder) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .disabled(true) - } + private func editorMinHeight(for kind: FieldEditorKind) -> CGFloat? { + switch kind { + case .json: + return context.isReadOnly ? 60 : 80 + case .blobHex: + return 60 + default: + return nil } - .contextMenu { - if let value { - Button("Copy Value") { - ClipboardService.shared.writeText(value) - } + } - if BlobFormattingService.shared.requiresFormatting(columnType: columnTypeEnum) { - Button("Copy as Hex") { - if let hex = BlobFormattingService.shared.format(value, for: .detail) { - ClipboardService.shared.writeText(hex) - } - } - } - } + // MARK: - Editor Dispatch + + @ViewBuilder + private func resolvedEditor(for kind: FieldEditorKind) -> some View { + switch kind { + case .json: + JsonEditorView(context: context) + case .blobHex: + BlobHexEditorView(context: context) + case .boolean: + BooleanPickerView(context: context) + case .enumPicker(let values): + EnumPickerView(context: context, values: values) + case .setPicker(let values): + SetPickerView(context: context, values: values) + case .multiLine: + MultiLineEditorView(context: context) + case .singleLine: + SingleLineEditorView(context: context) } } } diff --git a/TablePro/Views/RightSidebar/FieldEditors/BlobHexEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/BlobHexEditorView.swift new file mode 100644 index 000000000..f889f0cd5 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/BlobHexEditorView.swift @@ -0,0 +1,76 @@ +// +// BlobHexEditorView.swift +// TablePro +// + +import SwiftUI + +internal struct BlobHexEditorView: View { + let context: FieldEditorContext + + @FocusState private var isFocused: Bool + @State private var hexEditText = "" + + var body: some View { + if context.isReadOnly { + readOnlyHexView + } else { + editableHexView + } + } + + private var readOnlyHexView: some View { + ScrollView { + Text(BlobFormattingService.shared.format(context.value.wrappedValue, for: .detail) ?? "") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxHeight: 120) + } + + private var editableHexView: some View { + VStack(alignment: .leading, spacing: 2) { + TextField("Hex bytes", text: $hexEditText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, design: .monospaced)) + .lineLimit(3...8) + .focused($isFocused) + .onAppear { + hexEditText = BlobFormattingService.shared.format(context.value.wrappedValue, for: .edit) ?? "" + } + .onChange(of: context.value.wrappedValue) { + if !isFocused { + hexEditText = BlobFormattingService.shared.format(context.value.wrappedValue, for: .edit) ?? "" + } + } + .onChange(of: isFocused) { + if !isFocused { + commitHexEdit() + } + } + + HStack(spacing: 4) { + if let byteCount = context.value.wrappedValue.data(using: .isoLatin1)?.count, byteCount > 0 { + Text("\(byteCount) bytes") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) + .foregroundStyle(.tertiary) + } + + if BlobFormattingService.shared.parseHex(hexEditText) == nil, !hexEditText.isEmpty { + Text("Invalid hex") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) + .foregroundStyle(.red) + } + } + } + } + + private func commitHexEdit() { + if let raw = BlobFormattingService.shared.parseHex(hexEditText) { + context.value.wrappedValue = raw + } else { + hexEditText = BlobFormattingService.shared.format(context.value.wrappedValue, for: .edit) ?? "" + } + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/BooleanPickerView.swift b/TablePro/Views/RightSidebar/FieldEditors/BooleanPickerView.swift new file mode 100644 index 000000000..727b7429a --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/BooleanPickerView.swift @@ -0,0 +1,28 @@ +// +// BooleanPickerView.swift +// TablePro +// + +import SwiftUI + +internal struct BooleanPickerView: View { + let context: FieldEditorContext + + var body: some View { + dropdownField( + label: normalizeBooleanValue(context.value.wrappedValue) == "1" ? "true" : "false", + isDisabled: context.isReadOnly + ) { + Button("true") { context.value.wrappedValue = "1" } + Button("false") { context.value.wrappedValue = "0" } + } + } + + private func normalizeBooleanValue(_ val: String) -> String { + let lower = val.lowercased() + if lower == "true" || lower == "1" || lower == "t" || lower == "yes" { + return "1" + } + return "0" + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/DropdownFieldHelper.swift b/TablePro/Views/RightSidebar/FieldEditors/DropdownFieldHelper.swift new file mode 100644 index 000000000..98e55b290 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/DropdownFieldHelper.swift @@ -0,0 +1,30 @@ +// +// DropdownFieldHelper.swift +// TablePro +// + +import SwiftUI + +/// Reusable dropdown field wrapper with consistent styling for picker editors. +@MainActor +internal func dropdownField( + label: String, + isDisabled: Bool = false, + @ViewBuilder content: @escaping () -> Content +) -> some View { + Menu { + content() + } label: { + Text(label) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .padding(.horizontal, 4) + .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) + .background(.quinary, in: RoundedRectangle(cornerRadius: 5)) + .disabled(isDisabled) +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/EnumPickerView.swift b/TablePro/Views/RightSidebar/FieldEditors/EnumPickerView.swift new file mode 100644 index 000000000..a6470fdf5 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/EnumPickerView.swift @@ -0,0 +1,20 @@ +// +// EnumPickerView.swift +// TablePro +// + +import SwiftUI + +internal struct EnumPickerView: View { + let context: FieldEditorContext + let values: [String] + + var body: some View { + let label = context.value.wrappedValue.isEmpty ? (values.first ?? "") : context.value.wrappedValue + dropdownField(label: label, isDisabled: context.isReadOnly) { + ForEach(values, id: \.self) { val in + Button(val) { context.value.wrappedValue = val } + } + } + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift new file mode 100644 index 000000000..14ec94732 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift @@ -0,0 +1,25 @@ +// +// FieldEditorContext.swift +// TablePro + +import SwiftUI + +internal struct FieldEditorContext { + let columnName: String + let columnType: ColumnType + let isLongText: Bool + let value: Binding + let originalValue: String? + let hasMultipleValues: Bool + let isReadOnly: Bool + + var placeholderText: String { + if hasMultipleValues { + return String(localized: "Multiple values") + } else if let original = originalValue { + return original + } else { + return "NULL" + } + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorResolver.swift b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorResolver.swift new file mode 100644 index 000000000..853edbbb6 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorResolver.swift @@ -0,0 +1,38 @@ +// +// FieldEditorResolver.swift +// TablePro + +internal enum FieldEditorKind: Equatable { + case json + case blobHex + case boolean + case enumPicker(values: [String]) + case setPicker(values: [String]) + case multiLine + case singleLine +} + +@MainActor +internal enum FieldEditorResolver { + static func resolve(for type: ColumnType, isLongText: Bool, originalValue: String?) -> FieldEditorKind { + if type.isJsonType || (originalValue ?? "").looksLikeJson { + return .json + } + if type.isEnumType, let values = type.enumValues, !values.isEmpty { + return .enumPicker(values: values) + } + if type.isSetType, let values = type.enumValues, !values.isEmpty { + return .setPicker(values: values) + } + if type.isBooleanType { + return .boolean + } + if BlobFormattingService.shared.requiresFormatting(columnType: type) { + return .blobHex + } + if isLongText { + return .multiLine + } + return .singleLine + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldMenuView.swift b/TablePro/Views/RightSidebar/FieldEditors/FieldMenuView.swift new file mode 100644 index 000000000..ed8f97aaa --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/FieldMenuView.swift @@ -0,0 +1,71 @@ +// +// FieldMenuView.swift +// TablePro +// + +import SwiftUI + +internal struct FieldMenuView: View { + let value: String + let columnType: ColumnType + let sqlFunctions: [SQLFunctionProvider.SQLFunction] + let isPendingNull: Bool + let isPendingDefault: Bool + let onSetNull: () -> Void + let onSetDefault: () -> Void + let onSetEmpty: () -> Void + let onSetFunction: (String) -> Void + let onClear: () -> Void + + var body: some View { + Menu { + Button("Set NULL") { onSetNull() } + Button("Set DEFAULT") { onSetDefault() } + Button("Set EMPTY") { onSetEmpty() } + + Divider() + + if columnType.isJsonType { + Button("Pretty Print") { + if let formatted = value.prettyPrintedAsJson() { + ClipboardService.shared.writeText(formatted) + } + } + } + + if BlobFormattingService.shared.requiresFormatting(columnType: columnType) { + Button("Copy as Hex") { + if let hex = BlobFormattingService.shared.format(value, for: .detail) { + ClipboardService.shared.writeText(hex) + } + } + } + + Button("Copy Value") { + ClipboardService.shared.writeText(value) + } + + Divider() + + Menu("SQL Functions") { + ForEach(sqlFunctions, id: \.expression) { function in + Button(function.label) { onSetFunction(function.expression) } + } + } + + if isPendingNull || isPendingDefault { + Divider() + Button("Clear") { onClear() } + } + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 10)) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift new file mode 100644 index 000000000..507fe2be0 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift @@ -0,0 +1,17 @@ +// +// JsonEditorView.swift +// TablePro +// + +import SwiftUI + +internal struct JsonEditorView: View { + let context: FieldEditorContext + + var body: some View { + JSONSyntaxTextView(text: context.value, isEditable: !context.isReadOnly, wordWrap: true) + .frame(minHeight: context.isReadOnly ? 60 : 80, maxHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/MultiLineEditorView.swift new file mode 100644 index 000000000..8778e9ab2 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/MultiLineEditorView.swift @@ -0,0 +1,21 @@ +// +// MultiLineEditorView.swift +// TablePro +// + +import SwiftUI + +internal struct MultiLineEditorView: View { + let context: FieldEditorContext + + @FocusState private var isFocused: Bool + + var body: some View { + TextField(context.placeholderText, text: context.value, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .lineLimit(3...6) + .focused($isFocused) + .disabled(context.isReadOnly) + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/PendingStateOverlay.swift b/TablePro/Views/RightSidebar/FieldEditors/PendingStateOverlay.swift new file mode 100644 index 000000000..438f0576d --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/PendingStateOverlay.swift @@ -0,0 +1,43 @@ +// +// PendingStateOverlay.swift +// TablePro +// + +import SwiftUI + +internal struct PendingStateOverlay: View { + let isPendingNull: Bool + let isPendingDefault: Bool + let isLoadingFullValue: Bool + let isTruncated: Bool + var minHeight: CGFloat? + @ViewBuilder let editor: () -> Editor + + var body: some View { + if isLoadingFullValue { + TextField("", text: .constant("")) + .textFieldStyle(.roundedBorder) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .disabled(true) + .overlay { + ProgressView() + .controlSize(.small) + } + } else if isTruncated { + TextField(String(localized: "Value excluded from query"), text: .constant("")) + .textFieldStyle(.roundedBorder) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .disabled(true) + } else if isPendingNull || isPendingDefault { + Text(isPendingNull ? "NULL" : "DEFAULT") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .topLeading) + .padding(6) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + } else { + editor() + } + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/SetPickerView.swift b/TablePro/Views/RightSidebar/FieldEditors/SetPickerView.swift new file mode 100644 index 000000000..27703e75c --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/SetPickerView.swift @@ -0,0 +1,54 @@ +// +// SetPickerView.swift +// TablePro +// + +import SwiftUI + +internal struct SetPickerView: View { + let context: FieldEditorContext + let values: [String] + + @State private var isSetPopoverPresented = false + + var body: some View { + let displayLabel = context.value.wrappedValue.isEmpty + ? String(localized: "No selection") + : context.value.wrappedValue + + Button { + isSetPopoverPresented = true + } label: { + Text(displayLabel) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 4) + .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) + .background(.quinary, in: RoundedRectangle(cornerRadius: 5)) + .disabled(context.isReadOnly) + .popover(isPresented: $isSetPopoverPresented) { + SetPopoverContentView( + allowedValues: values, + initialSelections: parseSetSelections(from: context.value.wrappedValue, allowed: values), + onCommit: { result in + context.value.wrappedValue = result ?? "" + }, + onDismiss: { + isSetPopoverPresented = false + } + ) + } + } + + private func parseSetSelections(from value: String, allowed: [String]) -> [String: Bool] { + let selected = Set(value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }) + var dict: [String: Bool] = [:] + for val in allowed { + dict[val] = selected.contains(val) + } + return dict + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/SingleLineEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/SingleLineEditorView.swift new file mode 100644 index 000000000..d36669a83 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/SingleLineEditorView.swift @@ -0,0 +1,20 @@ +// +// SingleLineEditorView.swift +// TablePro +// + +import SwiftUI + +internal struct SingleLineEditorView: View { + let context: FieldEditorContext + + @FocusState private var isFocused: Bool + + var body: some View { + TextField(context.placeholderText, text: context.value) + .textFieldStyle(.roundedBorder) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .focused($isFocused) + .disabled(context.isReadOnly) + } +} diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index db02ed533..ff3527ff7 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -17,6 +17,7 @@ struct RightSidebarView: View { let onSave: () -> Void var editState: MultiRowEditState + let databaseType: DatabaseType @State private var searchText: String = "" @@ -176,12 +177,8 @@ struct RightSidebarView: View { .foregroundStyle(.tertiary) .frame(maxWidth: .infinity) } else { - ForEach(filtered, id: \.columnIndex) { field in - if contentMode == .editRow { - editableFieldRow(field, at: field.columnIndex) - } else { - readonlyFieldRow(field) - } + ForEach(filtered, id: \.id) { field in + fieldDetailRow(field, at: field.columnIndex, isEditable: contentMode == .editRow) } } } header: { @@ -212,38 +209,32 @@ struct RightSidebarView: View { } @ViewBuilder - private func editableFieldRow(_ field: FieldEditState, at index: Int) -> some View { - EditableFieldView( - columnName: field.columnName, - columnTypeEnum: field.columnTypeEnum, - isLongText: field.isLongText, - value: Binding( - get: { field.pendingValue ?? field.originalValue ?? "" }, - set: { editState.updateField(at: index, value: $0) } + private func fieldDetailRow(_ field: FieldEditState, at index: Int, isEditable: Bool) -> some View { + FieldDetailView( + context: FieldEditorContext( + columnName: field.columnName, + columnType: field.columnTypeEnum, + isLongText: field.isLongText, + value: isEditable ? Binding( + get: { field.pendingValue ?? field.originalValue ?? "" }, + set: { editState.updateField(at: index, value: $0) } + ) : .constant(field.originalValue ?? ""), + originalValue: field.originalValue, + hasMultipleValues: field.hasMultipleValues, + isReadOnly: !isEditable ), - originalValue: field.originalValue, - hasMultipleValues: field.hasMultipleValues, isPendingNull: field.isPendingNull, isPendingDefault: field.isPendingDefault, isModified: field.hasEdit, isTruncated: field.isTruncated, isLoadingFullValue: field.isLoadingFullValue, + databaseType: databaseType, onSetNull: { editState.setFieldToNull(at: index) }, onSetDefault: { editState.setFieldToDefault(at: index) }, onSetEmpty: { editState.setFieldToEmpty(at: index) }, onSetFunction: { editState.setFieldToFunction(at: index, function: $0) } ) } - - @ViewBuilder - private func readonlyFieldRow(_ field: FieldEditState) -> some View { - ReadOnlyFieldView( - columnName: field.columnName, - columnTypeEnum: field.columnTypeEnum, - isLongText: field.isLongText, - value: field.originalValue - ) - } } // MARK: - Preview @@ -269,7 +260,8 @@ struct RightSidebarView_Previews: PreviewProvider { isEditable: false, isRowDeleted: false, onSave: {}, - editState: MultiRowEditState() + editState: MultiRowEditState(), + databaseType: .mysql ) .frame(width: 280, height: 400) } diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index 8f239de79..108d03bc2 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -22,7 +22,8 @@ struct UnifiedRightPanelView: View { isEditable: inspectorContext.isEditable, isRowDeleted: inspectorContext.isRowDeleted, onSave: { state.onSave?() }, - editState: state.editState + editState: state.editState, + databaseType: connection.type ) } diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 3a6b5bd4b..85c2a2b11 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -285,7 +285,7 @@ The Cell Inspector is a right sidebar panel showing detailed information about t ### Row Details Mode -When a row is selected, the inspector shows all column values with full (untruncated) content. Search columns by name. TEXT/VARCHAR columns get a multi-line editor. Edit values here; changes are queued like inline edits. +When a row is selected, the inspector shows all column values with full (untruncated) content. Search columns by name. TEXT/VARCHAR columns get a multi-line editor. JSON/JSONB columns display syntax-highlighted JSON (colored keys, strings, numbers, and booleans) with brace matching. Edit values here; changes are queued like inline edits. ### Table Info Mode