Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 47 additions & 6 deletions TablePro/Models/UI/MultiRowEditState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -67,8 +73,9 @@ final class MultiRowEditState {
selectedRowIndices: Set<Int>,
allRows: [[String?]],
columns: [String],
columnTypes: [ColumnType], // Changed from [String] to [ColumnType]
externallyModifiedColumns: Set<Int> = []
columnTypes: [ColumnType],
externallyModifiedColumns: Set<Int> = [],
excludedColumnNames: Set<String> = []
) {
// Check if the underlying data has changed (not just edits)
let columnsChanged = self.columns != columns
Expand Down Expand Up @@ -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)
Expand All @@ -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
))
}

Expand Down Expand Up @@ -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..<fields.count {
guard let fullValue = fullValues[fields[i].columnName] else { continue }
fields[i] = FieldEditState(
columnIndex: fields[i].columnIndex,
columnName: fields[i].columnName,
columnTypeEnum: fields[i].columnTypeEnum,
isLongText: fields[i].isLongText,
originalValue: fullValue,
hasMultipleValues: fields[i].hasMultipleValues,
pendingValue: fields[i].pendingValue,
isPendingNull: fields[i].isPendingNull,
isPendingDefault: fields[i].isPendingDefault,
isTruncated: false,
isLoadingFullValue: false
)
}
}

/// Clear all pending edits
func clearEdits() {
for i in 0..<fields.count {
Expand All @@ -222,7 +263,7 @@ final class MultiRowEditState {
/// Get all edited fields with their new values
func getEditedFields() -> [(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)
}
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -30625,6 +30625,9 @@
}
}
}
},
"truncated" : {

},
"Truncated — read only" : {
"localizations" : {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
59 changes: 57 additions & 2 deletions TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
@State private var lazyLoadTask: Task<Void, Never>?
@State private var pendingTabSwitch: Task<Void, Never>?
@State private var evictionTask: Task<Void, Never>?
/// Stable identifier for this window in WindowLifecycleMonitor
Expand Down Expand Up @@ -870,12 +871,20 @@ struct MainContentView: View {
modifiedColumns.formUnion(changeManager.getModifiedColumnsForRow(rowIndex))
}

let excludedNames: Set<String>
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 {
Expand All @@ -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,
Expand All @@ -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..<capturedEditState.fields.count where capturedEditState.fields[i].isLoadingFullValue {
capturedEditState.fields[i].isLoadingFullValue = false
}
}
}
}
}
}

// MARK: - Inspector Context
Expand Down
29 changes: 28 additions & 1 deletion TablePro/Views/RightSidebar/EditableFieldView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ struct EditableFieldView: View {
let isPendingNull: Bool
let isPendingDefault: Bool
let isModified: Bool
let isTruncated: Bool
let isLoadingFullValue: Bool

let onSetNull: () -> Void
let onSetDefault: () -> Void
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/RightSidebar/RightSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
Loading
Loading