Skip to content

Commit 96bcd12

Browse files
authored
fix: lazy-load full values for truncated columns in detail pane (#446)
* fix: lazy-load full values for truncated LONGTEXT/MEDIUMTEXT/CLOB columns in detail pane * fix: revert unrelated pbxproj changes * fix: address code review issues for lazy-load truncated columns * fix: address CodeRabbit review comments
1 parent 64d13cd commit 96bcd12

8 files changed

Lines changed: 374 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- ClickHouse, MSSQL, Redis, XLSX Export, MQL Export, and SQL Import now ship as built-in plugins
1313
- Large document safety caps for syntax highlighting (skip >5MB, throttle >50KB)
14+
- Lazy-load full values for LONGTEXT/MEDIUMTEXT/CLOB columns in the detail pane sidebar
15+
16+
### Fixed
17+
18+
- Detail pane showing truncated values for LONGTEXT/MEDIUMTEXT/CLOB columns, preventing correct editing
1419

1520
## [0.23.2] - 2026-03-24
1621

TablePro/Models/UI/MultiRowEditState.swift

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ struct FieldEditState {
1717
let isLongText: Bool
1818

1919
/// Original values from all selected rows (nil if multiple different values)
20-
let originalValue: String?
20+
var originalValue: String?
2121

2222
/// Flag indicating if selected rows have different values for this field
2323
let hasMultipleValues: Bool
@@ -31,6 +31,12 @@ struct FieldEditState {
3131
/// Whether user has explicitly set this field to DEFAULT
3232
var isPendingDefault: Bool
3333

34+
/// Whether this field's value was truncated by column exclusion policy
35+
var isTruncated: Bool = false
36+
37+
/// Whether full value is currently being lazy-loaded
38+
var isLoadingFullValue: Bool = false
39+
3440
var hasEdit: Bool {
3541
pendingValue != nil || isPendingNull || isPendingDefault
3642
}
@@ -67,8 +73,9 @@ final class MultiRowEditState {
6773
selectedRowIndices: Set<Int>,
6874
allRows: [[String?]],
6975
columns: [String],
70-
columnTypes: [ColumnType], // Changed from [String] to [ColumnType]
71-
externallyModifiedColumns: Set<Int> = []
76+
columnTypes: [ColumnType],
77+
externallyModifiedColumns: Set<Int> = [],
78+
excludedColumnNames: Set<String> = []
7279
) {
7380
// Check if the underlying data has changed (not just edits)
7481
let columnsChanged = self.columns != columns
@@ -110,13 +117,25 @@ final class MultiRowEditState {
110117
var isPendingNull = false
111118
var isPendingDefault = false
112119

120+
let isExcluded = excludedColumnNames.contains(columnName)
121+
var preservedOriginalValue: String? = originalValue
122+
var preservedIsTruncated = isExcluded
123+
var preservedIsLoadingFullValue = isExcluded
124+
113125
if !columnsChanged, !selectionChanged, colIndex < fields.count {
114126
let oldField = fields[colIndex]
127+
// Preserve pending edits when original data matches
115128
if oldField.originalValue == originalValue && oldField.hasMultipleValues == hasMultipleValues {
116129
pendingValue = oldField.pendingValue
117130
isPendingNull = oldField.isPendingNull
118131
isPendingDefault = oldField.isPendingDefault
119132
}
133+
// Preserve resolved truncation state — don't reset already-fetched full values
134+
if isExcluded && !oldField.isTruncated && oldField.columnName == columnName {
135+
preservedOriginalValue = oldField.originalValue
136+
preservedIsTruncated = false
137+
preservedIsLoadingFullValue = false
138+
}
120139
}
121140

122141
// Mark externally modified columns (e.g., edited in data grid)
@@ -129,11 +148,13 @@ final class MultiRowEditState {
129148
columnName: columnName,
130149
columnTypeEnum: columnTypeEnum,
131150
isLongText: isLongText,
132-
originalValue: originalValue,
151+
originalValue: preservedOriginalValue,
133152
hasMultipleValues: hasMultipleValues,
134153
pendingValue: pendingValue,
135154
isPendingNull: isPendingNull,
136-
isPendingDefault: isPendingDefault
155+
isPendingDefault: isPendingDefault,
156+
isTruncated: preservedIsTruncated,
157+
isLoadingFullValue: preservedIsLoadingFullValue
137158
))
138159
}
139160

@@ -200,6 +221,26 @@ final class MultiRowEditState {
200221
}
201222
}
202223

224+
/// Apply lazy-loaded full values for previously truncated columns
225+
func applyFullValues(_ fullValues: [String: String?]) {
226+
for i in 0..<fields.count {
227+
guard let fullValue = fullValues[fields[i].columnName] else { continue }
228+
fields[i] = FieldEditState(
229+
columnIndex: fields[i].columnIndex,
230+
columnName: fields[i].columnName,
231+
columnTypeEnum: fields[i].columnTypeEnum,
232+
isLongText: fields[i].isLongText,
233+
originalValue: fullValue,
234+
hasMultipleValues: fields[i].hasMultipleValues,
235+
pendingValue: fields[i].pendingValue,
236+
isPendingNull: fields[i].isPendingNull,
237+
isPendingDefault: fields[i].isPendingDefault,
238+
isTruncated: false,
239+
isLoadingFullValue: false
240+
)
241+
}
242+
}
243+
203244
/// Clear all pending edits
204245
func clearEdits() {
205246
for i in 0..<fields.count {
@@ -222,7 +263,7 @@ final class MultiRowEditState {
222263
/// Get all edited fields with their new values
223264
func getEditedFields() -> [(columnIndex: Int, columnName: String, newValue: String?)] {
224265
fields.compactMap { field in
225-
guard field.hasEdit else { return nil }
266+
guard field.hasEdit, !field.isTruncated else { return nil }
226267
return (field.columnIndex, field.columnName, field.effectiveValue)
227268
}
228269
}

TablePro/Resources/Localizable.xcstrings

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30625,6 +30625,9 @@
3062530625
}
3062630626
}
3062730627
}
30628+
},
30629+
"truncated" : {
30630+
3062830631
},
3062930632
"Truncated — read only" : {
3063030633
"localizations" : {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// MainContentCoordinator+LazyLoadColumns.swift
3+
// TablePro
4+
//
5+
// Lazy-loads full values for columns truncated by ColumnExclusionPolicy.
6+
//
7+
8+
import Foundation
9+
import os
10+
import TableProPluginKit
11+
12+
private enum LazyLoadLog {
13+
static let logger = Logger(subsystem: "com.TablePro", category: "LazyLoadColumns")
14+
}
15+
16+
internal extension MainContentCoordinator {
17+
func fetchFullValuesForExcludedColumns(
18+
tableName: String,
19+
primaryKeyColumn: String,
20+
primaryKeyValue: String,
21+
excludedColumnNames: [String]
22+
) async throws -> [String: String?] {
23+
guard !excludedColumnNames.isEmpty else { return [:] }
24+
guard let driver = DatabaseManager.shared.driver(for: connectionId) else {
25+
throw DatabaseError.notConnected
26+
}
27+
28+
let quotedCols = excludedColumnNames.map { queryBuilder.quoteIdentifier($0) }
29+
let quotedTable = queryBuilder.quoteIdentifier(tableName)
30+
let quotedPK = queryBuilder.quoteIdentifier(primaryKeyColumn)
31+
32+
// Resolve parameter style from plugin metadata (? for MySQL/SQLite, $1 for PostgreSQL)
33+
let paramStyle = PluginMetadataRegistry.shared
34+
.snapshot(forTypeId: connection.type.pluginTypeId)?.parameterStyle ?? .questionMark
35+
let placeholder: String
36+
switch paramStyle {
37+
case .dollar:
38+
placeholder = "$1"
39+
case .questionMark:
40+
placeholder = "?"
41+
}
42+
43+
let query = "SELECT \(quotedCols.joined(separator: ", ")) FROM \(quotedTable) WHERE \(quotedPK) = \(placeholder)"
44+
45+
LazyLoadLog.logger.debug("Lazy-loading excluded columns: \(excludedColumnNames.joined(separator: ", "), privacy: .public)")
46+
47+
let result = try await driver.executeParameterized(
48+
query: query,
49+
parameters: [primaryKeyValue]
50+
)
51+
52+
guard let row = result.rows.first else {
53+
LazyLoadLog.logger.warning("No row returned for lazy-load query")
54+
return [:]
55+
}
56+
57+
var dict: [String: String?] = [:]
58+
for (index, colName) in excludedColumnNames.enumerated() where index < row.count {
59+
dict[colName] = row[index]
60+
}
61+
return dict
62+
}
63+
}

TablePro/Views/Main/MainContentView.swift

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ struct MainContentView: View {
4444
@State private var commandActions: MainContentCommandActions?
4545
@State private var queryResultsSummaryCache: (tabId: UUID, version: Int, summary: String?)?
4646
@State private var inspectorUpdateTask: Task<Void, Never>?
47+
@State private var lazyLoadTask: Task<Void, Never>?
4748
@State private var pendingTabSwitch: Task<Void, Never>?
4849
@State private var evictionTask: Task<Void, Never>?
4950
/// Stable identifier for this window in WindowLifecycleMonitor
@@ -870,12 +871,20 @@ struct MainContentView: View {
870871
modifiedColumns.formUnion(changeManager.getModifiedColumnsForRow(rowIndex))
871872
}
872873

874+
let excludedNames: Set<String>
875+
if let tableName = tab.tableName {
876+
excludedNames = Set(coordinator.columnExclusions(for: tableName).map(\.columnName))
877+
} else {
878+
excludedNames = []
879+
}
880+
873881
rightPanelState.editState.configure(
874882
selectedRowIndices: selectedRowIndices,
875883
allRows: allRows,
876884
columns: tab.resultColumns,
877885
columnTypes: columnTypes,
878-
externallyModifiedColumns: modifiedColumns
886+
externallyModifiedColumns: modifiedColumns,
887+
excludedColumnNames: excludedNames
879888
)
880889

881890
guard isSidebarEditable else {
@@ -892,7 +901,14 @@ struct MainContentView: View {
892901
for rowIndex in capturedEditState.selectedRowIndices {
893902
guard rowIndex < tab.resultRows.count else { continue }
894903
let originalRow = tab.resultRows[rowIndex]
895-
let oldValue = columnIndex < originalRow.count ? originalRow[columnIndex] : nil
904+
905+
// Use full (lazy-loaded) original value if available, not truncated row data
906+
let oldValue: String?
907+
if columnIndex < capturedEditState.fields.count, !capturedEditState.fields[columnIndex].isTruncated {
908+
oldValue = capturedEditState.fields[columnIndex].originalValue
909+
} else {
910+
oldValue = columnIndex < originalRow.count ? originalRow[columnIndex] : nil
911+
}
896912

897913
capturedCoordinator.changeManager.recordCellChange(
898914
rowIndex: rowIndex,
@@ -904,6 +920,45 @@ struct MainContentView: View {
904920
)
905921
}
906922
}
923+
924+
// Lazy-load full values for excluded columns when a single row is selected
925+
if !excludedNames.isEmpty,
926+
selectedRowIndices.count == 1,
927+
let tableName = tab.tableName,
928+
let pkColumn = tab.primaryKeyColumn,
929+
let rowIndex = selectedRowIndices.first,
930+
rowIndex < tab.resultRows.count {
931+
let row = tab.resultRows[rowIndex]
932+
if let pkColIndex = tab.resultColumns.firstIndex(of: pkColumn),
933+
pkColIndex < row.count,
934+
let pkValue = row[pkColIndex] {
935+
let excludedList = Array(excludedNames)
936+
937+
lazyLoadTask?.cancel()
938+
lazyLoadTask = Task { @MainActor in
939+
let expectedRowIndex = rowIndex
940+
do {
941+
let fullValues = try await capturedCoordinator.fetchFullValuesForExcludedColumns(
942+
tableName: tableName,
943+
primaryKeyColumn: pkColumn,
944+
primaryKeyValue: pkValue,
945+
excludedColumnNames: excludedList
946+
)
947+
guard !Task.isCancelled,
948+
capturedEditState.selectedRowIndices.count == 1,
949+
capturedEditState.selectedRowIndices.first == expectedRowIndex else { return }
950+
capturedEditState.applyFullValues(fullValues)
951+
} catch {
952+
guard !Task.isCancelled,
953+
capturedEditState.selectedRowIndices.count == 1,
954+
capturedEditState.selectedRowIndices.first == expectedRowIndex else { return }
955+
for i in 0..<capturedEditState.fields.count where capturedEditState.fields[i].isLoadingFullValue {
956+
capturedEditState.fields[i].isLoadingFullValue = false
957+
}
958+
}
959+
}
960+
}
961+
}
907962
}
908963

909964
// MARK: - Inspector Context

TablePro/Views/RightSidebar/EditableFieldView.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ struct EditableFieldView: View {
1919
let isPendingNull: Bool
2020
let isPendingDefault: Bool
2121
let isModified: Bool
22+
let isTruncated: Bool
23+
let isLoadingFullValue: Bool
2224

2325
let onSetNull: () -> Void
2426
let onSetDefault: () -> Void
@@ -63,6 +65,16 @@ struct EditableFieldView: View {
6365
.padding(.vertical, 1)
6466
.background(.quaternary)
6567
.clipShape(Capsule())
68+
69+
if isTruncated && !isLoadingFullValue {
70+
Text("truncated")
71+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny, weight: .medium))
72+
.foregroundStyle(.orange)
73+
.padding(.horizontal, 5)
74+
.padding(.vertical, 1)
75+
.background(.orange.opacity(0.15))
76+
.clipShape(Capsule())
77+
}
6678
}
6779

6880
// Line 2: full-width editor with inline menu overlay
@@ -80,7 +92,22 @@ struct EditableFieldView: View {
8092

8193
@ViewBuilder
8294
private var typeAwareEditor: some View {
83-
if isPendingNull || isPendingDefault {
95+
if isLoadingFullValue {
96+
TextField("", text: .constant(""))
97+
.textFieldStyle(.roundedBorder)
98+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small))
99+
.disabled(true)
100+
.overlay {
101+
ProgressView()
102+
.controlSize(.small)
103+
}
104+
} else if isTruncated {
105+
Text("Failed to load full value")
106+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small))
107+
.foregroundStyle(.secondary)
108+
.frame(maxWidth: .infinity, alignment: .leading)
109+
.padding(.vertical, 4)
110+
} else if isPendingNull || isPendingDefault {
84111
TextField(isPendingNull ? "NULL" : "DEFAULT", text: .constant(""))
85112
.textFieldStyle(.roundedBorder)
86113
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small))

TablePro/Views/RightSidebar/RightSidebarView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ struct RightSidebarView: View {
226226
isPendingNull: field.isPendingNull,
227227
isPendingDefault: field.isPendingDefault,
228228
isModified: field.hasEdit,
229+
isTruncated: field.isTruncated,
230+
isLoadingFullValue: field.isLoadingFullValue,
229231
onSetNull: { editState.setFieldToNull(at: index) },
230232
onSetDefault: { editState.setFieldToDefault(at: index) },
231233
onSetEmpty: { editState.setFieldToEmpty(at: index) },

0 commit comments

Comments
 (0)