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 @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- The row details inspector now shows the selected row's values, including JSON, when a column value filter is active, and a JSON or serialized value you open now follows the selected row as you move between rows. (#1837)
- Copying, duplicating, and deleting rows now act on the rows you selected when a column value filter is active, instead of the rows sitting at those positions in the unfiltered result. (#1837)

## [0.56.0] - 2026-07-09

### Added
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ Missing a case produces a wrong "{Language} Query" title on the first frame.

**Schema loading**: `SQLSchemaProvider` (actor) stores an in-flight `loadTask: Task<Void, Never>?`. Concurrent callers `await` the same Task instead of firing duplicate `fetchTables()` queries. Never use a boolean `isLoading` guard that returns without data — callers need to await the result.

**Selection indices are display positions**: `GridSelectionState.indices` come from `NSTableView.selectedRowIndexes` and are display-row positions, not indices into `TableRows.rows`. They match array indices only when `displayIDs` (`valueFilteredIDs ?? sortedIDs`) is nil; a per-column value filter makes them diverge. Resolve any selected index through `DisplayRowMapping` (or `TableViewCoordinator.displayRow(at:)` / `tableRowsIndex(forDisplayRow:)`) before reading or mutating a row; never index `TableRows.rows` with a display position. The row details inspector shipped this bug (#1837).

### Main Coordinator Pattern

`MainContentCoordinator` is the central coordinator, split across 7+ extension files in `Views/Main/Extensions/` (e.g., `+Alerts`, `+Filtering`, `+Pagination`, `+RowOperations`). When adding coordinator functionality, add a new extension file rather than growing the main file.
Expand Down
97 changes: 97 additions & 0 deletions TablePro/Core/Coordinators/RowEditingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ final class RowEditingCoordinator {
tab.tableContext.isEditable,
!indices.isEmpty else { return }

if parent.activeGridDisplayIDs != nil {
deleteFilteredRows(indices: indices, tab: tab, tabIndex: tabIndex)
return
}

let tabId = tab.id

var deleteResult = RowOperationsManager.DeleteRowsResult(
Expand Down Expand Up @@ -85,12 +90,68 @@ final class RowEditingCoordinator {
}
}

private func deleteFilteredRows(indices: Set<Int>, tab: QueryTab, tabIndex: Int) {
let tabId = tab.id
let displayIDs = parent.activeGridDisplayIDs
let tableRows = parent.tabSessionRegistry.tableRows(for: tabId)

var existingRows: [(displayIndex: Int, originalRow: [PluginCellValue])] = []
var insertedStorageIndices: [Int] = []
for displayIndex in indices {
guard let storageIndex = DisplayRowMapping.rowIndex(
forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows
) else { continue }
let row = tableRows.rows[storageIndex]
if row.id.isInserted {
insertedStorageIndices.append(storageIndex)
} else if !parent.changeManager.isRowDeleted(displayIndex) {
existingRows.append((displayIndex: displayIndex, originalRow: Array(row.values)))
}
}

guard !existingRows.isEmpty || !insertedStorageIndices.isEmpty else { return }

var deleteResult = RowOperationsManager.DeleteRowsResult(
nextRowToSelect: -1, physicallyRemovedIndices: [], delta: .none
)
parent.mutateActiveTableRows(for: tabId) { rows in
let result = parent.rowOperationsManager.deleteRows(
existingRows: existingRows,
insertedStorageIndices: insertedStorageIndices,
tableRows: &rows
)
deleteResult = result
return result.delta
}

parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true }

if !deleteResult.physicallyRemovedIndices.isEmpty {
parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(deleteResult.delta)
} else {
parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo()
}

let displayCount = parent.dataTabDelegate?.tableViewCoordinator?.displayIDs?.count
?? parent.tabSessionRegistry.tableRows(for: tabId).count
if let minSelected = indices.min(), displayCount > 0 {
parent.selectionState.indices = [min(minSelected, displayCount - 1)]
} else {
parent.selectionState.indices = []
}
}

func duplicateSelectedRow(index: Int) {
guard !parent.safeModeLevel.blocksAllWrites,
let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex,
tab.tableContext.isEditable,
tab.tableContext.tableName != nil else { return }

if parent.activeGridDisplayIDs != nil {
duplicateFilteredRow(displayIndex: index, tab: tab, tabIndex: tabIndex)
return
}

let tabId = tab.id
let columns = parent.tabSessionRegistry.tableRows(for: tabId).columns
guard index >= 0, index < parent.tabSessionRegistry.tableRows(for: tabId).count else { return }
Expand All @@ -116,6 +177,40 @@ final class RowEditingCoordinator {
parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0)
}

private func duplicateFilteredRow(displayIndex: Int, tab: QueryTab, tabIndex: Int) {
let tabId = tab.id
let tableRows = parent.tabSessionRegistry.tableRows(for: tabId)
let columns = tableRows.columns
guard let storageIndex = DisplayRowMapping.rowIndex(
forDisplay: displayIndex, displayIDs: parent.activeGridDisplayIDs, in: tableRows
), storageIndex >= 0, storageIndex < tableRows.count else { return }

parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit()

var dupResult: RowOperationsManager.AddNewRowResult?
parent.mutateActiveTableRows(for: tabId) { rows in
let result = parent.rowOperationsManager.duplicateRow(
sourceRowIndex: storageIndex,
columns: columns,
tableRows: &rows
)
dupResult = result
return result?.delta ?? .none
}

guard let result = dupResult else { return }

parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true }
parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta)

let displayCount = parent.dataTabDelegate?.tableViewCoordinator?.displayIDs?.count
?? parent.tabSessionRegistry.tableRows(for: tabId).count
let newDisplayIndex = displayCount - 1
guard newDisplayIndex >= 0 else { return }
parent.selectionState.indices = [newDisplayIndex]
parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: newDisplayIndex, column: 0)
}

