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/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index a85c434d..3b0b2228 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 @@ -110,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) @@ -129,11 +148,13 @@ final class MultiRowEditState { columnName: columnName, columnTypeEnum: columnTypeEnum, isLongText: isLongText, - originalValue: originalValue, + originalValue: preservedOriginalValue, hasMultipleValues: hasMultipleValues, pendingValue: pendingValue, isPendingNull: isPendingNull, - isPendingDefault: isPendingDefault + isPendingDefault: isPendingDefault, + isTruncated: preservedIsTruncated, + isLoadingFullValue: preservedIsLoadingFullValue )) } @@ -200,6 +221,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..8e1a52b1 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift @@ -0,0 +1,63 @@ +// +// MainContentCoordinator+LazyLoadColumns.swift +// TablePro +// +// Lazy-loads full values for columns truncated by ColumnExclusionPolicy. +// + +import Foundation +import os +import TableProPluginKit + +private enum LazyLoadLog { + static let logger = Logger(subsystem: "com.TablePro", category: "LazyLoadColumns") +} + +internal 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)" + + LazyLoadLog.logger.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 { + LazyLoadLog.logger.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..f5ad4fb8 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,45 @@ 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 + let expectedRowIndex = rowIndex + do { + let fullValues = try await capturedCoordinator.fetchFullValuesForExcludedColumns( + tableName: tableName, + primaryKeyColumn: pkColumn, + primaryKeyValue: pkValue, + excludedColumnNames: excludedList + ) + guard !Task.isCancelled, + capturedEditState.selectedRowIndices.count == 1, + capturedEditState.selectedRowIndices.first == expectedRowIndex else { return } + capturedEditState.applyFullValues(fullValues) + } catch { + guard !Task.isCancelled, + capturedEditState.selectedRowIndices.count == 1, + capturedEditState.selectedRowIndices.first == expectedRowIndex 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,22 @@ 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 { + Text("Failed to load full value") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } else if isPendingNull || isPendingDefault { TextField(isPendingNull ? "NULL" : "DEFAULT", text: .constant("")) .textFieldStyle(.roundedBorder) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) 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") + } +}