diff --git a/CHANGELOG.md b/CHANGELOG.md index 2367f576f..8bacb83ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Open in Window on a row inspector text field, for reading or editing a long value on a bigger surface. + ### Fixed - Switch Connection and Open Database now open on a narrow window, and after you remove their toolbar button, instead of doing nothing at all. +- A large text value in the row inspector now scrolls in a resizable text view instead of being clipped, and stays selectable and copyable when the row is read-only. +- The inspector picks its multi-line editor from the value, so a large value in `VARCHAR(MAX)`, `NCLOB` or ClickHouse's `Nullable(String)` is no longer stuck on one line. +- Right-clicking a read-only inspector field now offers Copy Value instead of an empty menu. ## [0.67.1] - 2026-08-22 diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index 090212cc0..70c768ca9 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -229,6 +229,7 @@ final class AppSettingsStorage: Sendable { saveMCP(.default) defaults.removeObject(forKey: PreferenceKeys.selectedSettingsPane.name) defaults.removeObject(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name) + defaults.removeObject(forKey: PreferenceKeys.rowInspectorTextFieldHeight.name) defaults.removeObject(forKey: SidebarPersistenceKey.defaultLayout) } diff --git a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift index f72687792..b62baecab 100644 --- a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift +++ b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift @@ -10,6 +10,7 @@ enum PreferenceKeys { static let linkedSQLFolders = DefaultsKey<[LinkedSQLFolder]>("com.TablePro.linkedSQLFolders") static let selectedSettingsPane = DefaultsKey("com.TablePro.settings.selectedPane") static let rowInspectorJsonFieldHeight = DefaultsKey("com.TablePro.rightSidebar.jsonFieldHeight") + static let rowInspectorTextFieldHeight = DefaultsKey("com.TablePro.rightSidebar.textFieldHeight") static let workspaceRailOrder = DefaultsKey<[WorkspaceID]>("com.TablePro.workspaceRail.order") static let queryPlanRawFontSize = DefaultsKey("com.TablePro.queryPlan.rawFontSize") @@ -18,6 +19,7 @@ enum PreferenceKeys { linkedSQLFolders.name, selectedSettingsPane.name, rowInspectorJsonFieldHeight.name, + rowInspectorTextFieldHeight.name, workspaceRailOrder.name, queryPlanRawFontSize.name, ] diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 88273a69f..88fa54282 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -115311,6 +115311,74 @@ } } }, + "Text Viewer" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "텍스트 뷰어" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metin Görüntüleyici" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trình xem văn bản" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文本查看器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "文字檢視器" + } + } + } + }, + "Text: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "텍스트: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metin: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Văn bản: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文本:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "文字:%@" + } + } + } + }, "That doesn't look like a valid license key. Check for typos and try again." : { "localizations" : { "ko" : { diff --git a/TablePro/Views/Connection/ConnectionAdvancedView.swift b/TablePro/Views/Connection/ConnectionAdvancedView.swift index e8cd80420..5692d2986 100644 --- a/TablePro/Views/Connection/ConnectionAdvancedView.swift +++ b/TablePro/Views/Connection/ConnectionAdvancedView.swift @@ -5,6 +5,7 @@ // Created by Ngo Quoc Dat on 31/3/26. // +import AppKit import SwiftUI import TableProPluginKit @@ -121,50 +122,14 @@ struct ConnectionAdvancedView: View { // MARK: - Startup Commands Editor -struct StartupCommandsEditor: NSViewRepresentable { +struct StartupCommandsEditor: View { @Binding var text: String - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - guard let textView = scrollView.documentView as? NSTextView else { return scrollView } - - textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.isRichText = false - textView.string = text - textView.textContainerInset = NSSize(width: 4, height: 6) - textView.delegate = context.coordinator - - scrollView.borderType = .bezelBorder - scrollView.hasVerticalScroller = true - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { return } - if textView.string != text { - textView.string = text - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - final class Coordinator: NSObject, NSTextViewDelegate { - private var text: Binding - - init(text: Binding) { - self.text = text - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { return } - text.wrappedValue = textView.string - } + var body: some View { + TextValueEditor( + text: $text, + font: .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), + borderType: .bezelBorder + ) } } diff --git a/TablePro/Views/ConnectionForm/Panes/AIRulesPaneView.swift b/TablePro/Views/ConnectionForm/Panes/AIRulesPaneView.swift index f68e00ec5..8301cd74c 100644 --- a/TablePro/Views/ConnectionForm/Panes/AIRulesPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/AIRulesPaneView.swift @@ -42,62 +42,15 @@ struct AIRulesPaneView: View { } } -private struct AIRulesEditor: NSViewRepresentable { +private struct AIRulesEditor: View { @Binding var text: String - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - guard let textView = scrollView.documentView as? NSTextView else { return scrollView } - - textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.isRichText = false - textView.string = text - textView.textContainerInset = NSSize(width: 4, height: 6) - textView.delegate = context.coordinator - - scrollView.borderType = .bezelBorder - scrollView.hasVerticalScroller = true - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { return } - if textView.string != text { - textView.string = text - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - final class Coordinator: NSObject, NSTextViewDelegate { - private var text: Binding - - init(text: Binding) { - self.text = text - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { return } - text.wrappedValue = textView.string - } - - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if commandSelector == #selector(NSResponder.insertTab(_:)) { - textView.window?.selectNextKeyView(nil) - return true - } - if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - textView.window?.selectPreviousKeyView(nil) - return true - } - return false - } + var body: some View { + TextValueEditor( + text: $text, + font: .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), + borderType: .bezelBorder, + movesFocusOnTab: true + ) } } diff --git a/TablePro/Views/Results/JSONViewerWindowController.swift b/TablePro/Views/Results/JSONViewerWindowController.swift index 9e6cf7018..fed6705ff 100644 --- a/TablePro/Views/Results/JSONViewerWindowController.swift +++ b/TablePro/Views/Results/JSONViewerWindowController.swift @@ -7,15 +7,7 @@ import AppKit import SwiftUI @MainActor -final class JSONViewerWindowController { - private static var activeWindows: [ObjectIdentifier: JSONViewerWindowController] = [:] - private static let defaultSize = NSSize(width: 640, height: 500) - private static let minSize = NSSize(width: 400, height: 300) - private static let autosaveName: NSWindow.FrameAutosaveName = "JSONViewerWindow" - - private var window: NSWindow? - private var closeObserver: NSObjectProtocol? - +internal final class JSONViewerWindowController: ValueViewerWindowController { @discardableResult static func open( text: String?, @@ -23,68 +15,27 @@ final class JSONViewerWindowController { isEditable: Bool, onCommit: ((String) -> Void)? ) -> JSONViewerWindowController { - let controller = JSONViewerWindowController() - controller.showWindow(text: text, columnName: columnName, isEditable: isEditable, onCommit: onCommit) - return controller - } - - /// A popped-out editor commits through the display row it was opened from, so whoever opened it - /// has to be able to close it once those rows are gone. - func close() { - window?.close() - } - - private func showWindow( - text: String?, - columnName: String?, - isEditable: Bool, - onCommit: ((String) -> Void)? - ) { - let window = NSWindow( - contentRect: NSRect(origin: .zero, size: Self.defaultSize), - styleMask: [.titled, .closable, .resizable, .miniaturizable], - backing: .buffered, - defer: false - ) - window.identifier = NSUserInterfaceItemIdentifier("json-viewer") + let title: String if let columnName { - window.title = String(format: String(localized: "JSON: %@"), columnName) + title = String(format: String(localized: "JSON: %@"), columnName) } else { - window.title = String(localized: "JSON Viewer") + title = String(localized: "JSON Viewer") } - window.isReleasedWhenClosed = false - window.minSize = Self.minSize - window.collectionBehavior = [.fullScreenPrimary] - - let closeWindow: () -> Void = { [weak window] in window?.close() } - let contentView = JSONViewerWindowContent( - initialValue: text, - isEditable: isEditable, - onCommit: onCommit, - onDismiss: closeWindow - ) - window.contentView = NSHostingView(rootView: contentView) - self.window = window - - let key = ObjectIdentifier(self) - Self.activeWindows[key] = self - - closeObserver = NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, - object: window, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - Self.activeWindows.removeValue(forKey: key) - self?.closeObserver.map { NotificationCenter.default.removeObserver($0) } - self?.closeObserver = nil - self?.window = nil - } + let controller = JSONViewerWindowController() + controller.present( + identifier: "json-viewer", + title: title, + autosaveName: "JSONViewerWindow" + ) { dismiss in + JSONViewerWindowContent( + initialValue: text, + isEditable: isEditable, + onCommit: onCommit, + onDismiss: dismiss + ) } - - window.applyAutosaveName(Self.autosaveName) - window.makeKeyAndOrderFront(nil) + return controller } } diff --git a/TablePro/Views/Results/PhpViewerWindowController.swift b/TablePro/Views/Results/PhpViewerWindowController.swift index dca88761b..179e86772 100644 --- a/TablePro/Views/Results/PhpViewerWindowController.swift +++ b/TablePro/Views/Results/PhpViewerWindowController.swift @@ -7,78 +7,22 @@ import AppKit import SwiftUI @MainActor -final class PhpViewerWindowController { - private static var activeWindows: [ObjectIdentifier: PhpViewerWindowController] = [:] - private static let defaultSize = NSSize(width: 640, height: 500) - private static let minSize = NSSize(width: 400, height: 300) - private static let autosaveName: NSWindow.FrameAutosaveName = "PhpViewerWindow" - - private var window: NSWindow? - private var closeObserver: NSObjectProtocol? - +internal final class PhpViewerWindowController: ValueViewerWindowController { static func open(text: String?, columnName: String?) { - let controller = PhpViewerWindowController() - controller.showWindow(text: text, columnName: columnName) - } - - private func showWindow(text: String?, columnName: String?) { - let window = NSWindow( - contentRect: NSRect(origin: .zero, size: Self.defaultSize), - styleMask: [.titled, .closable, .resizable, .miniaturizable], - backing: .buffered, - defer: false - ) - window.identifier = NSUserInterfaceItemIdentifier("php-viewer") + let title: String if let columnName { - window.title = String(format: String(localized: "PHP: %@"), columnName) + title = String(format: String(localized: "PHP: %@"), columnName) } else { - window.title = String(localized: "PHP Viewer") + title = String(localized: "PHP Viewer") } - window.isReleasedWhenClosed = false - window.minSize = Self.minSize - window.collectionBehavior = [.fullScreenPrimary] - - let closeWindow: () -> Void = { [weak window] in window?.close() } - let contentView = PhpViewerWindowContent( - initialValue: text, - onDismiss: closeWindow - ) - window.contentView = NSHostingView(rootView: contentView) - - self.window = window - - let key = ObjectIdentifier(self) - Self.activeWindows[key] = self - closeObserver = NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, - object: window, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - Self.activeWindows.removeValue(forKey: key) - self?.closeObserver.map { NotificationCenter.default.removeObserver($0) } - self?.closeObserver = nil - self?.window = nil - } + let controller = PhpViewerWindowController() + controller.present( + identifier: "php-viewer", + title: title, + autosaveName: "PhpViewerWindow" + ) { dismiss in + PhpViewerView(rawValue: text ?? "", onDismiss: dismiss, onPopOut: nil) } - - window.applyAutosaveName(Self.autosaveName) - window.makeKeyAndOrderFront(nil) - } -} - -// MARK: - Window Content - -private struct PhpViewerWindowContent: View { - let initialValue: String? - let onDismiss: (() -> Void)? - - var body: some View { - PhpViewerView( - rawValue: initialValue ?? "", - onDismiss: onDismiss, - onPopOut: nil - ) } } diff --git a/TablePro/Views/Results/TextViewerWindowController.swift b/TablePro/Views/Results/TextViewerWindowController.swift new file mode 100644 index 000000000..8c938750b --- /dev/null +++ b/TablePro/Views/Results/TextViewerWindowController.swift @@ -0,0 +1,74 @@ +// +// TextViewerWindowController.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +internal final class TextViewerWindowController: ValueViewerWindowController { + static func open( + text: String?, + columnName: String?, + isEditable: Bool, + onCommit: ((String) -> Void)? + ) { + let title: String + if let columnName { + title = String(format: String(localized: "Text: %@"), columnName) + } else { + title = String(localized: "Text Viewer") + } + + let controller = TextViewerWindowController() + controller.present( + identifier: "text-viewer", + title: title, + autosaveName: "TextViewerWindow" + ) { dismiss in + TextViewerWindowContent( + initialValue: text, + isEditable: isEditable, + onCommit: onCommit, + onDismiss: dismiss + ) + } + } +} + +// MARK: - Window Content + +private struct TextViewerWindowContent: View { + let isEditable: Bool + let onCommit: ((String) -> Void)? + let onDismiss: (() -> Void)? + + @State private var text: String + + init( + initialValue: String?, + isEditable: Bool, + onCommit: ((String) -> Void)?, + onDismiss: (() -> Void)? + ) { + self.isEditable = isEditable + self.onCommit = onCommit + self.onDismiss = onDismiss + self._text = State(initialValue: initialValue ?? "") + } + + var body: some View { + TextValueEditor( + text: $text, + isEditable: isEditable, + font: .preferredFont(forTextStyle: .body), + textContainerInset: NSSize(width: 8, height: 10) + ) + .onChange(of: text) { + guard isEditable else { return } + onCommit?(text) + } + .onExitCommand { onDismiss?() } + } +} diff --git a/TablePro/Views/Results/ValueViewerWindowController.swift b/TablePro/Views/Results/ValueViewerWindowController.swift new file mode 100644 index 000000000..dc6b8991d --- /dev/null +++ b/TablePro/Views/Results/ValueViewerWindowController.swift @@ -0,0 +1,66 @@ +// +// ValueViewerWindowController.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// The detached window a popped-out cell value opens in. Subclasses supply the content and the +/// window's identity; the bookkeeping that keeps a detached window alive lives here once. +@MainActor +internal class ValueViewerWindowController { + private static var activeWindows: [ObjectIdentifier: ValueViewerWindowController] = [:] + private static let defaultSize = NSSize(width: 640, height: 500) + private static let minSize = NSSize(width: 400, height: 300) + + private var window: NSWindow? + private var closeObserver: NSObjectProtocol? + + /// A popped-out editor commits through the display row it was opened from, so whoever opened + /// it has to be able to close it once those rows are gone. + func close() { + window?.close() + } + + func present( + identifier: String, + title: String, + autosaveName: NSWindow.FrameAutosaveName, + @ViewBuilder content: (@escaping () -> Void) -> Content + ) { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: ValueViewerWindowController.defaultSize), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier(identifier) + window.title = title + window.isReleasedWhenClosed = false + window.minSize = ValueViewerWindowController.minSize + window.collectionBehavior = [.fullScreenPrimary] + window.contentView = NSHostingView(rootView: content { [weak window] in window?.close() }) + + self.window = window + + let key = ObjectIdentifier(self) + ValueViewerWindowController.activeWindows[key] = self + + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + ValueViewerWindowController.activeWindows.removeValue(forKey: key) + self?.closeObserver.map { NotificationCenter.default.removeObserver($0) } + self?.closeObserver = nil + self?.window = nil + } + } + + window.applyAutosaveName(autosaveName) + window.makeKeyAndOrderFront(nil) + } +} diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 949594a50..f93289c8f 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -25,20 +25,15 @@ internal struct FieldDetailView: View { @State private var isHovered = false - private var offersNullAndDefault: Bool { - !context.isReadOnly && context.allowsNullAndDefault - } - var body: some View { let kind = FieldEditorResolver.resolve(context: context) let isPickerField: Bool = { switch kind { - case .boolean, .enumPicker, .setPicker: return true + case .boolean, .enumPicker, .setPicker, .typePicker: return true default: return false } }() - let showsFieldMenu = offersNullAndDefault VStack(alignment: .leading, spacing: 4) { fieldHeader @@ -54,11 +49,12 @@ internal struct FieldDetailView: View { resolvedEditor(for: kind) } .overlay(alignment: .topTrailing) { - if showsFieldMenu && isHovered { + if isHovered { FieldMenuView( value: context.value.wrappedValue, columnType: context.columnType, sqlFunctions: SQLFunctionProvider.functions(for: databaseType), + canMutate: context.canMutate, isPendingNull: isPendingNull, isPendingDefault: isPendingDefault, onSetNull: onSetNull, @@ -75,20 +71,19 @@ internal struct FieldDetailView: View { .labelsHidden() .onHover { isHovered = $0 } .contextMenu { - if showsFieldMenu { - FieldMenuContent( - 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 ?? "" } - ) - } + FieldMenuContent( + value: context.value.wrappedValue, + columnType: context.columnType, + sqlFunctions: SQLFunctionProvider.functions(for: databaseType), + canMutate: context.canMutate, + isPendingNull: isPendingNull, + isPendingDefault: isPendingDefault, + onSetNull: onSetNull, + onSetDefault: onSetDefault, + onSetEmpty: onSetEmpty, + onSetFunction: onSetFunction, + onClear: { context.value.wrappedValue = context.originalValue ?? "" } + ) } } @@ -133,6 +128,8 @@ internal struct FieldDetailView: View { return 80 case .blobHex: return 60 + case .multiLine: + return ResizableFieldMetrics.defaultTextHeight default: return nil } @@ -160,8 +157,8 @@ internal struct FieldDetailView: View { context: context, isPendingNull: isPendingNull, isPendingDefault: isPendingDefault, - onSetNull: offersNullAndDefault ? onSetNull : nil, - onSetDefault: offersNullAndDefault ? onSetDefault : nil + onSetNull: context.canMutate ? onSetNull : nil, + onSetDefault: context.canMutate ? onSetDefault : nil ) case .enumPicker(let values): EnumPickerView( @@ -169,8 +166,8 @@ internal struct FieldDetailView: View { values: values, isPendingNull: isPendingNull, isPendingDefault: isPendingDefault, - onSetNull: offersNullAndDefault ? onSetNull : nil, - onSetDefault: offersNullAndDefault ? onSetDefault : nil + onSetNull: context.canMutate ? onSetNull : nil, + onSetDefault: context.canMutate ? onSetDefault : nil ) case .setPicker(let values): SetPickerView( @@ -178,15 +175,15 @@ internal struct FieldDetailView: View { values: values, isPendingNull: isPendingNull, isPendingDefault: isPendingDefault, - onSetNull: offersNullAndDefault ? onSetNull : nil, - onSetDefault: offersNullAndDefault ? onSetDefault : nil + onSetNull: context.canMutate ? onSetNull : nil, + onSetDefault: context.canMutate ? onSetDefault : nil ) case .typePicker: TypePickerFieldView(context: context, databaseType: databaseType) case .schemaText: SchemaTextFieldView(context: context) case .multiLine: - MultiLineEditorView(context: context) + MultiLineEditorView(context: context, onPopOut: onPopOut) case .singleLine: SingleLineEditorView(context: context) } diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift index e97346128..043a3d4c9 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift @@ -56,4 +56,19 @@ internal struct FieldEditorContext { return "NULL" } } + + /// Set NULL, Set DEFAULT and the SQL functions only make sense where an edit can be recorded. + /// The copy actions in the same menu are always available, which is why this is its own flag. + var canMutate: Bool { + !isReadOnly && allowsNullAndDefault + } + + /// A text view has no placeholder of its own, and echoing a long stored value behind an + /// emptied editor would draw the whole value twice. Only the state placeholders belong there, + /// and nil where the stored value really is the empty string: a database client must not + /// report `''` as NULL. + var emptyStatePlaceholder: String? { + if hasMultipleValues { return String(localized: "Multiple values") } + return originalValue == nil ? "NULL" : nil + } } diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldMenuView.swift b/TablePro/Views/RightSidebar/FieldEditors/FieldMenuView.swift index 73723f76d..c0886a1c1 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/FieldMenuView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/FieldMenuView.swift @@ -7,10 +7,14 @@ import SwiftUI /// The field actions (Set NULL/DEFAULT/EMPTY, copy, SQL functions). Shared by the /// hover menu button and the field's context menu so both stay in sync. +/// +/// A read-only field keeps the copy actions and loses the mutating ones. Hiding the whole menu +/// left a value that is neither selectable nor copyable. internal struct FieldMenuContent: View { let value: String let columnType: ColumnType let sqlFunctions: [SQLFunctionProvider.SQLFunction] + let canMutate: Bool let isPendingNull: Bool let isPendingDefault: Bool let onSetNull: () -> Void @@ -20,11 +24,13 @@ internal struct FieldMenuContent: View { let onClear: () -> Void var body: some View { - Button("Set NULL") { onSetNull() } - Button("Set DEFAULT") { onSetDefault() } - Button("Set EMPTY") { onSetEmpty() } + if canMutate { + Button("Set NULL") { onSetNull() } + Button("Set DEFAULT") { onSetDefault() } + Button("Set EMPTY") { onSetEmpty() } - Divider() + Divider() + } if columnType.isJsonType { Button("Pretty Print") { @@ -46,17 +52,19 @@ internal struct FieldMenuContent: View { ClipboardService.shared.writeText(value) } - Divider() + if canMutate { + Divider() - Menu("SQL Functions") { - ForEach(sqlFunctions, id: \.expression) { function in - Button(function.label) { onSetFunction(function.expression) } + Menu("SQL Functions") { + ForEach(sqlFunctions, id: \.expression) { function in + Button(function.label) { onSetFunction(function.expression) } + } } - } - if isPendingNull || isPendingDefault { - Divider() - Button("Clear") { onClear() } + if isPendingNull || isPendingDefault { + Divider() + Button("Clear") { onClear() } + } } } } @@ -65,6 +73,7 @@ internal struct FieldMenuView: View { let value: String let columnType: ColumnType let sqlFunctions: [SQLFunctionProvider.SQLFunction] + let canMutate: Bool let isPendingNull: Bool let isPendingDefault: Bool let onSetNull: () -> Void @@ -79,6 +88,7 @@ internal struct FieldMenuView: View { value: value, columnType: columnType, sqlFunctions: sqlFunctions, + canMutate: canMutate, isPendingNull: isPendingNull, isPendingDefault: isPendingDefault, onSetNull: onSetNull, diff --git a/TablePro/Views/RightSidebar/FieldEditors/MultiLineEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/MultiLineEditorView.swift index 25f44201a..387a860c1 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/MultiLineEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/MultiLineEditorView.swift @@ -7,16 +7,50 @@ import SwiftUI internal struct MultiLineEditorView: View { let context: FieldEditorContext + var onPopOut: ((String) -> Void)? - @FocusState private var isFocused: Bool + @AppStorage(PreferenceKeys.rowInspectorTextFieldHeight.name, store: AppStorageEnvironment.shared.defaults) + private var fieldHeight = ResizableFieldMetrics.defaultTextHeight var body: some View { - TextField(context.placeholderText, text: context.value, axis: .vertical) - .textFieldStyle(.roundedBorder) - .font(.subheadline) - .lineLimit(3...6) - .autocorrectionDisabled(true) - .focused($isFocused) - .disabled(context.isReadOnly) + ResizableEditorContainer(height: $fieldHeight, range: ResizableFieldMetrics.textHeightRange) { + TextValueEditor( + text: context.value, + isEditable: !context.isReadOnly, + font: .preferredFont(forTextStyle: .subheadline), + movesFocusOnTab: true + ) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) + .overlay(alignment: .topLeading) { placeholder } + .overlay(alignment: .bottomTrailing) { popOutButton } + } + } + + @ViewBuilder + private var placeholder: some View { + if context.value.wrappedValue.isEmpty, let text = context.emptyStatePlaceholder { + Text(text) + .font(.subheadline) + .foregroundStyle(.tertiary) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .allowsHitTesting(false) + } + } + + @ViewBuilder + private var popOutButton: some View { + if let onPopOut { + Button { onPopOut(context.value.wrappedValue) } label: { + Image(systemName: "arrow.up.forward.app") + .font(.caption2) + .padding(4) + .themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.borderless) + .help(String(localized: "Open in Window")) + .padding(4) + } } } diff --git a/TablePro/Views/RightSidebar/FieldEditors/ResizableFieldMetrics.swift b/TablePro/Views/RightSidebar/FieldEditors/ResizableFieldMetrics.swift index 155c37fcb..011e0b40d 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/ResizableFieldMetrics.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/ResizableFieldMetrics.swift @@ -9,6 +9,9 @@ internal enum ResizableFieldMetrics { static let jsonHeightRange: ClosedRange = 80...600 static let defaultJsonHeight: Double = 120 + static let textHeightRange: ClosedRange = 60...600 + static let defaultTextHeight: Double = 110 + static func resolve(base: Double, delta: Double, range: ClosedRange) -> Double { min(max(base + delta, range.lowerBound), range.upperBound) } diff --git a/TablePro/Views/RightSidebar/FieldEditors/SingleLineEditorView.swift b/TablePro/Views/RightSidebar/FieldEditors/SingleLineEditorView.swift index 5db143717..3756700df 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/SingleLineEditorView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/SingleLineEditorView.swift @@ -11,11 +11,30 @@ internal struct SingleLineEditorView: View { @FocusState private var isFocused: Bool var body: some View { - TextField(context.placeholderText, text: context.value) - .textFieldStyle(.roundedBorder) + if context.isReadOnly { + readOnlyValue + } else { + TextField(context.placeholderText, text: context.value) + .textFieldStyle(.roundedBorder) + .font(.subheadline) + .autocorrectionDisabled(true) + .focused($isFocused) + } + } + + /// A disabled text field takes no first responder, so a read-only value could be neither + /// selected nor copied out of the field. Selectable text is the read-only presentation. + private var readOnlyValue: some View { + let value = context.value.wrappedValue + let placeholder = value.isEmpty ? context.emptyStatePlaceholder : nil + return Text(placeholder ?? value) .font(.subheadline) - .autocorrectionDisabled(true) - .focused($isFocused) - .disabled(context.isReadOnly) + .foregroundStyle(placeholder == nil ? .primary : .tertiary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, minHeight: 16, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor))) } } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 12bd015e5..95b9c0909 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -266,6 +266,20 @@ struct RightSidebarView: View { PhpViewerWindowController.open(text: text, columnName: field.columnName) } + private func popOutTextField(text: String? = nil, field: FieldEditState, isEditable: Bool) { + let text = text ?? field.pendingValue ?? field.originalValue + let fieldId = field.id + TextViewerWindowController.open( + text: text, + columnName: field.columnName, + isEditable: isEditable, + onCommit: isEditable ? { [editState] newValue in + guard let current = editState.fields.first(where: { $0.id == fieldId }) else { return } + editState.updateField(at: current.columnIndex, value: newValue) + } : nil + ) + } + // MARK: - Field List private func fieldListForm( @@ -320,6 +334,7 @@ struct RightSidebarView: View { let isJsonField = kind == .json let isPhpField = kind == .phpSerialized let isStructuredField = isJsonField || isPhpField + let isTextField = kind == .multiLine FieldDetailView( context: FieldEditorContext( @@ -355,11 +370,13 @@ struct RightSidebarView: View { expandedPhpColumnIndex = field.columnIndex } } : nil, - onPopOut: isStructuredField ? { currentText in + onPopOut: isStructuredField || isTextField ? { currentText in if isJsonField { popOutJsonField(text: currentText, field: field, isEditable: isEditable) - } else { + } else if isPhpField { popOutPhpField(text: currentText, field: field) + } else { + popOutTextField(text: currentText, field: field, isEditable: isEditable) } } : nil ) diff --git a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index a2ed33b52..ae2bfb4a4 100644 --- a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -66,9 +66,24 @@ internal enum FieldEditorResolver { if BlobFormattingService.shared.requiresFormatting(columnType: type) { return .blobHex } - if isLongText { + if isLongText || needsMultiLineEditor(originalValue) { return .multiLine } return .singleLine } + + /// `isLongText` only matches six exact type names, so a large value in `VARCHAR(MAX)`, + /// `NCLOB` or ClickHouse's `Nullable(String)` never reached the multi-line editor. Whether a + /// value belongs on one line is a property of the value, so ask the value as well. + static func needsMultiLineEditor(_ value: String?) -> Bool { + guard let value, !value.isEmpty else { return false } + let text = value as NSString + if text.length > multiLineValueThreshold { return true } + return text.rangeOfCharacter(from: .newlines).location != NSNotFound + } + + /// Two lines' worth. Between 32 and 46 subheadline characters fit one line at the inspector's + /// minimum width, so a value past this needs a third line and a short scalar keeps the text + /// field AppKit intends for it. + static let multiLineValueThreshold = 80 } diff --git a/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift b/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift new file mode 100644 index 000000000..62f333206 --- /dev/null +++ b/TablePro/Views/Shared/FieldEditors/TextValueEditor.swift @@ -0,0 +1,122 @@ +// +// TextValueEditor.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// A scrolling plain-text view for a stored value. +/// +/// `TextField` is an `NSTextField` at every `axis` setting, so it clips instead of scrolling, and +/// `TextEditor` leaves AppKit's quote, dash and text substitutions on with no API to reach them, +/// which would rewrite a value on its way to the database. +internal struct TextValueEditor: NSViewRepresentable { + @Binding var text: String + var isEditable: Bool = true + var font: NSFont = .systemFont(ofSize: NSFont.systemFontSize) + var borderType: NSBorderType = .noBorder + var movesFocusOnTab: Bool = false + var textContainerInset = NSSize(width: 4, height: 6) + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + + guard let textView = scrollView.documentView as? NSTextView else { return scrollView } + TextValueEditor.applyPlainTextDefaults(to: textView) + textView.delegate = context.coordinator + applyConfiguration(to: scrollView, textView: textView, coordinator: context.coordinator) + + textView.string = text + context.coordinator.lastAppliedText = text + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + applyConfiguration(to: scrollView, textView: textView, coordinator: context.coordinator) + + guard context.coordinator.lastAppliedText != text else { return } + context.coordinator.lastAppliedText = text + + let caret = textView.selectedRange().location + textView.string = text + let clamped = min(caret, (text as NSString).length) + textView.setSelectedRange(NSRange(location: clamped, length: 0)) + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, movesFocusOnTab: movesFocusOnTab) + } + + /// AppKit substitutes typed quotes, dashes and text by default. A stored value has to reach + /// the database as the user typed it, so every substitution is off. + static func applyPlainTextDefaults(to textView: NSTextView) { + textView.isRichText = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isAutomaticLinkDetectionEnabled = false + textView.isAutomaticDataDetectionEnabled = false + textView.usesFindBar = true + textView.isIncrementalSearchingEnabled = true + textView.allowsUndo = true + } + + private func applyConfiguration(to scrollView: NSScrollView, textView: NSTextView, coordinator: Coordinator) { + coordinator.text = $text + coordinator.movesFocusOnTab = movesFocusOnTab + if scrollView.borderType != borderType { + scrollView.borderType = borderType + } + if textView.font != font { + textView.font = font + } + if textView.textContainerInset != textContainerInset { + textView.textContainerInset = textContainerInset + } + if textView.isEditable != isEditable { + textView.isEditable = isEditable + } + if !textView.isSelectable { + textView.isSelectable = true + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var text: Binding + var movesFocusOnTab: Bool + /// What the text view was last given. Comparing against it answers "has the binding + /// moved" without bridging the whole NSString back, which costs 45ms on a 1 MB value and + /// would run on every SwiftUI update pass. + var lastAppliedText: String = "" + + init(text: Binding, movesFocusOnTab: Bool) { + self.text = text + self.movesFocusOnTab = movesFocusOnTab + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + let value = textView.string + lastAppliedText = value + text.wrappedValue = value + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard movesFocusOnTab else { return false } + if commandSelector == #selector(NSResponder.insertTab(_:)) { + textView.window?.selectNextKeyView(nil) + return true + } + if commandSelector == #selector(NSResponder.insertBacktab(_:)) { + textView.window?.selectPreviousKeyView(nil) + return true + } + return false + } + } +} diff --git a/TableProTests/Views/RightSidebar/FieldEditorContextPolicyTests.swift b/TableProTests/Views/RightSidebar/FieldEditorContextPolicyTests.swift new file mode 100644 index 000000000..80b7401ee --- /dev/null +++ b/TableProTests/Views/RightSidebar/FieldEditorContextPolicyTests.swift @@ -0,0 +1,68 @@ +// +// FieldEditorContextPolicyTests.swift +// TableProTests +// + +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +@Suite("FieldEditorContext policy") +struct FieldEditorContextPolicyTests { + private func makeContext( + isReadOnly: Bool, + allowsNullAndDefault: Bool = true, + originalValue: String? = "value", + hasMultipleValues: Bool = false + ) -> FieldEditorContext { + FieldEditorContext( + columnName: "body", + columnType: .text(rawType: "TEXT"), + isLongText: true, + value: .constant(originalValue ?? ""), + originalValue: originalValue, + hasMultipleValues: hasMultipleValues, + isReadOnly: isReadOnly, + allowsNullAndDefault: allowsNullAndDefault + ) + } + + @Test("a read-only field cannot mutate, so the menu keeps only its copy actions") + func readOnlyFieldCannotMutate() { + #expect(!makeContext(isReadOnly: true).canMutate) + } + + @Test("an editable field can mutate") + func editableFieldCanMutate() { + #expect(makeContext(isReadOnly: false).canMutate) + } + + @Test("a schema field has no NULL or DEFAULT state, so it cannot mutate either") + func schemaFieldCannotMutate() { + #expect(!makeContext(isReadOnly: false, allowsNullAndDefault: false).canMutate) + } + + @Test("the empty-state placeholder never echoes the stored value") + func emptyStatePlaceholderIsAStateNotAValue() { + let long = String(repeating: "a", count: 5_000) + #expect(makeContext(isReadOnly: false, originalValue: long).emptyStatePlaceholder == nil) + #expect(makeContext(isReadOnly: false, originalValue: long).placeholderText == long) + } + + @Test("a stored empty string is not reported as NULL") + func storedEmptyStringIsNotNull() { + #expect(makeContext(isReadOnly: true, originalValue: "").emptyStatePlaceholder == nil) + } + + @Test("a stored NULL says NULL") + func storedNullSaysNull() { + #expect(makeContext(isReadOnly: true, originalValue: nil).emptyStatePlaceholder == "NULL") + } + + @Test("a multi-row selection says so instead of showing NULL") + func emptyStatePlaceholderReportsMultipleValues() { + let context = makeContext(isReadOnly: false, originalValue: nil, hasMultipleValues: true) + #expect(context.emptyStatePlaceholder == String(localized: "Multiple values")) + } +} diff --git a/TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift b/TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift index 50a56d98e..e7fbba892 100644 --- a/TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift +++ b/TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift @@ -34,4 +34,14 @@ final class ResizableFieldMetricsTests: XCTestCase { func testDefaultJsonHeightIsWithinJsonRange() { XCTAssertTrue(ResizableFieldMetrics.jsonHeightRange.contains(ResizableFieldMetrics.defaultJsonHeight)) } + + func testDefaultTextHeightIsWithinTextRange() { + XCTAssertTrue(ResizableFieldMetrics.textHeightRange.contains(ResizableFieldMetrics.defaultTextHeight)) + } + + func testTextHeightResolvesWithinItsOwnRange() { + let textRange = ResizableFieldMetrics.textHeightRange + XCTAssertEqual(ResizableFieldMetrics.resolve(base: 110, delta: -500, range: textRange), textRange.lowerBound) + XCTAssertEqual(ResizableFieldMetrics.resolve(base: 110, delta: 5_000, range: textRange), textRange.upperBound) + } } diff --git a/TableProTests/Views/Shared/FieldEditorResolverTests.swift b/TableProTests/Views/Shared/FieldEditorResolverTests.swift index 158240377..f4299ef90 100644 --- a/TableProTests/Views/Shared/FieldEditorResolverTests.swift +++ b/TableProTests/Views/Shared/FieldEditorResolverTests.swift @@ -51,6 +51,98 @@ struct FieldEditorResolverTests { #expect(kind == .phpSerialized) } + @Test("a stored value with a newline needs the multi-line editor whatever the column type says") + func newlineInVarcharReturnsMultiLine() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "VARCHAR(255)"), + isLongText: false, + originalValue: "first line\nsecond line" + ) + #expect(kind == .multiLine) + } + + @Test("a long single-line value needs the multi-line editor") + func longVarcharValueReturnsMultiLine() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "VARCHAR(10000)"), + isLongText: false, + originalValue: String(repeating: "a", count: 5_000) + ) + #expect(kind == .multiLine) + } + + @Test("NCLOB and VARCHAR(MAX) route on the value, which the exact-match type list never covered") + func longValueRoutesForTypesIsLongTextMisses() { + let long = String(repeating: "a", count: 20_000) + #expect(ColumnType.text(rawType: "NCLOB").isLongText == false) + #expect(ColumnType.text(rawType: "nvarchar(max)").isLongText == false) + #expect(ColumnType.text(rawType: "Nullable(String)").isLongText == false) + for raw in ["NCLOB", "nvarchar(max)", "Nullable(String)"] { + let type = ColumnType.text(rawType: raw) + #expect(FieldEditorResolver.resolve(for: type, isLongText: type.isLongText, originalValue: long) == .multiLine) + } + } + + @Test("a short scalar keeps the single-line field AppKit intends for it") + func shortVarcharStaysSingleLine() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "VARCHAR(255)"), + isLongText: false, + originalValue: "hello" + ) + #expect(kind == .singleLine) + } + + @Test("an empty long-text column still opens the multi-line editor") + func emptyLongTextColumnStaysMultiLine() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "TEXT"), + isLongText: true, + originalValue: "" + ) + #expect(kind == .multiLine) + } + + @Test("a NULL value in a short column stays single-line") + func nullShortValueStaysSingleLine() { + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "VARCHAR(255)"), + isLongText: false, + originalValue: nil + ) + #expect(kind == .singleLine) + } + + @Test("a value right at the threshold stays single-line and one past it does not") + func thresholdBoundary() { + let type = ColumnType.text(rawType: "VARCHAR(255)") + let atLimit = String(repeating: "a", count: FieldEditorResolver.multiLineValueThreshold) + let overLimit = String(repeating: "a", count: FieldEditorResolver.multiLineValueThreshold + 1) + #expect(FieldEditorResolver.resolve(for: type, isLongText: false, originalValue: atLimit) == .singleLine) + #expect(FieldEditorResolver.resolve(for: type, isLongText: false, originalValue: overLimit) == .multiLine) + } + + @Test("a long JSON value still opens the JSON editor rather than the plain text one") + func longJsonValueStillResolvesJson() { + let json = "{\"k\":\"" + String(repeating: "a", count: 5_000) + "\"}" + let kind = FieldEditorResolver.resolve( + for: .text(rawType: "TEXT"), + isLongText: true, + originalValue: json + ) + #expect(kind == .json) + } + + @Test("a long value in a boolean column still opens the picker") + func longValueInBooleanColumnStillResolvesPicker() { + let kind = FieldEditorResolver.resolve( + for: .boolean(rawType: "TINYINT(1)"), + isLongText: false, + originalValue: String(repeating: "1", count: 5_000) + ) + #expect(kind == .boolean) + } + @Test("override .json forces .json on non-JSON text") func overrideJsonWins() { let kind = FieldEditorResolver.resolve( diff --git a/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift b/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift new file mode 100644 index 000000000..307bcfb6f --- /dev/null +++ b/TableProTests/Views/Shared/TextValueEditorDefaultsTests.swift @@ -0,0 +1,45 @@ +// +// TextValueEditorDefaultsTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@MainActor +@Suite("TextValueEditor defaults") +struct TextValueEditorDefaultsTests { + @Test("every automatic substitution is off, so a typed value reaches the database unchanged") + func substitutionsAreDisabled() { + let textView = NSTextView(frame: .zero) + textView.isAutomaticQuoteSubstitutionEnabled = true + textView.isAutomaticDashSubstitutionEnabled = true + textView.isAutomaticTextReplacementEnabled = true + textView.isAutomaticSpellingCorrectionEnabled = true + textView.isAutomaticLinkDetectionEnabled = true + textView.isAutomaticDataDetectionEnabled = true + textView.isRichText = true + + TextValueEditor.applyPlainTextDefaults(to: textView) + + #expect(!textView.isAutomaticQuoteSubstitutionEnabled) + #expect(!textView.isAutomaticDashSubstitutionEnabled) + #expect(!textView.isAutomaticTextReplacementEnabled) + #expect(!textView.isAutomaticSpellingCorrectionEnabled) + #expect(!textView.isAutomaticLinkDetectionEnabled) + #expect(!textView.isAutomaticDataDetectionEnabled) + #expect(!textView.isRichText) + } + + /// `undoManager` itself comes from the window through the responder chain, so a detached text + /// view has none. What this function owns, and what a field editor needs, is `allowsUndo`: + /// without it Command Z walks past the field and reaches the app's row-edit undo instead. + @Test("undo is local, so Command Z in a field does not reach the app's row-edit undo") + func undoIsLocalToTheTextView() { + let textView = NSTextView(frame: .zero) + textView.allowsUndo = false + TextValueEditor.applyPlainTextDefaults(to: textView) + #expect(textView.allowsUndo) + } +} diff --git a/TableProUITests/InspectorLongTextFieldUITests.swift b/TableProUITests/InspectorLongTextFieldUITests.swift new file mode 100644 index 000000000..13d1a019c --- /dev/null +++ b/TableProUITests/InspectorLongTextFieldUITests.swift @@ -0,0 +1,105 @@ +// +// InspectorLongTextFieldUITests.swift +// TableProUITests +// +// The row inspector rendered a plain string in an NSTextField, which carries no scroll view at +// any axis setting, so a value past the line limit was clipped with no way to reach the rest. +// The element type is what separates the fix from the bug: a text view scrolls, a text field +// cannot, and both report the same accessibility value. +// + +import XCTest + +final class InspectorLongTextFieldUITests: UITestCase { + /// 1,998 characters on one line, produced by the sample database itself so the test needs no + /// fixture. XCUITest synthesizes typing at roughly 113ms per character, so the query is short + /// even though its result is not. + private let query = "SELECT hex(zeroblob(999));" + private let shortestAcceptedValue = 1_500 + + func testALongValueOpensInAScrollingTextViewInTheInspector() throws { + let app = try launchWithSampleDatabase() + let editor = try runQuery(in: app) + + let window = app.windows.firstMatch + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "The query must produce a result grid") + XCTAssertTrue( + waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, + "The query must return a row; the editor holds '\(editor.value as? String ?? "nil")'" + ) + + showInspector(in: app) + clickAtCenter(grid.tableRows.firstMatch) + + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.longValueTextView(in: window) != nil }, + "A \(shortestAcceptedValue)+ character value must render in a text view, which " + + "scrolls, rather than in a text field, which clips. Text view lengths present: " + + "\(textViewLengths(in: window))" + ) + } + + /// The editor has live autocompletion, and it costs this flow twice. Typing into a tab that is + /// not ready yet loses and reorders characters, and a suggestion list still open when Command + /// Return arrives takes the Return as an acceptance, so the run executed + /// `SELECT hex(zeroblob(999));Total`. Waiting for the empty editor, then for the exact query, + /// then dismissing the list, is what makes it deterministic; without it the failure surfaces + /// much later as a query that returned no rows. + private func runQuery(in app: XCUIApplication) throws -> XCUIElement { + app.typeKey("t", modifierFlags: .command) + + let editor = editorTextView(in: app) + XCTAssertTrue(editor.waitToExist(timeout: 15)) + XCTAssertTrue( + waitForValue("", in: editor, timeout: 15), + "A new tab starts with an empty editor; got '\(editor.value as? String ?? "nil")'" + ) + + app.typeText(query) + XCTAssertTrue( + waitForValue(query, in: editor, timeout: 15), + "Every typed character must land in the editor; got '\(editor.value as? String ?? "nil")'" + ) + + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue( + waitForValue(query, in: editor, timeout: 5), + "Dismissing the suggestion list must leave the query alone; got " + + "'\(editor.value as? String ?? "nil")'" + ) + + app.typeKey(.return, modifierFlags: .command) + return editor + } + + /// The inspector remembers whether it was open, so the starting state is whatever the previous + /// launch left. The View menu item reads Hide Inspector once it is showing, which is the only + /// handle on that state. + private func showInspector(in app: XCUIApplication) { + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + menuBar.menuBarItems["View"].click() + + let show = menuBar.menuItems["Show Inspector"] + if show.waitToExist(timeout: 5) { + show.click() + return + } + app.typeKey(.escape, modifierFlags: []) + } + + private func waitForValue(_ expected: String, in element: XCUIElement, timeout: TimeInterval) -> Bool { + waitForPredicate(timeout: timeout) { (element.value as? String) == expected } + } + + private func longValueTextView(in window: XCUIElement) -> XCUIElement? { + window.textViews.allElementsBoundByIndex.first { element in + (element.value as? String)?.count ?? 0 >= shortestAcceptedValue + } + } + + private func textViewLengths(in window: XCUIElement) -> [Int] { + window.textViews.allElementsBoundByIndex.map { ($0.value as? String)?.count ?? -1 } + } +} diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index 697e2a2a0..2c5983ca5 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -72,13 +72,30 @@ A value that refuses to open in one mode still opens in another. ## Row details inspector -**View > Show Inspector** (`Cmd+Option+I`) opens the right sidebar. Select a row and every field appears with an editor matched to its content, with no **Display As** step: JSON columns and text values that parse as JSON get the JSON editor, PHP serialized values the read-only tree, blob columns the hex editor, and enum, set, and boolean columns get pickers. +**View > Show Inspector** (`Cmd+Option+I`), or the toolbar button at the trailing end, opens it. A field's editor follows its content, with no **Display As** step, and the first match wins. + +| Content | Editor | +| --- | --- | +| A JSON column, or text that parses as JSON | JSON editor | +| A PHP serialized value | Read-only [PHP viewer](#php-serialized-viewer) | +| An enum, set, or boolean column | Picker | +| A blob column | Hex editor | +| A `TEXT` variant, `CLOB` or `NTEXT` column, or any value with a line break or over 80 characters | Scrolling text box | +| Anything else | One-line field | Row details inspector Row details inspector -JSON and PHP fields carry buttons to expand inside the sidebar or pop out to a window. Hover an editable field for a menu that sets NULL, the column default, an empty value, or a SQL function. With no row selected, the inspector lists table statistics instead: data, index and total size, row count, average row size, engine, collation, and creation and update dates, as far as the database reports them. On a Structure tab it follows the structure grid instead of the rows. +The length test reads the stored value, so a long value pasted into a one-line field stays on one line until you save and reselect the row. + +The text box and the JSON editor each carry a drag handle under them, with separate remembered heights that survive a relaunch. + +**Open in Window** detaches a JSON, PHP or text value into its own resizable window; on an editable row, edits there join the same pending changes as the field. **Expand in Sidebar**, on JSON and PHP only, fills the inspector with that one field, and **Fields** goes back. + +Right-click a field for its menu; an editable field also shows it on a hover button. **Set NULL**, **Set DEFAULT**, **Set EMPTY** and **SQL Functions** need an editable field. **Copy Value** is always there, and a read-only field keeps its text selectable. + +With no row selected, the inspector describes the table: data, index and total size, row count, average row size, engine, collation, and creation and update dates, as far as the database reports them. On a Structure tab it follows the structure grid. For whole-row JSON, switch the result to JSON mode with the switcher at the leading edge of the status bar, or from **View > Result View**. It shows what the [grid](/features/data-grid) shows, in the same order, minus hidden columns and rows marked for deletion, which the count line reports separately. Select rows in Data mode first to narrow it, then click **Copy JSON**.