func undoInsertRow(at rowIndex: Int) {
guard let (tab, _) = parent.tabManager.selectedTabAndIndex else { return }
let tabId = tab.id
Expand Down Expand Up @@ -165,6 +260,7 @@ final class RowEditingCoordinator {
parent.rowOperationsManager.copySelectedRowsToClipboard(
selectedIndices: indices,
tableRows: tableRows,
displayIDs: parent.activeGridDisplayIDs,
visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices()
)
}
Expand All @@ -175,6 +271,7 @@ final class RowEditingCoordinator {
parent.rowOperationsManager.copySelectedRowsToClipboard(
selectedIndices: indices,
tableRows: tableRows,
displayIDs: parent.activeGridDisplayIDs,
includeHeaders: true,
visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices()
)
Expand Down
34 changes: 31 additions & 3 deletions TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,32 @@ final class RowOperationsManager {
)
}

func deleteRows(
existingRows: [(displayIndex: Int, originalRow: [PluginCellValue])],
insertedStorageIndices: [Int],
tableRows: inout TableRows
) -> DeleteRowsResult {
let sortedInsertedRows = insertedStorageIndices.sorted(by: >)

var delta: Delta = .none
if !sortedInsertedRows.isEmpty {
delta = tableRows.remove(at: IndexSet(sortedInsertedRows))
changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows)
}

if !existingRows.isEmpty {
changeManager.recordBatchRowDeletion(
rows: existingRows.map { (rowIndex: $0.displayIndex, originalRow: $0.originalRow) }
)
}

return DeleteRowsResult(
nextRowToSelect: -1,
physicallyRemovedIndices: sortedInsertedRows,
delta: delta
)
}

