From 1e7ed87e890490a31a8bdf446dbb3a6af046066e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 27 Apr 2026 14:12:54 +0700 Subject: [PATCH] refactor: native macOS inspector with dense layout, PK/FK icons, and inline picker actions --- CHANGELOG.md | 1 + .../MainSplitViewController.swift | 5 +- .../Infrastructure/MainWindowToolbar.swift | 10 ++ TablePro/Models/UI/MultiRowEditState.swift | 18 ++-- .../MainContentView+EventHandlers.swift | 7 +- .../RightSidebar/EditableFieldView.swift | 97 +++++++++++++------ .../FieldEditors/BooleanPickerView.swift | 42 +++++++- .../FieldEditors/EnumPickerView.swift | 43 +++++++- .../FieldEditors/SetPickerView.swift | 32 ++++-- .../Views/RightSidebar/RightSidebarView.swift | 27 ++---- .../RightSidebar/UnifiedRightPanelView.swift | 3 +- 11 files changed, 215 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c9d11394..95afcacfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Inspector: dense field list, PK/FK key icons, Set NULL/DEFAULT in picker dropdowns, toolbar tracking separator - OpenSSL shared as dylib across app and plugins, saving ~15MB in bundle size ## [0.36.0] - 2026-04-27 diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index dd7c936ef..b04a41f23 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -134,8 +134,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi inspectorHosting = NSHostingController(rootView: AnyView(buildInspectorView())) inspectorSplitItem = NSSplitViewItem(inspectorWithViewController: inspectorHosting) inspectorSplitItem.canCollapse = true - inspectorSplitItem.minimumThickness = 280 - inspectorSplitItem.maximumThickness = 500 + inspectorSplitItem.minimumThickness = 270 + inspectorSplitItem.maximumThickness = 400 addSplitViewItem(inspectorSplitItem) if currentSession == nil { @@ -391,7 +391,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi connection: currentSession.connection, tables: currentSession.tables ) - .background(Color(nsColor: .windowBackgroundColor)) } else { Color.clear } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index f7b911a74..6329c85d8 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -98,6 +98,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { private static let refreshSaveGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.refreshSaveGroup") private static let exportImportGroup = NSToolbarItem.Identifier("com.TablePro.toolbar.exportImportGroup") private static let sidebarToggle = NSToolbarItem.Identifier("com.TablePro.toolbar.sidebarToggle") + private static let inspectorTrackingSeparator = NSToolbarItem.Identifier("com.TablePro.toolbar.inspectorTrackingSeparator") // MARK: - NSToolbarDelegate @@ -114,6 +115,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { Self.newTab, Self.filters, Self.previewSQL, + Self.inspectorTrackingSeparator, Self.inspector, ] } @@ -132,6 +134,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { Self.exportImportGroup, Self.dashboard, Self.history, + Self.inspectorTrackingSeparator, ] } @@ -209,6 +212,13 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { ExportToolbarButton(coordinator: coordinator) ImportToolbarButton(coordinator: coordinator) }) + case Self.inspectorTrackingSeparator: + guard let splitView = coordinator.splitViewController?.splitView else { return nil } + return NSTrackingSeparatorToolbarItem( + identifier: itemIdentifier, + splitView: splitView, + dividerIndex: 1 + ) default: return nil } diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index 82efcd22d..2c490222b 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -17,25 +17,21 @@ struct FieldEditState: Identifiable { let columnTypeEnum: ColumnType let isLongText: Bool - /// Original values from all selected rows (nil if multiple different values) + var isPrimaryKey: Bool = false + var isForeignKey: Bool = false + var originalValue: String? - /// Flag indicating if selected rows have different values for this field let hasMultipleValues: Bool - /// Pending new value (nil if not edited yet) var pendingValue: String? - /// Whether user has explicitly set this field to NULL var isPendingNull: Bool - /// Whether user has explicitly set this field to DEFAULT var isPendingDefault: Bool - /// Whether this field's value was truncated by column exclusion policy var isTruncated: Bool = false - /// Whether full value is currently being lazy-loaded var isLoadingFullValue: Bool = false var hasEdit: Bool { @@ -76,7 +72,9 @@ final class MultiRowEditState { columns: [String], columnTypes: [ColumnType], externallyModifiedColumns: Set = [], - excludedColumnNames: Set = [] + excludedColumnNames: Set = [], + primaryKeyColumns: Set = [], + foreignKeyColumns: Set = [] ) { // Check if the underlying data has changed (not just edits) let columnsChanged = self.columns != columns @@ -151,6 +149,8 @@ final class MultiRowEditState { columnName: columnName, columnTypeEnum: columnTypeEnum, isLongText: isLongText, + isPrimaryKey: primaryKeyColumns.contains(columnName), + isForeignKey: foreignKeyColumns.contains(columnName), originalValue: preservedOriginalValue, hasMultipleValues: hasMultipleValues, pendingValue: pendingValue, @@ -237,6 +237,8 @@ final class MultiRowEditState { columnName: fields[i].columnName, columnTypeEnum: fields[i].columnTypeEnum, isLongText: fields[i].isLongText, + isPrimaryKey: fields[i].isPrimaryKey, + isForeignKey: fields[i].isForeignKey, originalValue: fullValue, hasMultipleValues: fields[i].hasMultipleValues, pendingValue: fields[i].pendingValue, diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index df6560269..843e86e48 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -217,13 +217,18 @@ extension MainContentView { excludedNames = [] } + let pkColumns = Set(tab.primaryKeyColumns) + let fkColumns = Set(tab.columnForeignKeys.keys) + rightPanelState.editState.configure( selectedRowIndices: selectedRowIndices, allRows: allRows, columns: tab.resultColumns, columnTypes: columnTypes, externallyModifiedColumns: modifiedColumns, - excludedColumnNames: excludedNames + excludedColumnNames: excludedNames, + primaryKeyColumns: pkColumns, + foreignKeyColumns: fkColumns ) guard isSidebarEditable else { diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 080a42d38..a08291c68 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -20,6 +20,8 @@ internal struct FieldDetailView: View { let onSetDefault: () -> Void let onSetEmpty: () -> Void let onSetFunction: (String) -> Void + var isPrimaryKey: Bool = false + var isForeignKey: Bool = false var onExpand: (() -> Void)? var onPopOut: ((String) -> Void)? @@ -32,37 +34,48 @@ internal struct FieldDetailView: View { originalValue: context.originalValue ) + let isPickerField: Bool = { + switch kind { + case .boolean, .enumPicker, .setPicker: return true + default: return false + } + }() + VStack(alignment: .leading, spacing: 4) { fieldHeader - PendingStateOverlay( - isPendingNull: isPendingNull, - isPendingDefault: isPendingDefault, - isLoadingFullValue: isLoadingFullValue, - isTruncated: isTruncated, - minHeight: editorMinHeight(for: kind) - ) { + if isPickerField { resolvedEditor(for: kind) - } - .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) + } else { + PendingStateOverlay( + isPendingNull: isPendingNull, + isPendingDefault: isPendingDefault, + isLoadingFullValue: isLoadingFullValue, + isTruncated: isTruncated, + minHeight: editorMinHeight(for: kind) + ) { + resolvedEditor(for: kind) + } + .overlay(alignment: .topTrailing) { + if !context.isReadOnly && isHovered { + 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 ?? "" } + ) + .padding(.trailing, 4) + } } } } + .labelsHidden() .onHover { isHovered = $0 } } @@ -76,6 +89,16 @@ internal struct FieldDetailView: View { .frame(width: 6, height: 6) } + if isPrimaryKey { + Image(systemName: "key.fill") + .font(.system(size: 9)) + .foregroundStyle(.yellow) + } else if isForeignKey { + Image(systemName: "arrow.right.arrow.left") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + Text(context.columnName) .font(.subheadline) .lineLimit(1) @@ -123,11 +146,31 @@ internal struct FieldDetailView: View { case .blobHex: BlobHexEditorView(context: context) case .boolean: - BooleanPickerView(context: context) + BooleanPickerView( + context: context, + isPendingNull: isPendingNull, + isPendingDefault: isPendingDefault, + onSetNull: context.isReadOnly ? nil : onSetNull, + onSetDefault: context.isReadOnly ? nil : onSetDefault + ) case .enumPicker(let values): - EnumPickerView(context: context, values: values) + EnumPickerView( + context: context, + values: values, + isPendingNull: isPendingNull, + isPendingDefault: isPendingDefault, + onSetNull: context.isReadOnly ? nil : onSetNull, + onSetDefault: context.isReadOnly ? nil : onSetDefault + ) case .setPicker(let values): - SetPickerView(context: context, values: values) + SetPickerView( + context: context, + values: values, + isPendingNull: isPendingNull, + isPendingDefault: isPendingDefault, + onSetNull: context.isReadOnly ? nil : onSetNull, + onSetDefault: context.isReadOnly ? nil : onSetDefault + ) case .multiLine: MultiLineEditorView(context: context) case .singleLine: diff --git a/TablePro/Views/RightSidebar/FieldEditors/BooleanPickerView.swift b/TablePro/Views/RightSidebar/FieldEditors/BooleanPickerView.swift index dd9d6cc35..9dd56b12f 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/BooleanPickerView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/BooleanPickerView.swift @@ -7,19 +7,57 @@ import SwiftUI internal struct BooleanPickerView: View { let context: FieldEditorContext + var isPendingNull: Bool = false + var isPendingDefault: Bool = false + var onSetNull: (() -> Void)? + var onSetDefault: (() -> Void)? + + private static let nullSentinel = "\u{FFFE}NULL" + private static let defaultSentinel = "\u{FFFE}DEFAULT" var body: some View { + let isNullValue = context.originalValue == nil && !isPendingDefault + let displayValue: String = { + if isPendingNull || isNullValue { return Self.nullSentinel } + if isPendingDefault { return Self.defaultSentinel } + return normalizeBooleanValue(context.value.wrappedValue) + }() + Picker(selection: Binding( - get: { normalizeBooleanValue(context.value.wrappedValue) }, - set: { context.value.wrappedValue = $0 } + get: { displayValue }, + set: { newValue in + switch newValue { + case Self.nullSentinel: onSetNull?() + case Self.defaultSentinel: onSetDefault?() + default: context.value.wrappedValue = newValue + } + } )) { + if isPendingNull || isNullValue { + Text("NULL").tag(Self.nullSentinel) + } + if isPendingDefault { + Text("DEFAULT").tag(Self.defaultSentinel) + } Text("true").tag("1") Text("false").tag("0") + let showSetNull = onSetNull != nil && !isPendingNull && !isNullValue + let showSetDefault = onSetDefault != nil && !isPendingDefault + if showSetNull || showSetDefault { + Divider() + if showSetNull { + Text("Set NULL").tag(Self.nullSentinel) + } + if showSetDefault { + Text("Set DEFAULT").tag(Self.defaultSentinel) + } + } } label: { EmptyView() } .pickerStyle(.menu) .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) .disabled(context.isReadOnly) } diff --git a/TablePro/Views/RightSidebar/FieldEditors/EnumPickerView.swift b/TablePro/Views/RightSidebar/FieldEditors/EnumPickerView.swift index f7f1e6242..c2706d99e 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/EnumPickerView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/EnumPickerView.swift @@ -8,17 +8,58 @@ import SwiftUI internal struct EnumPickerView: View { let context: FieldEditorContext let values: [String] + var isPendingNull: Bool = false + var isPendingDefault: Bool = false + var onSetNull: (() -> Void)? + var onSetDefault: (() -> Void)? + + private static let nullSentinel = "\u{FFFE}NULL" + private static let defaultSentinel = "\u{FFFE}DEFAULT" var body: some View { - Picker(selection: context.value) { + let isNullValue = context.originalValue == nil && !isPendingDefault + let displayValue: String = { + if isPendingNull || isNullValue { return Self.nullSentinel } + if isPendingDefault { return Self.defaultSentinel } + return context.value.wrappedValue + }() + + Picker(selection: Binding( + get: { displayValue }, + set: { newValue in + switch newValue { + case Self.nullSentinel: onSetNull?() + case Self.defaultSentinel: onSetDefault?() + default: context.value.wrappedValue = newValue + } + } + )) { + if isPendingNull || isNullValue { + Text("NULL").tag(Self.nullSentinel) + } + if isPendingDefault { + Text("DEFAULT").tag(Self.defaultSentinel) + } ForEach(values, id: \.self) { val in Text(val).tag(val) } + let showSetNull = onSetNull != nil && !isPendingNull && !isNullValue + let showSetDefault = onSetDefault != nil && !isPendingDefault + if showSetNull || showSetDefault { + Divider() + if showSetNull { + Text("Set NULL").tag(Self.nullSentinel) + } + if showSetDefault { + Text("Set DEFAULT").tag(Self.defaultSentinel) + } + } } label: { EmptyView() } .pickerStyle(.menu) .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) .disabled(context.isReadOnly) } } diff --git a/TablePro/Views/RightSidebar/FieldEditors/SetPickerView.swift b/TablePro/Views/RightSidebar/FieldEditors/SetPickerView.swift index 6ed5238aa..51e8c7c9b 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/SetPickerView.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/SetPickerView.swift @@ -8,16 +8,36 @@ import SwiftUI internal struct SetPickerView: View { let context: FieldEditorContext let values: [String] + var isPendingNull: Bool = false + var isPendingDefault: Bool = false + var onSetNull: (() -> Void)? + var onSetDefault: (() -> Void)? @State private var isSetPopoverPresented = false var body: some View { - let displayLabel = context.value.wrappedValue.isEmpty - ? String(localized: "No selection") - : context.value.wrappedValue + let isNullValue = context.originalValue == nil && !isPendingDefault + let displayLabel: String = { + if isPendingNull || isNullValue { return "NULL" } + if isPendingDefault { return "DEFAULT" } + return context.value.wrappedValue.isEmpty + ? String(localized: "No selection") + : context.value.wrappedValue + }() - Button { - isSetPopoverPresented = true + Menu { + Button { isSetPopoverPresented = true } label: { + Text("Edit Values...") + } + if onSetNull != nil || onSetDefault != nil { + Divider() + if let onSetNull { + Button("Set NULL", action: onSetNull) + } + if let onSetDefault { + Button("Set DEFAULT", action: onSetDefault) + } + } } label: { HStack(spacing: 4) { Text(displayLabel) @@ -30,7 +50,7 @@ internal struct SetPickerView: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .menuStyle(.borderlessButton) .padding(.horizontal, 4) .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) .background(.quinary, in: RoundedRectangle(cornerRadius: 5)) diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index c5aa2bd8c..9b76d73a9 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -7,14 +7,12 @@ import SwiftUI -/// Right sidebar that shows table metadata or selected row details struct RightSidebarView: View { let tableName: String? let tableMetadata: TableMetadata? let selectedRowData: [(column: String, value: String?, type: String)]? let isEditable: Bool let isRowDeleted: Bool - let onSave: () -> Void var editState: MultiRowEditState let databaseType: DatabaseType @@ -221,13 +219,11 @@ struct RightSidebarView: View { return VStack(spacing: 0) { NativeSearchField( text: $searchText, - placeholder: String(localized: "Search for field..."), + placeholder: String(localized: "Search fields..."), controlSize: .small ) .padding(.horizontal, 6) - Divider() - List { Section { if filtered.isEmpty && !searchText.isEmpty { @@ -238,32 +234,20 @@ struct RightSidebarView: View { } else { ForEach(filtered, id: \.id) { field in fieldDetailRow(field, at: field.columnIndex, isEditable: contentMode == .editRow) + .listRowSeparator(.hidden) } } } header: { HStack { - Text("FIELDS") + Text("Fields") Spacer() Text("\(filtered.count)") .foregroundStyle(.secondary) } - .padding(.trailing, 15) } } - .listStyle(.sidebar) + .listStyle(.inset) .scrollContentBackground(.hidden) - - if contentMode == .editRow && editState.hasEdits { - Divider() - Button(action: onSave) { - Text("Save Changes") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } } } @@ -298,6 +282,8 @@ struct RightSidebarView: View { onSetDefault: { editState.setFieldToDefault(at: index) }, onSetEmpty: { editState.setFieldToEmpty(at: index) }, onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, + isPrimaryKey: field.isPrimaryKey, + isForeignKey: field.isForeignKey, onExpand: isJsonField ? { expandedJsonFieldId = field.id } : nil, onPopOut: isJsonField ? { currentText in popOutJsonField(text: currentText, field: field, isEditable: isEditable) @@ -328,7 +314,6 @@ struct RightSidebarView_Previews: PreviewProvider { selectedRowData: nil, isEditable: false, isRowDeleted: false, - onSave: {}, editState: MultiRowEditState(), databaseType: .mysql ) diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index 558e2778e..a8bb62d97 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -22,7 +22,6 @@ struct UnifiedRightPanelView: View { selectedRowData: ctx.selectedRowData, isEditable: ctx.isEditable, isRowDeleted: ctx.isRowDeleted, - onSave: { state.onSave?() }, editState: state.editState, databaseType: connection.type ) @@ -42,6 +41,8 @@ struct UnifiedRightPanelView: View { .padding(.horizontal, 12) .padding(.vertical, 8) + Divider() + switch state.activeTab { case .details: detailsView