From 629fdfccd8f3df093ebe86d32c356140f758f7ba Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 24 Mar 2026 21:35:28 +0700 Subject: [PATCH 1/4] fix: lazy-load full values for truncated LONGTEXT/MEDIUMTEXT/CLOB columns in detail pane --- CHANGELOG.md | 5 + TablePro.xcodeproj/project.pbxproj | 60 +++---- TablePro/Models/UI/MultiRowEditState.swift | 41 ++++- TablePro/Resources/Localizable.xcstrings | 3 + ...inContentCoordinator+LazyLoadColumns.swift | 61 +++++++ TablePro/Views/Main/MainContentView.swift | 54 +++++- .../RightSidebar/EditableFieldView.swift | 28 ++- .../Views/RightSidebar/RightSidebarView.swift | 2 + .../MultiRowEditStateTruncationTests.swift | 169 ++++++++++++++++++ 9 files changed, 385 insertions(+), 38 deletions(-) create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift create mode 100644 TableProTests/Models/MultiRowEditStateTruncationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 559b71ee..e2eed45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse, MSSQL, Redis, XLSX Export, MQL Export, and SQL Import now ship as built-in plugins - Large document safety caps for syntax highlighting (skip >5MB, throttle >50KB) +- Lazy-load full values for LONGTEXT/MEDIUMTEXT/CLOB columns in the detail pane sidebar + +### Fixed + +- Detail pane showing truncated values for LONGTEXT/MEDIUMTEXT/CLOB columns, preventing correct editing ## [0.23.2] - 2026-03-24 diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 24bb2624..4863a4ef 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -12,11 +12,14 @@ 5A862000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A862000100000000 /* SQLiteDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A863000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A863000100000000 /* ClickHouseDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A864000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A864000D00000000 /* MSSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A864000100000000 /* MSSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A865000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A865000100000000 /* MySQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A866000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A867000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A868000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A869000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -26,15 +29,12 @@ 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86B000100000000 /* JSONExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86C000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86C000100000000 /* SQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A863000100000000 /* ClickHouseDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A864000D00000000 /* MSSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A864000100000000 /* MSSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86D000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; @@ -90,6 +90,13 @@ remoteGlobalIDString = 5A863000000000000; remoteInfo = ClickHouseDriver; }; + 5A864000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A864000000000000; + remoteInfo = MSSQLDriver; + }; 5A865000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -97,6 +104,13 @@ remoteGlobalIDString = 5A865000000000000; remoteInfo = MySQLDriver; }; + 5A867000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A867000000000000; + remoteInfo = RedisDriver; + }; 5A868000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -132,20 +146,6 @@ remoteGlobalIDString = 5A86C000000000000; remoteInfo = SQLExport; }; - 5A864000B00000000 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5A864000000000000; - remoteInfo = MSSQLDriver; - }; - 5A867000B00000000 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5A867000000000000; - remoteInfo = RedisDriver; - }; 5A86D000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -1793,11 +1793,21 @@ target = 5A863000000000000 /* ClickHouseDriver */; targetProxy = 5A863000B00000000 /* PBXContainerItemProxy */; }; + 5A864000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A864000000000000 /* MSSQLDriver */; + targetProxy = 5A864000B00000000 /* PBXContainerItemProxy */; + }; 5A865000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A865000000000000 /* MySQLDriver */; targetProxy = 5A865000B00000000 /* PBXContainerItemProxy */; }; + 5A867000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A867000000000000 /* RedisDriver */; + targetProxy = 5A867000B00000000 /* PBXContainerItemProxy */; + }; 5A868000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A868000000000000 /* PostgreSQLDriver */; @@ -1823,16 +1833,6 @@ target = 5A86C000000000000 /* SQLExport */; targetProxy = 5A86C000B00000000 /* PBXContainerItemProxy */; }; - 5A864000C00000000 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5A864000000000000 /* MSSQLDriver */; - targetProxy = 5A864000B00000000 /* PBXContainerItemProxy */; - }; - 5A867000C00000000 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5A867000000000000 /* RedisDriver */; - targetProxy = 5A867000B00000000 /* PBXContainerItemProxy */; - }; 5A86D000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A86D000000000000 /* XLSXExport */; diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index a85c434d..65ecd6f3 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -17,7 +17,7 @@ struct FieldEditState { let isLongText: Bool /// Original values from all selected rows (nil if multiple different values) - let originalValue: String? + var originalValue: String? /// Flag indicating if selected rows have different values for this field let hasMultipleValues: Bool @@ -31,6 +31,12 @@ struct FieldEditState { /// 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 { pendingValue != nil || isPendingNull || isPendingDefault } @@ -67,8 +73,9 @@ final class MultiRowEditState { selectedRowIndices: Set, allRows: [[String?]], columns: [String], - columnTypes: [ColumnType], // Changed from [String] to [ColumnType] - externallyModifiedColumns: Set = [] + columnTypes: [ColumnType], + externallyModifiedColumns: Set = [], + excludedColumnNames: Set = [] ) { // Check if the underlying data has changed (not just edits) let columnsChanged = self.columns != columns @@ -124,6 +131,8 @@ final class MultiRowEditState { pendingValue = originalValue ?? "" } + let isExcluded = excludedColumnNames.contains(columnName) + newFields.append(FieldEditState( columnIndex: colIndex, columnName: columnName, @@ -133,7 +142,9 @@ final class MultiRowEditState { hasMultipleValues: hasMultipleValues, pendingValue: pendingValue, isPendingNull: isPendingNull, - isPendingDefault: isPendingDefault + isPendingDefault: isPendingDefault, + isTruncated: isExcluded, + isLoadingFullValue: isExcluded )) } @@ -200,6 +211,26 @@ final class MultiRowEditState { } } + /// Apply lazy-loaded full values for previously truncated columns + func applyFullValues(_ fullValues: [String: String?]) { + for i in 0.. [(columnIndex: Int, columnName: String, newValue: String?)] { fields.compactMap { field in - guard field.hasEdit else { return nil } + guard field.hasEdit, !field.isTruncated else { return nil } return (field.columnIndex, field.columnName, field.effectiveValue) } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 43d9993d..3d02370c 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -30625,6 +30625,9 @@ } } } + }, + "truncated" : { + }, "Truncated — read only" : { "localizations" : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift new file mode 100644 index 00000000..398e39d4 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift @@ -0,0 +1,61 @@ +// +// MainContentCoordinator+LazyLoadColumns.swift +// TablePro +// +// Lazy-loads full values for columns truncated by ColumnExclusionPolicy. +// + +import Foundation +import os +import TableProPluginKit + +private let lazyLoadLogger = Logger(subsystem: "com.TablePro", category: "LazyLoadColumns") + +extension MainContentCoordinator { + func fetchFullValuesForExcludedColumns( + tableName: String, + primaryKeyColumn: String, + primaryKeyValue: String, + excludedColumnNames: [String] + ) async throws -> [String: String?] { + guard !excludedColumnNames.isEmpty else { return [:] } + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.notConnected + } + + let quotedCols = excludedColumnNames.map { queryBuilder.quoteIdentifier($0) } + let quotedTable = queryBuilder.quoteIdentifier(tableName) + let quotedPK = queryBuilder.quoteIdentifier(primaryKeyColumn) + + // Resolve parameter style from plugin metadata (? for MySQL/SQLite, $1 for PostgreSQL) + let paramStyle = PluginMetadataRegistry.shared + .snapshot(forTypeId: connection.type.pluginTypeId)?.parameterStyle ?? .questionMark + let placeholder: String + switch paramStyle { + case .dollar: + placeholder = "$1" + case .questionMark: + placeholder = "?" + } + + let query = "SELECT \(quotedCols.joined(separator: ", ")) FROM \(quotedTable) WHERE \(quotedPK) = \(placeholder) LIMIT 1" + + lazyLoadLogger.debug("Lazy-loading excluded columns: \(excludedColumnNames.joined(separator: ", "), privacy: .public)") + + let result = try await driver.executeParameterized( + query: query, + parameters: [primaryKeyValue] + ) + + guard let row = result.rows.first else { + lazyLoadLogger.warning("No row returned for lazy-load query") + return [:] + } + + var dict: [String: String?] = [:] + for (index, colName) in excludedColumnNames.enumerated() where index < row.count { + dict[colName] = row[index] + } + return dict + } +} diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 90d66f6e..b99d9321 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -44,6 +44,7 @@ struct MainContentView: View { @State private var commandActions: MainContentCommandActions? @State private var queryResultsSummaryCache: (tabId: UUID, version: Int, summary: String?)? @State private var inspectorUpdateTask: Task? + @State private var lazyLoadTask: Task? @State private var pendingTabSwitch: Task? @State private var evictionTask: Task? /// Stable identifier for this window in WindowLifecycleMonitor @@ -870,12 +871,20 @@ struct MainContentView: View { modifiedColumns.formUnion(changeManager.getModifiedColumnsForRow(rowIndex)) } + let excludedNames: Set + if let tableName = tab.tableName { + excludedNames = Set(coordinator.columnExclusions(for: tableName).map(\.columnName)) + } else { + excludedNames = [] + } + rightPanelState.editState.configure( selectedRowIndices: selectedRowIndices, allRows: allRows, columns: tab.resultColumns, columnTypes: columnTypes, - externallyModifiedColumns: modifiedColumns + externallyModifiedColumns: modifiedColumns, + excludedColumnNames: excludedNames ) guard isSidebarEditable else { @@ -892,7 +901,14 @@ struct MainContentView: View { for rowIndex in capturedEditState.selectedRowIndices { guard rowIndex < tab.resultRows.count else { continue } let originalRow = tab.resultRows[rowIndex] - let oldValue = columnIndex < originalRow.count ? originalRow[columnIndex] : nil + + // Use full (lazy-loaded) original value if available, not truncated row data + let oldValue: String? + if columnIndex < capturedEditState.fields.count, !capturedEditState.fields[columnIndex].isTruncated { + oldValue = capturedEditState.fields[columnIndex].originalValue + } else { + oldValue = columnIndex < originalRow.count ? originalRow[columnIndex] : nil + } capturedCoordinator.changeManager.recordCellChange( rowIndex: rowIndex, @@ -904,6 +920,40 @@ struct MainContentView: View { ) } } + + // Lazy-load full values for excluded columns when a single row is selected + if !excludedNames.isEmpty, + selectedRowIndices.count == 1, + let tableName = tab.tableName, + let pkColumn = tab.primaryKeyColumn, + let rowIndex = selectedRowIndices.first, + rowIndex < tab.resultRows.count { + let row = tab.resultRows[rowIndex] + if let pkColIndex = tab.resultColumns.firstIndex(of: pkColumn), + pkColIndex < row.count, + let pkValue = row[pkColIndex] { + let excludedList = Array(excludedNames) + + lazyLoadTask?.cancel() + lazyLoadTask = Task { @MainActor in + do { + let fullValues = try await capturedCoordinator.fetchFullValuesForExcludedColumns( + tableName: tableName, + primaryKeyColumn: pkColumn, + primaryKeyValue: pkValue, + excludedColumnNames: excludedList + ) + guard !Task.isCancelled else { return } + capturedEditState.applyFullValues(fullValues) + } catch { + guard !Task.isCancelled else { return } + for i in 0.. Void let onSetDefault: () -> Void @@ -63,6 +65,16 @@ struct EditableFieldView: View { .padding(.vertical, 1) .background(.quaternary) .clipShape(Capsule()) + + 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()) + } } // Line 2: full-width editor with inline menu overlay @@ -80,7 +92,21 @@ struct EditableFieldView: View { @ViewBuilder private var typeAwareEditor: some View { - if isPendingNull || isPendingDefault { + 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(placeholderText, text: .constant("")) + .textFieldStyle(.roundedBorder) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .disabled(true) + } else if isPendingNull || isPendingDefault { TextField(isPendingNull ? "NULL" : "DEFAULT", text: .constant("")) .textFieldStyle(.roundedBorder) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index f1754036..db02ed53 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -226,6 +226,8 @@ struct RightSidebarView: View { isPendingNull: field.isPendingNull, isPendingDefault: field.isPendingDefault, isModified: field.hasEdit, + isTruncated: field.isTruncated, + isLoadingFullValue: field.isLoadingFullValue, onSetNull: { editState.setFieldToNull(at: index) }, onSetDefault: { editState.setFieldToDefault(at: index) }, onSetEmpty: { editState.setFieldToEmpty(at: index) }, diff --git a/TableProTests/Models/MultiRowEditStateTruncationTests.swift b/TableProTests/Models/MultiRowEditStateTruncationTests.swift new file mode 100644 index 00000000..62e0852d --- /dev/null +++ b/TableProTests/Models/MultiRowEditStateTruncationTests.swift @@ -0,0 +1,169 @@ +// +// MultiRowEditStateTruncationTests.swift +// TableProTests +// +// Tests for truncation support in MultiRowEditState. +// + +@testable import TablePro +import Testing + +@MainActor @Suite("MultiRowEditState Truncation") +struct MultiRowEditStateTruncationTests { + // MARK: - Helper + + private func makeSUT( + columns: [String] = ["id", "name", "content"], + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [["1", "Alice", "short..."]], + selectedIndices: Set = [0], + excludedColumnNames: Set = [] + ) -> MultiRowEditState { + let sut = MultiRowEditState() + let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } + sut.configure( + selectedRowIndices: selectedIndices, + allRows: rows, + columns: columns, + columnTypes: types, + excludedColumnNames: excludedColumnNames + ) + return sut + } + + // MARK: - FieldEditState defaults + + @Test("isTruncated defaults to false") + func isTruncatedDefaultsToFalse() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: nil, isPendingNull: false, isPendingDefault: false, + isTruncated: false, isLoadingFullValue: false + ) + #expect(field.isTruncated == false) + } + + @Test("isLoadingFullValue defaults to false") + func isLoadingFullValueDefaultsToFalse() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: nil, isPendingNull: false, isPendingDefault: false, + isTruncated: false, isLoadingFullValue: false + ) + #expect(field.isLoadingFullValue == false) + } + + // MARK: - configure() with excludedColumnNames + + @Test("configure with excludedColumnNames marks matching fields as truncated") + func configureWithExcludedColumnNamesMarksTruncated() { + let sut = makeSUT(excludedColumnNames: ["content"]) + + #expect(sut.fields[0].isTruncated == false) // id + #expect(sut.fields[1].isTruncated == false) // name + #expect(sut.fields[2].isTruncated == true) // content + } + + @Test("configure without excludedColumnNames leaves all fields not truncated") + func configureWithoutExcludedColumnNamesLeavesNotTruncated() { + let sut = makeSUT() + + for field in sut.fields { + #expect(field.isTruncated == false) + } + } + + @Test("configure sets isLoadingFullValue to true for excluded columns") + func configureSetsIsLoadingFullValueForExcludedColumns() { + let sut = makeSUT(excludedColumnNames: ["content"]) + + #expect(sut.fields[0].isLoadingFullValue == false) // id + #expect(sut.fields[1].isLoadingFullValue == false) // name + #expect(sut.fields[2].isLoadingFullValue == true) // content (excluded) + } + + // MARK: - applyFullValues() + + @Test("applyFullValues patches originalValue and clears isTruncated") + func applyFullValuesPatchesOriginalValueAndClearsTruncated() { + let sut = makeSUT(excludedColumnNames: ["content"]) + + #expect(sut.fields[2].isTruncated == true) + + sut.applyFullValues(["content": "full long text that was previously truncated"]) + + #expect(sut.fields[2].originalValue == "full long text that was previously truncated") + #expect(sut.fields[2].isTruncated == false) + #expect(sut.fields[2].isLoadingFullValue == false) + } + + @Test("applyFullValues preserves pending edits") + func applyFullValuesPreservesPendingEdits() { + let sut = makeSUT(excludedColumnNames: ["content"]) + + sut.fields[2].pendingValue = "user edit" + + sut.applyFullValues(["content": "full text"]) + + #expect(sut.fields[2].pendingValue == "user edit") + #expect(sut.fields[2].originalValue == "full text") + #expect(sut.fields[2].isTruncated == false) + } + + @Test("applyFullValues ignores columns not in dictionary") + func applyFullValuesIgnoresUnknownColumns() { + let sut = makeSUT(excludedColumnNames: ["content"]) + let originalContentValue = sut.fields[2].originalValue + + sut.applyFullValues(["nonexistent": "value"]) + + #expect(sut.fields[2].originalValue == originalContentValue) + #expect(sut.fields[2].isTruncated == true) // still truncated + } + + @Test("applyFullValues handles nil values") + func applyFullValuesHandlesNilValues() { + let sut = makeSUT(excludedColumnNames: ["content"]) + + sut.applyFullValues(["content": nil]) + + #expect(sut.fields[2].originalValue == nil) + #expect(sut.fields[2].isTruncated == false) + } + + // MARK: - getEditedFields() safety net + + @Test("getEditedFields excludes fields still marked as truncated") + func getEditedFieldsExcludesTruncatedFields() { + let sut = makeSUT(excludedColumnNames: ["content"]) + + // Set a pending value on the truncated field without clearing isTruncated + sut.fields[2].pendingValue = "some edit" + + let editedFields = sut.getEditedFields() + + // Should NOT include the truncated field even though it has a pending edit + #expect(editedFields.isEmpty) + } + + // MARK: - updateField works after applyFullValues + + @Test("updateField works normally after applyFullValues patches value") + func updateFieldWorksAfterApplyFullValues() { + let sut = makeSUT(excludedColumnNames: ["content"]) + + sut.applyFullValues(["content": "full original text"]) + + sut.updateField(at: 2, value: "new edited value") + + #expect(sut.fields[2].pendingValue == "new edited value") + #expect(sut.fields[2].isTruncated == false) + + let editedFields = sut.getEditedFields() + #expect(editedFields.count == 1) + #expect(editedFields[0].columnName == "content") + #expect(editedFields[0].newValue == "new edited value") + } +} From e7f43c2431b9bcf98a9872a675ce4a06499eef21 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 24 Mar 2026 21:38:38 +0700 Subject: [PATCH 2/4] fix: revert unrelated pbxproj changes --- TablePro.xcodeproj/project.pbxproj | 60 +++++++++++++++--------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 4863a4ef..24bb2624 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -12,14 +12,11 @@ 5A862000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A862000100000000 /* SQLiteDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A863000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A863000100000000 /* ClickHouseDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A864000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A864000D00000000 /* MSSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A864000100000000 /* MSSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A865000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A865000100000000 /* MySQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A866000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A867000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A868000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A869000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -29,12 +26,15 @@ 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86B000100000000 /* JSONExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86C000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86C000100000000 /* SQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86D000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A863000100000000 /* ClickHouseDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A864000D00000000 /* MSSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A864000100000000 /* MSSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86D000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; @@ -90,13 +90,6 @@ remoteGlobalIDString = 5A863000000000000; remoteInfo = ClickHouseDriver; }; - 5A864000B00000000 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5A864000000000000; - remoteInfo = MSSQLDriver; - }; 5A865000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -104,13 +97,6 @@ remoteGlobalIDString = 5A865000000000000; remoteInfo = MySQLDriver; }; - 5A867000B00000000 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5A867000000000000; - remoteInfo = RedisDriver; - }; 5A868000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -146,6 +132,20 @@ remoteGlobalIDString = 5A86C000000000000; remoteInfo = SQLExport; }; + 5A864000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A864000000000000; + remoteInfo = MSSQLDriver; + }; + 5A867000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A867000000000000; + remoteInfo = RedisDriver; + }; 5A86D000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -1793,21 +1793,11 @@ target = 5A863000000000000 /* ClickHouseDriver */; targetProxy = 5A863000B00000000 /* PBXContainerItemProxy */; }; - 5A864000C00000000 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5A864000000000000 /* MSSQLDriver */; - targetProxy = 5A864000B00000000 /* PBXContainerItemProxy */; - }; 5A865000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A865000000000000 /* MySQLDriver */; targetProxy = 5A865000B00000000 /* PBXContainerItemProxy */; }; - 5A867000C00000000 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5A867000000000000 /* RedisDriver */; - targetProxy = 5A867000B00000000 /* PBXContainerItemProxy */; - }; 5A868000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A868000000000000 /* PostgreSQLDriver */; @@ -1833,6 +1823,16 @@ target = 5A86C000000000000 /* SQLExport */; targetProxy = 5A86C000B00000000 /* PBXContainerItemProxy */; }; + 5A864000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A864000000000000 /* MSSQLDriver */; + targetProxy = 5A864000B00000000 /* PBXContainerItemProxy */; + }; + 5A867000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A867000000000000 /* RedisDriver */; + targetProxy = 5A867000B00000000 /* PBXContainerItemProxy */; + }; 5A86D000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A86D000000000000 /* XLSXExport */; From 4e14162e350c353b292be79c47902e43d93b3408 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 24 Mar 2026 21:48:43 +0700 Subject: [PATCH 3/4] fix: address code review issues for lazy-load truncated columns --- TablePro/Models/UI/MultiRowEditState.swift | 20 ++++++++++++++----- ...inContentCoordinator+LazyLoadColumns.swift | 8 +++++--- TablePro/Views/Main/MainContentView.swift | 5 ++++- .../RightSidebar/EditableFieldView.swift | 7 ++++--- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index 65ecd6f3..3b0b2228 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -117,13 +117,25 @@ final class MultiRowEditState { var isPendingNull = false var isPendingDefault = false + let isExcluded = excludedColumnNames.contains(columnName) + var preservedOriginalValue: String? = originalValue + var preservedIsTruncated = isExcluded + var preservedIsLoadingFullValue = isExcluded + if !columnsChanged, !selectionChanged, colIndex < fields.count { let oldField = fields[colIndex] + // Preserve pending edits when original data matches if oldField.originalValue == originalValue && oldField.hasMultipleValues == hasMultipleValues { pendingValue = oldField.pendingValue isPendingNull = oldField.isPendingNull isPendingDefault = oldField.isPendingDefault } + // Preserve resolved truncation state — don't reset already-fetched full values + if isExcluded && !oldField.isTruncated && oldField.columnName == columnName { + preservedOriginalValue = oldField.originalValue + preservedIsTruncated = false + preservedIsLoadingFullValue = false + } } // Mark externally modified columns (e.g., edited in data grid) @@ -131,20 +143,18 @@ final class MultiRowEditState { pendingValue = originalValue ?? "" } - let isExcluded = excludedColumnNames.contains(columnName) - newFields.append(FieldEditState( columnIndex: colIndex, columnName: columnName, columnTypeEnum: columnTypeEnum, isLongText: isLongText, - originalValue: originalValue, + originalValue: preservedOriginalValue, hasMultipleValues: hasMultipleValues, pendingValue: pendingValue, isPendingNull: isPendingNull, isPendingDefault: isPendingDefault, - isTruncated: isExcluded, - isLoadingFullValue: isExcluded + isTruncated: preservedIsTruncated, + isLoadingFullValue: preservedIsLoadingFullValue )) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift index 398e39d4..c536d00d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift @@ -9,7 +9,9 @@ import Foundation import os import TableProPluginKit -private let lazyLoadLogger = Logger(subsystem: "com.TablePro", category: "LazyLoadColumns") +private enum LazyLoadLog { + static let logger = Logger(subsystem: "com.TablePro", category: "LazyLoadColumns") +} extension MainContentCoordinator { func fetchFullValuesForExcludedColumns( @@ -40,7 +42,7 @@ extension MainContentCoordinator { let query = "SELECT \(quotedCols.joined(separator: ", ")) FROM \(quotedTable) WHERE \(quotedPK) = \(placeholder) LIMIT 1" - lazyLoadLogger.debug("Lazy-loading excluded columns: \(excludedColumnNames.joined(separator: ", "), privacy: .public)") + LazyLoadLog.logger.debug("Lazy-loading excluded columns: \(excludedColumnNames.joined(separator: ", "), privacy: .public)") let result = try await driver.executeParameterized( query: query, @@ -48,7 +50,7 @@ extension MainContentCoordinator { ) guard let row = result.rows.first else { - lazyLoadLogger.warning("No row returned for lazy-load query") + LazyLoadLog.logger.warning("No row returned for lazy-load query") return [:] } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index b99d9321..30bb7059 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -936,6 +936,7 @@ struct MainContentView: View { lazyLoadTask?.cancel() lazyLoadTask = Task { @MainActor in + let expectedRowIndex = rowIndex do { let fullValues = try await capturedCoordinator.fetchFullValuesForExcludedColumns( tableName: tableName, @@ -943,7 +944,9 @@ struct MainContentView: View { primaryKeyValue: pkValue, excludedColumnNames: excludedList ) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, + capturedEditState.selectedRowIndices.count == 1, + capturedEditState.selectedRowIndices.first == expectedRowIndex else { return } capturedEditState.applyFullValues(fullValues) } catch { guard !Task.isCancelled else { return } diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 6be5e34e..e66d5a0e 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -102,10 +102,11 @@ struct EditableFieldView: View { .controlSize(.small) } } else if isTruncated { - TextField(placeholderText, text: .constant("")) - .textFieldStyle(.roundedBorder) + Text("Failed to load full value") .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .disabled(true) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) } else if isPendingNull || isPendingDefault { TextField(isPendingNull ? "NULL" : "DEFAULT", text: .constant("")) .textFieldStyle(.roundedBorder) From b217d63c61f73ccfdf8cca5e7a7f49b6a5ef75cb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 24 Mar 2026 22:02:24 +0700 Subject: [PATCH 4/4] fix: address CodeRabbit review comments --- .../Extensions/MainContentCoordinator+LazyLoadColumns.swift | 4 ++-- TablePro/Views/Main/MainContentView.swift | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift index c536d00d..8e1a52b1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift @@ -13,7 +13,7 @@ private enum LazyLoadLog { static let logger = Logger(subsystem: "com.TablePro", category: "LazyLoadColumns") } -extension MainContentCoordinator { +internal extension MainContentCoordinator { func fetchFullValuesForExcludedColumns( tableName: String, primaryKeyColumn: String, @@ -40,7 +40,7 @@ extension MainContentCoordinator { placeholder = "?" } - let query = "SELECT \(quotedCols.joined(separator: ", ")) FROM \(quotedTable) WHERE \(quotedPK) = \(placeholder) LIMIT 1" + let query = "SELECT \(quotedCols.joined(separator: ", ")) FROM \(quotedTable) WHERE \(quotedPK) = \(placeholder)" LazyLoadLog.logger.debug("Lazy-loading excluded columns: \(excludedColumnNames.joined(separator: ", "), privacy: .public)") diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 30bb7059..f5ad4fb8 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -949,7 +949,9 @@ struct MainContentView: View { capturedEditState.selectedRowIndices.first == expectedRowIndex else { return } capturedEditState.applyFullValues(fullValues) } catch { - guard !Task.isCancelled else { return } + guard !Task.isCancelled, + capturedEditState.selectedRowIndices.count == 1, + capturedEditState.selectedRowIndices.first == expectedRowIndex else { return } for i in 0..