func applyUndoResult(_ result: UndoResult, tableRows: inout TableRows) -> UndoApplicationResult {
switch result.action {
case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _):
Expand Down Expand Up @@ -231,6 +257,7 @@ final class RowOperationsManager {
func copySelectedRowsToClipboard(
selectedIndices: Set<Int>,
tableRows: TableRows,
displayIDs: [RowID]? = nil,
includeHeaders: Bool = false,
visibleColumnIndices: [Int]? = nil
) {
Expand Down Expand Up @@ -263,10 +290,11 @@ final class RowOperationsManager {
}
}

for rowIndex in indicesToCopy {
guard rowIndex < tableRows.count else { continue }
for displayIndex in indicesToCopy {
guard let row = DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows)
else { continue }
if !result.isEmpty { result.append("\n") }
let cells = projection.values(Array(tableRows.rows[rowIndex].values))
let cells = projection.values(Array(row.values))
structuredRows.append(cells)
for (colIdx, cell) in cells.enumerated() {
if colIdx > 0 { result.append("\t") }
Expand Down
10 changes: 6 additions & 4 deletions TablePro/Views/Main/Extensions/MainContentView+Bindings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ extension MainContentView {
var selectedRowDataForSidebar: [(column: String, value: String?, type: String)]? {
guard let tab = coordinator.tabManager.selectedTab,
!coordinator.selectionState.indices.isEmpty,
let firstIndex = coordinator.selectionState.indices.min() else { return nil }
let firstDisplayIndex = coordinator.selectionState.indices.min() else { return nil }
let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id)
guard firstIndex < tableRows.rows.count else { return nil }

let row = tableRows.rows[firstIndex].values
guard let row = DisplayRowMapping.row(
forDisplay: firstDisplayIndex,
displayIDs: coordinator.activeGridDisplayIDs,
in: tableRows
)?.values else { return nil }
var data: [(column: String, value: String?, type: String)] = []

let service = ValueDisplayFormatService.shared
Expand Down
16 changes: 11 additions & 5 deletions TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,11 @@ extension MainContentView {
}
let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id)

let displayIDs = coordinator.activeGridDisplayIDs
var allRows: [[PluginCellValue]] = []
for index in selectedIndices.sorted() {
if index < tableRows.rows.count {
allRows.append(Array(tableRows.rows[index].values))
for displayIndex in selectedIndices.sorted() {
if let row = DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) {
allRows.append(Array(row.values))
Comment on lines +164 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve filtered selections when saving inspector edits

When a value filter is active, this now populates the inspector edit state from the row resolved by display index, but the Save action still calls saveSidebarEdits, which iterates selectionState.indices and indexes tableRows.rows[rowIndex] directly. Selecting display row 1 for underlying row 2 will therefore show/edit row 2 here, then pressing Save generates SQL using row 1's original values/PK. Please resolve the selected display indices in the save path before building the RowChanges.

Useful? React with 👍 / 👎.

}
}

Expand Down Expand Up @@ -222,9 +223,14 @@ extension MainContentView {
let columnName =
columnIndex < tableRows.columns.count ? tableRows.columns[columnIndex] : ""

let displayIDs = capturedCoordinator.activeGridDisplayIDs
for rowIndex in capturedEditState.selectedRowIndices {
guard rowIndex < tableRows.rows.count else { continue }
let originalRow = Array(tableRows.rows[rowIndex].values)
guard let resolvedRow = DisplayRowMapping.row(
forDisplay: rowIndex,
displayIDs: displayIDs,
in: tableRows
) else { continue }
let originalRow = Array(resolvedRow.values)

let oldValue: PluginCellValue
if columnIndex < capturedEditState.fields.count {
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ final class MainContentCoordinator {
/// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate.
@ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate?

var activeGridDisplayIDs: [RowID]? {
dataTabDelegate?.tableViewCoordinator?.displayIDs
}

/// One-shot intent set when the user explicitly opens a table (Return/double-click),
/// consumed by the grid as it appears to move focus into it. Never set on mere selection.
@ObservationIgnored var pendingGridFocusOnOpen = false
Expand Down
15 changes: 2 additions & 13 deletions TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -367,22 +367,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
}

func displayRow(at displayIndex: Int, in tableRows: TableRows) -> Row? {
if let displayIDs {
guard displayIndex >= 0, displayIndex < displayIDs.count else { return nil }
return tableRows.row(withID: displayIDs[displayIndex])
}
guard displayIndex >= 0, displayIndex < tableRows.count else { return nil }
return tableRows.rows[displayIndex]
DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows)
}

func tableRowsIndex(forDisplayRow displayIndex: Int) -> Int? {
if let displayIDs {
guard displayIndex >= 0, displayIndex < displayIDs.count else { return nil }
return tableRowsProvider().index(of: displayIDs[displayIndex])
}
let count = tableRowsProvider().count
guard displayIndex >= 0, displayIndex < count else { return nil }
return displayIndex
DisplayRowMapping.rowIndex(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRowsProvider())
}

func displayValue(forID id: RowID, column: Int, rawValue: PluginCellValue, columnType: ColumnType?) -> String? {
Expand Down
22 changes: 22 additions & 0 deletions TablePro/Views/Results/DisplayRowMapping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// DisplayRowMapping.swift
// TablePro
//

import Foundation

enum DisplayRowMapping {
static func rowIndex(forDisplay displayIndex: Int, displayIDs: [RowID]?, in tableRows: TableRows) -> Int? {
guard let displayIDs else {
guard displayIndex >= 0, displayIndex < tableRows.count else { return nil }
return displayIndex
}
guard displayIndex >= 0, displayIndex < displayIDs.count else { return nil }
return tableRows.index(of: displayIDs[displayIndex])
}

static func row(forDisplay displayIndex: Int, displayIDs: [RowID]?, in tableRows: TableRows) -> Row? {
guard let index = rowIndex(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) else { return nil }
return tableRows.rows[index]
}
}
33 changes: 21 additions & 12 deletions TablePro/Views/RightSidebar/RightSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ struct RightSidebarView: View {
let databaseType: DatabaseType

@State private var searchText: String = ""
@State private var expandedJsonFieldId: UUID?
@State private var expandedPhpFieldId: UUID?
@State private var expandedJsonColumnIndex: Int?
@State private var expandedPhpColumnIndex: Int?

// MARK: - Inspector Mode

Expand Down Expand Up @@ -141,14 +141,23 @@ struct RightSidebarView: View {
private func rowDetailForm(
_ rowData: [(column: String, value: String?, type: String)]
) -> some View {
if let expandedId = expandedJsonFieldId,
let field = editState.fields.first(where: { $0.id == expandedId }) {
rowDetailContent(rowData)
.onChange(of: rowData.map(\.column)) {
expandedJsonColumnIndex = nil
expandedPhpColumnIndex = nil
}
}

@ViewBuilder
private func rowDetailContent(
_ rowData: [(column: String, value: String?, type: String)]
) -> some View {
if let columnIndex = expandedJsonColumnIndex,
let field = editState.fields.first(where: { $0.columnIndex == columnIndex }) {
expandedJsonViewer(field: field, isEditable: contentMode == .editRow)
.onChange(of: selectedRowData?.count) { expandedJsonFieldId = nil }
} else if let expandedId = expandedPhpFieldId,
let field = editState.fields.first(where: { $0.id == expandedId }) {
} else if let columnIndex = expandedPhpColumnIndex,
let field = editState.fields.first(where: { $0.columnIndex == columnIndex }) {
expandedPhpViewer(field: field)
.onChange(of: selectedRowData?.count) { expandedPhpFieldId = nil }
} else {
fieldListForm(rowData)
}
Expand All @@ -159,7 +168,7 @@ struct RightSidebarView: View {
private func expandedJsonViewer(field: FieldEditState, isEditable: Bool) -> some View {
VStack(spacing: 0) {
HStack {
Button { expandedJsonFieldId = nil } label: {
Button { expandedJsonColumnIndex = nil } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
Text("Fields")
Expand Down Expand Up @@ -218,7 +227,7 @@ struct RightSidebarView: View {
private func expandedPhpViewer(field: FieldEditState) -> some View {
VStack(spacing: 0) {
HStack {
Button { expandedPhpFieldId = nil } label: {
Button { expandedPhpColumnIndex = nil } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
Text("Fields")
Expand Down Expand Up @@ -342,9 +351,9 @@ struct RightSidebarView: View {
isForeignKey: field.isForeignKey,
onExpand: isStructuredField ? {
if isJsonField {
expandedJsonFieldId = field.id
expandedJsonColumnIndex = field.columnIndex
} else {
expandedPhpFieldId = field.id
expandedPhpColumnIndex = field.columnIndex
}
} : nil,
onPopOut: isStructuredField ? { currentText in
Expand Down
Loading
Loading