diff --git a/CHANGELOG.md b/CHANGELOG.md index 2debae997..eb0a040b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,50 +9,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **File > Close All Tabs**, **Close Other Tabs**, and **Close Tabs for Other Databases** close a group of tabs in one step instead of one at a time. Closing every tab leaves the window open on its empty state with the connection still live, and each closed tab stays in **Recently Closed**. None of the three has a shortcut out of the box; bind them in **Settings > Keyboard**. (#1972) -- AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945) -- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) -- A tool call limit you can raise or turn off, in **Settings > AI**. It defaults to 25 per reply, up from a fixed 10. Reaching it now pauses the reply and keeps everything the AI found, with **Continue** to carry on and **Adjust Limit** to change the setting. Before, a long investigation ended in "AI made too many tool calls in one response" and the partial answer was thrown away. Copilot runs its own tool loop and ignores this setting. (#1987) -- A pin button on result tabs. It shows on the tab you are on and when you point at a tab, and stays visible once the result is pinned. Pinned results move to the front of the strip. Pinning was already there, but only as a right-click item and a View menu command, so nobody found it. (#1982) - -### Changed - -- The welcome screen groups the ways to add an existing connection into one **Add from Existing** menu, next to **Create Connection**. Import from URL now has a home in that menu and in the File menu, where it had none. **Try Sample Database** moved to the connection list, which already offers it when the list is empty. -- A failed iCloud sync on iPhone and iPad now says what went wrong instead of showing a raw error. Out of storage, no network, and a rejected change each get their own message, the same ones the Mac already showed. The Mac and the mobile app now run the same sync engine, so a sync fix on one reaches the other. (#1990) - -### Fixed - -- Editing a connection on one device no longer overwrites a different field of the same connection edited on another. Each device sent the whole connection on every sync, so the last one to sync won outright. Sync now sends only the fields that actually changed and merges the rest. (#643) -- A per-connection query timeout set on iPhone or iPad no longer stops that connection from syncing at all. (#643) -- A device catching up on a lot of iCloud changes at once now downloads all of them. Only the first page of changes was read and the rest were skipped, and the sync position was still moved past them, so those changes never arrived. (#643) -- iCloud Sync reports a failed sync instead of always reporting success. A rejected change was logged and then dropped, the status went back to idle, and the Last Synced time was stamped as though everything had worked, so there was no way to tell sync was broken. (#643) -- One rejected change no longer stops every other change in the same batch from syncing. Each change is now confirmed on its own, and only the ones iCloud accepted are marked as sent, so the rest are retried instead of being silently discarded. (#643) -- Connection changes made on the Mac reach the iPhone and iPad app again. The Mac was sending fields that its iCloud database does not hold, so iCloud rejected every connection it sent while the app still reported the sync as successful. Changes made on iPhone or iPad always reached the Mac, which is why sync looked like it worked in one direction only. (#643) -- A connection synced from iPhone or iPad keeps its safe mode setting on the Mac. Read-only and confirm-writes connections arrived with no protection at all, because the Mac did not recognise the values the mobile app writes and fell back to Silent. An unrecognised value now asks for confirmation before a write instead of allowing it. (#643) -- A connection's colour tag set on iPhone or iPad no longer overwrites the colour label on the Mac. Both were stored in the same field despite meaning different things. (#643) -- **Primary Key**, **Nullable**, **Auto Inc**, and the index **Unique** flag now change when you pick a value. The cell read YES or NO but the menu offered true and false, so picking true did nothing on PostgreSQL, DuckDB, BigQuery, Snowflake, Cassandra, Trino, and SurrealDB. The menu now offers YES and NO to match the cell, in both the new table editor and the structure editor, and no longer offers Set NULL for a flag that has no NULL state. Setting Primary Key to YES now also sets Nullable to NO, since a primary key column cannot hold NULL. (#1991) -- A message sent in the AI panel right after launch is no longer replaced by the last saved conversation. Restoring that conversation reads from disk in the background, and it used to overwrite whatever you had already typed and sent. -- A PostgreSQL partitioned table is listed once instead of once per partition. Expand it in the sidebar to see its partitions, and expand one again if it is subpartitioned. A table split into hundreds of partitions no longer buries the rest of the schema, and those partitions no longer fill up query autocomplete, Cmd+K, and the export picker. (#1984) -- An SSH tunnel that cannot reach the database now says so, instead of leaving the database driver to report a timeout reading the server greeting. MySQL showed this as "Lost connection to server at 'handshake: reading initial communication packet', system error: 35", which named no cause. TablePro now checks the destination is reachable before the tunnel is handed over, and reports whether it was refused or timed out. The most common cause is the **Host** field holding the server's public address while the database only listens on `127.0.0.1`; the SSH server resolves that address from where it sits, so **Host** should be `localhost`. (#1981) -- The macOS local network prompt now says why TablePro wants access. The app never declared a reason, so the prompt showed a generic message that was easy to deny, which left SSH tunnels and databases on your own network failing with "no route to host". -- An SSH tunnel no longer dies while it is busy. The keep-alive read a send that had to wait as a dead tunnel, so heavy traffic through the tunnel could tear it down along with every connection using it. -- A `ProxyCommand` line in `~/.ssh/config` no longer passes without a word. TablePro cannot run the command, so it connects straight to the hostname and can land somewhere `ssh` never would. It now logs that it skipped the directive. `ProxyJump` is still followed as before. -- Right-clicking below the SQL editor opens the menu for what you clicked. A query taller than the editor pane made the editor answer right-clicks on the result tabs, the data grid, and the status bar with its own menu, which put **Pin Result**, the row actions, and **Clear Results** out of reach. (#1982) -- **View > Pin Result** is enabled only when there is a result tab to pin. It stayed on in JSON view, during Explain, and in Structure view, where it either did nothing or pinned a result you could not see. Result tabs now also show in JSON view, so a multi-statement run lets you switch between its results there. (#1982) -- Closing a window that holds more than one tab now asks about unsaved changes in any of them, not just the tab on screen. (#1972) -- MongoDB no longer fails with an authentication error when you open a database your credentials do not belong to. TablePro authenticated against whichever database you were browsing instead of the one set on the connection, which left the connection broken until you deleted and recreated it. Creating a database hit this every time. (#1970) -- Creating a MongoDB database now asks for its first collection and keeps it. The database was created and then removed again, because MongoDB drops a database as soon as its last collection goes. (#1970) -- A MongoDB authentication error now names the user and the database it tried to authenticate against, instead of a bare driver code. (#1970) -- Exporting a MongoDB collection works again. Every format failed with "Unsupported MongoDB method: getCollection" before any data was read. You can now also write `db.getCollection("name")` in the query editor, which is the only way to reach collections whose names contain dots or spaces, start with a digit, or match a database method such as `stats`. (#1971) -- Exported MongoDB collections keep every field. TablePro took the column list from the first document alone, so a field that showed up later was dropped from JSON exports and pushed CSV rows out of line with their header. (#1971) -- The `postgres` database shows in the database list again. It was marked as a system database, which hid it from the sidebar, Cmd+K, the database filter, and the Backup and Restore Dump pickers. PostgreSQL creates it for users and applications, so nothing about it is internal. CockroachDB's `defaultdb` and Redshift's `dev` were hidden the same way and now show too. (#1967) -- The database a connection is using always shows in the sidebar and the database switchers, even when it is a system database or the database filter excludes it. (#1967) -- The license activation sheet now opens when you click **Activate License**. It was built and then failed to appear, and once that happened further clicks did nothing at all. -- File > New Connection... and ⌘N now open the connection chooser once you have a connection open. They did nothing at all, because the welcome window closes as soon as you connect and the command was addressed to it. Import from Other App..., Open Project Folder..., Import Connections..., Import from URL... and Export Connections... had the same fault and are fixed with it. (#1975) -- The tooltip on the welcome screen's **+** button shows the shortcut you actually have bound for New Connection instead of always claiming ⌘N. -- The AI chat panel stays inside the right panel when you drag it narrow. The model name, tool names, and code block headers now truncate, long chat messages and code wrap, wide tables scroll inside their own box, and the composer text follows the panel width instead of running under the editor. (#1956) +- **Close All Tabs**, **Close Other Tabs**, and **Close Tabs for Other Databases** in the File menu, bindable in **Settings > Keyboard**. (#1972) +- AI Explain, Optimize, and Fix Error answer with numbered steps and a before/after SQL diff you can apply to the editor. (#1945) +- Create a connection from a project folder, filled in from `.env`, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, or `appsettings.json`. (#1959) +- An AI tool call limit in **Settings > AI**, now 25 per reply instead of a fixed 10, with **Continue** to resume a paused reply. (#1987) +- A pin button on result tabs. (#1982) +- The inspector edits the selected column, index, or foreign key in the Structure tab, and its edits join the grid's pending changes. + +### Changed + +- The welcome screen groups Import from Other App, Open Project Folder, Import Connections, and Import from URL into one **Add from Existing** menu, and offers **Try Sample Database** in the connection list. +- A failed iCloud sync on iPhone and iPad names the cause: out of storage, no network, or a rejected change. (#1990) + +### Fixed + +- Selecting a row in the Structure tab shows that row in the inspector instead of the data row in the same position. An edit made in the inspector there, and Duplicate Row and Copy with Headers, no longer act on a data row you never selected. +- Editing a connection on two devices no longer overwrites the field the other one changed. (#643) +- A per-connection query timeout set on iPhone or iPad no longer stops that connection from syncing. (#643) +- iCloud Sync downloads every page of changes when catching up, instead of only the first. (#643) +- iCloud Sync reports a failed sync instead of always reporting success. (#643) +- A change iCloud rejects no longer stops the rest of its batch from syncing. (#643) +- Connection changes made on the Mac reach the iPhone and iPad app again. (#643) +- A connection synced from iPhone or iPad keeps its safe mode setting on the Mac. (#643) +- A connection's colour tag set on iPhone or iPad no longer overwrites its colour label on the Mac. (#643) +- **Primary Key**, **Nullable**, **Auto Inc**, and the index **Unique** flag now change when you pick a value, and no longer offer Set NULL. (#1991) +- A message sent in the AI panel right after launch is no longer replaced by the restored conversation. +- A PostgreSQL partitioned table is listed once, with its partitions nested under it. (#1984) +- An SSH tunnel that cannot reach the database reports whether the connection was refused or timed out. (#1981) +- The macOS local network prompt says why TablePro needs access. +- An SSH tunnel no longer drops under heavy traffic. +- A `ProxyCommand` line in `~/.ssh/config` is logged as skipped instead of ignored without a word. +- Right-clicking below the SQL editor opens the menu for the result tabs, data grid, or status bar instead of the editor's. (#1982) +- **View > Pin Result** is enabled only when there is a result tab to pin, and result tabs now show in JSON view. (#1982) +- Closing a window asks about unsaved changes in every tab, not just the one on screen. (#1972) +- MongoDB authenticates against the database set on the connection, not the one you are browsing. (#1970) +- Creating a MongoDB database asks for its first collection, so the database is kept. (#1970) +- A MongoDB authentication error names the user and the database it tried. (#1970) +- Exporting a MongoDB collection works again, and `db.getCollection("name")` is accepted in the query editor. (#1971) +- A MongoDB export includes fields that first appear after the opening document. (#1971) +- `postgres`, CockroachDB's `defaultdb`, and Redshift's `dev` are no longer hidden as system databases. (#1967) +- The database a connection is using always shows in the sidebar and the database switchers. (#1967) +- The license activation sheet opens when you click **Activate License**. +- File > New Connection..., ⌘N, and the other File menu connection commands work once a connection is open. (#1975) +- The tooltip on the welcome screen's **+** button shows the shortcut bound to New Connection. +- The AI chat panel stays inside the right panel at narrow widths. (#1956) - BigQuery `REPEATED` columns (including repeated `STRUCT`) no longer show every element as `null`. (#1963) -- BigQuery `STRUCT` cells now show nested structs and arrays as real JSON instead of escaped text, and quotes, backslashes, tabs, and newlines inside values are escaped correctly. (#1963) +- BigQuery `STRUCT` cells show nested structs and arrays as JSON instead of escaped text. (#1963) ## [0.60.1] - 2026-07-25 diff --git a/TablePro/Core/DataGrid/RowDisplayBox.swift b/TablePro/Core/DataGrid/RowDisplayBox.swift index cf9f0107c..511b397d3 100644 --- a/TablePro/Core/DataGrid/RowDisplayBox.swift +++ b/TablePro/Core/DataGrid/RowDisplayBox.swift @@ -49,6 +49,18 @@ final class RowDisplayCache { totalCost = 0 } + /// Drops one row's formatted values while keeping its box, so the next read + /// reformats from the current cell values. Row ids are positional, so a row + /// whose content changed in place keeps its id and would otherwise be served + /// its pre-edit text. + func clearValues(forID id: RowID) { + guard let box = storage[id] else { return } + totalCost -= rowCost(box.values) + for index in box.values.indices { + box.values[index] = nil + } + } + private func evictIfNeeded() { while storage.count > countLimit || totalCost > costLimit { guard insertionHead < insertionOrder.count else { break } diff --git a/TablePro/Models/UI/FieldEditorKind.swift b/TablePro/Models/UI/FieldEditorKind.swift new file mode 100644 index 000000000..cd8fc8e5d --- /dev/null +++ b/TablePro/Models/UI/FieldEditorKind.swift @@ -0,0 +1,19 @@ +// +// FieldEditorKind.swift +// TablePro +// + +import Foundation + +internal enum FieldEditorKind: Equatable { + case json + case phpSerialized + case blobHex + case boolean + case enumPicker(values: [String]) + case setPicker(values: [String]) + case typePicker + case schemaText + case multiLine + case singleLine +} diff --git a/TablePro/Models/UI/GridSelectionOwner.swift b/TablePro/Models/UI/GridSelectionOwner.swift new file mode 100644 index 000000000..6fd39a257 --- /dev/null +++ b/TablePro/Models/UI/GridSelectionOwner.swift @@ -0,0 +1,28 @@ +// +// GridSelectionOwner.swift +// TablePro +// +// Resolves which grid owns the row selection in `GridSelectionState`. The data grid, +// the structure grid, and the new-table grid all publish into that one channel, so +// every consumer has to agree on whose display positions the indices are. +// + +import Foundation + +internal enum GridSelectionOwner: Equatable { + case dataGrid + case schemaGrid + case none + + static func resolve(tabType: TabType?, resultsViewMode: ResultsViewMode?) -> GridSelectionOwner { + guard let tabType else { return .none } + if tabType == .createTable { return .schemaGrid } + if resultsViewMode == .structure { return .schemaGrid } + switch tabType { + case .table, .query: + return .dataGrid + case .createTable, .erDiagram, .serverDashboard, .usersRoles: + return .none + } + } +} diff --git a/TablePro/Models/UI/InspectorRowSource.swift b/TablePro/Models/UI/InspectorRowSource.swift new file mode 100644 index 000000000..3e194c8ea --- /dev/null +++ b/TablePro/Models/UI/InspectorRowSource.swift @@ -0,0 +1,35 @@ +// +// InspectorRowSource.swift +// TablePro +// +// Lets the grid that owns the current selection supply the inspector's +// fields and receive its edits, instead of the inspector assuming every +// selection indexes the data tab's rows. +// + +import Foundation + +internal struct InspectorRowField: Equatable { + let name: String + let value: String? + let editor: FieldEditorKind + let isModified: Bool + + init(name: String, value: String?, editor: FieldEditorKind, isModified: Bool = false) { + self.name = name + self.value = value + self.editor = editor + self.isModified = isModified + } +} + +internal struct InspectorRow: Equatable { + let fields: [InspectorRowField] + let isEditable: Bool +} + +@MainActor +internal protocol InspectorRowSource: AnyObject { + func inspectorRow(atDisplayRow displayRow: Int) -> InspectorRow? + func commitInspectorField(displayRow: Int, fieldIndex: Int, value: String?) +} diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index 3f5dfe9f8..5f7bee7ef 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -22,6 +22,15 @@ struct FieldEditState: Identifiable { var isPrimaryKey: Bool = false var isForeignKey: Bool = false + /// Set when the owning grid dictates the editor instead of the column type. + var editor: FieldEditorKind? + + /// A schema field has no data type, so it offers no type badge and no NULL or DEFAULT state. + var isSchemaField: Bool = false + + /// The value already differs from the loaded schema because the edit is recorded elsewhere. + var hasCommittedEdit: Bool = false + var originalValue: String? let hasMultipleValues: Bool @@ -151,6 +160,41 @@ final class MultiRowEditState { self.fields = newFields } + /// Configure state for a single schema row supplied by the grid that owns the selection. + /// Field ids survive a reconfigure of the same row so a commit does not rebuild the + /// editors and drop focus while the user is still moving between fields. + func configure(schemaFields: [InspectorRowField], displayRow: Int) { + let names = schemaFields.map(\.name) + let reusedIds = selectedRowIndices == [displayRow] && columns == names ? fields.map(\.id) : [] + + selectedRowIndices = [displayRow] + columns = names + columnTypes = Array(repeating: .text(rawType: nil), count: names.count) + allRows = [schemaFields.map(\.value)] + + fields = schemaFields.enumerated().map { index, field in + var state = FieldEditState( + columnIndex: index, + columnName: field.name, + columnTypeEnum: .text(rawType: nil), + isLongText: false, + isJson: false, + editor: field.editor, + isSchemaField: true, + hasCommittedEdit: field.isModified, + originalValue: field.value, + hasMultipleValues: false, + pendingValue: nil, + isPendingNull: false, + isPendingDefault: false + ) + if index < reusedIds.count { + state.id = reusedIds[index] + } + return state + } + } + /// Update a field's pending value func updateField(at index: Int, value: String?) { guard index < fields.count else { return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index a0c457279..b261f1212 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -16,7 +16,11 @@ extension MainContentCoordinator { ) async throws { guard let tab = tabManager.selectedTab, !selectionState.indices.isEmpty, - tab.tableContext.tableName != nil + tab.tableContext.tableName != nil, + GridSelectionOwner.resolve( + tabType: tab.tabType, + resultsViewMode: tab.display.resultsViewMode + ) == .dataGrid else { return } @@ -25,9 +29,14 @@ extension MainContentCoordinator { guard !editedFields.isEmpty else { return } let tableRows = tabSessionRegistry.tableRows(for: tab.id) + let displayIDs = activeGridDisplayIDs let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex -> RowChange? in - guard rowIndex < tableRows.rows.count else { return nil } - let originalRow = Array(tableRows.rows[rowIndex].values) + guard let resolvedRow = DisplayRowMapping.row( + forDisplay: rowIndex, + displayIDs: displayIDs, + in: tableRows + ) else { return nil } + let originalRow = Array(resolvedRow.values) return RowChange( rowIndex: rowIndex, type: .update, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 4597bd44a..b91fe05e9 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -10,11 +10,34 @@ import SwiftUI import TableProPluginKit extension MainContentView { + // MARK: - Inspector Selection + + /// Which grid owns the current selection. `GridSelectionState` is shared by the data + /// grid, the structure grid, and the new-table grid, so its indices only address the + /// data tab's rows when the data grid is the one on screen. + var gridSelectionOwner: GridSelectionOwner { + GridSelectionOwner.resolve( + tabType: coordinator.tabManager.selectedTab?.tabType, + resultsViewMode: coordinator.tabManager.selectedTab?.display.resultsViewMode + ) + } + + var selectedInspectorRow: InspectorRow? { + guard gridSelectionOwner == .schemaGrid, + let displayRow = coordinator.selectionState.indices.min() else { return nil } + return coordinator.inspectorRowSource?.inspectorRow(atDisplayRow: displayRow) + } + // MARK: - Selected Row Data for Sidebar /// Compute selected row data for right sidebar display var selectedRowDataForSidebar: [(column: String, value: String?, type: String)]? { - guard let tab = coordinator.tabManager.selectedTab, + if gridSelectionOwner == .schemaGrid { + guard let row = selectedInspectorRow else { return nil } + return row.fields.map { (column: $0.name, value: $0.value, type: "string") } + } + guard gridSelectionOwner == .dataGrid, + let tab = coordinator.tabManager.selectedTab, !coordinator.selectionState.indices.isEmpty, let firstDisplayIndex = coordinator.selectionState.indices.min() else { return nil } let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) @@ -59,7 +82,11 @@ extension MainContentView { /// Determine if sidebar should be in editable mode var isSidebarEditable: Bool { - guard !coordinator.safeModeLevel.blocksAllWrites, + if gridSelectionOwner == .schemaGrid { + return selectedInspectorRow?.isEditable ?? false + } + guard gridSelectionOwner == .dataGrid, + !coordinator.safeModeLevel.blocksAllWrites, let tab = coordinator.tabManager.selectedTab, tab.tabType == .table || tab.tableContext.tableName != nil, !coordinator.selectionState.indices.isEmpty else { @@ -69,7 +96,8 @@ extension MainContentView { } var isSelectedRowDeleted: Bool { - guard let firstIndex = coordinator.selectionState.indices.min() else { return false } + guard gridSelectionOwner == .dataGrid, + let firstIndex = coordinator.selectionState.indices.min() else { return false } return coordinator.changeManager.isRowDeleted(firstIndex) } @@ -119,7 +147,9 @@ extension MainContentView { InspectorTrigger( tableName: currentTab?.tableContext.tableName, schemaVersion: currentTab?.schemaVersion ?? -1, - metadataVersion: currentTab?.metadataVersion ?? -1 + metadataVersion: currentTab?.metadataVersion ?? -1, + resultsViewMode: currentTab?.display.resultsViewMode ?? .data, + inspectorRowSourceRevision: coordinator.inspectorRowSourceRevision ) } } @@ -128,6 +158,8 @@ struct InspectorTrigger: Equatable { let tableName: String? let schemaVersion: Int let metadataVersion: Int + let resultsViewMode: ResultsViewMode + let inspectorRowSourceRevision: Int } /// Lightweight equatable value combining all pending-change sources diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 7951dea73..9c3fc7535 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -149,12 +149,22 @@ extension MainContentView { // MARK: - Sidebar Edit Handling func updateSidebarEditState() { + switch gridSelectionOwner { + case .schemaGrid: + updateSchemaSidebarEditState() + return + case .none: + clearSidebarEditState() + return + case .dataGrid: + break + } + let selectedIndices = coordinator.selectionState.indices guard let tab = coordinator.tabManager.selectedTab, !selectedIndices.isEmpty else { - rightPanelState.editState.fields = [] - rightPanelState.editState.onFieldChanged = nil + clearSidebarEditState() return } let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) @@ -252,4 +262,38 @@ extension MainContentView { } } } + + private func clearSidebarEditState() { + rightPanelState.editState.fields = [] + rightPanelState.editState.onFieldChanged = nil + } + + /// Populate the inspector from the grid that owns a schema selection, and send every + /// edit back to it so a change lands in its change manager exactly as an inline + /// grid edit does, with one undo step and one pending change. + private func updateSchemaSidebarEditState() { + guard let displayRow = coordinator.selectionState.indices.min(), + let row = selectedInspectorRow + else { + clearSidebarEditState() + return + } + + rightPanelState.editState.configure(schemaFields: row.fields, displayRow: displayRow) + + guard row.isEditable else { + rightPanelState.editState.onFieldChanged = nil + return + } + + let capturedCoordinator = coordinator + rightPanelState.editState.onFieldChanged = { fieldIndex, newValue in + capturedCoordinator.inspectorRowSource?.commitInspectorField( + displayRow: displayRow, + fieldIndex: fieldIndex, + value: newValue.asText + ) + capturedCoordinator.inspectorRowSourceRevision += 1 + } + } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 95869492b..69a9d1328 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -185,6 +185,15 @@ final class MainContentCommandActions { coordinator?.dataTabDelegate?.tableViewCoordinator?.currentRowSelection() ?? selectionState.indices } + /// `selectionState` is shared with the structure and new-table grids, so a row command + /// has to confirm the data grid owns the selection before it acts on data rows. + private var dataGridOwnsSelection: Bool { + GridSelectionOwner.resolve( + tabType: coordinator?.tabManager.selectedTab?.tabType, + resultsViewMode: coordinator?.tabManager.selectedTab?.display.resultsViewMode + ) == .dataGrid + } + func deleteSelectedRows(rowIndices: Set? = nil) { let fromDataGrid = rowIndices != nil @@ -217,6 +226,7 @@ final class MainContentCommandActions { } func duplicateRow() { + guard dataGridOwnsSelection else { return } let indices = selectionState.indices guard let selectedIndex = indices.first, indices.count == 1 else { return } coordinator?.duplicateSelectedRow(index: selectedIndex) @@ -231,6 +241,7 @@ final class MainContentCommandActions { } func copySelectedRowsWithHeaders() { + guard dataGridOwnsSelection else { return } coordinator?.copySelectedRowsWithHeaders(indices: resolvedRowSelection()) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index d71d1945b..e49a01013 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -127,6 +127,14 @@ final class MainContentCoordinator { /// `TableStructureView` writes to this; `MainStatusBarView` reads from it. let structureFooterState = StructureFooterState() + /// The grid that owns the current selection when it is not the data grid, so the + /// inspector reads the selected row from it instead of the data tab's rows. + /// Set by `TableStructureView` and `CreateTableView` on appear. + weak var inspectorRowSource: (any InspectorRowSource)? + + /// Bumped whenever a published schema row changes, so the inspector re-reads it. + var inspectorRowSourceRevision: Int = 0 + /// Direct reference to AI chat viewmodel — eliminates notification broadcasts weak var aiViewModel: AIChatViewModel? diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index f13aa5cbb..1c3b68d92 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -538,6 +538,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return total } + private func invalidateDisplayCache(forDisplayRow displayIndex: Int) { + guard let row = displayRow(at: displayIndex) else { return } + displayCache.clearValues(forID: row.id) + } + private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) { guard let row = displayRow(at: displayIndex) else { return } guard let box = displayCache.box(forID: row.id), @@ -636,6 +641,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData guard let tableView else { return } let visibleRange = tableView.rows(in: tableView.visibleRect) guard visibleRange.length > 0 else { return } + invalidateDisplayCache() tableView.reloadData( forRowIndexes: IndexSet(integersIn: visibleRange.location..<(visibleRange.location + visibleRange.length)), columnIndexes: IndexSet(integersIn: 0..= 0, row < tableView.numberOfRows else { return } + invalidateDisplayCache(forDisplayRow: row) tableView.reloadData( forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integersIn: 0.. Void)? + /// Set when the owning grid dictates the editor instead of the column type. + let editor: FieldEditorKind? + + /// A schema field has no NULL or DEFAULT state and no data type to badge. + let allowsNullAndDefault: Bool + let showsTypeBadge: Bool + init( columnName: String, columnType: ColumnType, @@ -22,7 +29,10 @@ internal struct FieldEditorContext { originalValue: String?, hasMultipleValues: Bool, isReadOnly: Bool, - commitBytes: ((Data) -> Void)? = nil + commitBytes: ((Data) -> Void)? = nil, + editor: FieldEditorKind? = nil, + allowsNullAndDefault: Bool = true, + showsTypeBadge: Bool = true ) { self.columnName = columnName self.columnType = columnType @@ -32,6 +42,9 @@ internal struct FieldEditorContext { self.hasMultipleValues = hasMultipleValues self.isReadOnly = isReadOnly self.commitBytes = commitBytes + self.editor = editor + self.allowsNullAndDefault = allowsNullAndDefault + self.showsTypeBadge = showsTypeBadge } var placeholderText: String { diff --git a/TablePro/Views/RightSidebar/FieldEditors/SchemaTextFieldView.swift b/TablePro/Views/RightSidebar/FieldEditors/SchemaTextFieldView.swift new file mode 100644 index 000000000..41fc33890 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/SchemaTextFieldView.swift @@ -0,0 +1,40 @@ +// +// SchemaTextFieldView.swift +// TablePro +// + +import SwiftUI + +/// Text editor for a schema field. Commits on Return or when focus leaves, the way +/// the structure grid's cell editor does, so one edit records one schema change +/// instead of one per keystroke. +internal struct SchemaTextFieldView: View { + let context: FieldEditorContext + + @State private var draft: String = "" + @FocusState private var isFocused: Bool + + var body: some View { + TextField(context.placeholderText, text: $draft) + .textFieldStyle(.roundedBorder) + .font(.subheadline) + .autocorrectionDisabled(true) + .focused($isFocused) + .disabled(context.isReadOnly) + .onAppear { draft = context.value.wrappedValue } + .onChange(of: context.value.wrappedValue) { _, newValue in + guard !isFocused else { return } + draft = newValue + } + .onChange(of: isFocused) { _, focused in + guard !focused else { return } + commit() + } + .onSubmit { commit() } + } + + private func commit() { + guard draft != context.value.wrappedValue else { return } + context.value.wrappedValue = draft + } +} diff --git a/TablePro/Views/RightSidebar/FieldEditors/TypePickerFieldView.swift b/TablePro/Views/RightSidebar/FieldEditors/TypePickerFieldView.swift new file mode 100644 index 000000000..05e8f6d56 --- /dev/null +++ b/TablePro/Views/RightSidebar/FieldEditors/TypePickerFieldView.swift @@ -0,0 +1,40 @@ +// +// TypePickerFieldView.swift +// TablePro +// + +import SwiftUI + +/// Column type editor for a schema field: free-form text for lengths and precision, +/// plus the same searchable type list the structure grid opens from its cell chevron. +internal struct TypePickerFieldView: View { + let context: FieldEditorContext + let databaseType: DatabaseType + + @State private var isPickerPresented = false + + var body: some View { + HStack(spacing: 4) { + SchemaTextFieldView(context: context) + + Button { + isPickerPresented = true + } label: { + Image(systemName: "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .disabled(context.isReadOnly) + .accessibilityLabel(String(localized: "Choose Type")) + .popover(isPresented: $isPickerPresented) { + TypePickerContentView( + databaseType: databaseType, + currentValue: context.value.wrappedValue, + onCommit: { context.value.wrappedValue = $0 }, + onDismiss: { isPickerPresented = false } + ) + } + } + } +} diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index a8186e936..73729e53b 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -316,11 +316,7 @@ struct RightSidebarView: View { @ViewBuilder private func fieldDetailRow(_ field: FieldEditState, at index: Int, isEditable: Bool) -> some View { - let kind = FieldEditorResolver.resolve( - for: field.columnTypeEnum, - isLongText: field.isLongText, - originalValue: field.originalValue - ) + let kind = FieldEditorResolver.resolve(field: field) let isJsonField = kind == .json let isPhpField = kind == .phpSerialized let isStructuredField = isJsonField || isPhpField @@ -337,11 +333,14 @@ struct RightSidebarView: View { originalValue: field.originalValue, hasMultipleValues: field.hasMultipleValues, isReadOnly: !isEditable || isPhpField, - commitBytes: isEditable ? { data in editState.setFieldToBytes(at: index, data: data) } : nil + commitBytes: isEditable ? { data in editState.setFieldToBytes(at: index, data: data) } : nil, + editor: field.editor, + allowsNullAndDefault: !field.isSchemaField, + showsTypeBadge: !field.isSchemaField ), isPendingNull: field.isPendingNull, isPendingDefault: field.isPendingDefault, - isModified: field.hasEdit, + isModified: field.hasEdit || field.hasCommittedEdit, databaseType: databaseType, onSetNull: { editState.setFieldToNull(at: index) }, onSetDefault: { editState.setFieldToDefault(at: index) }, diff --git a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index b6ae4eb18..a2ed33b52 100644 --- a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -4,19 +4,26 @@ import Foundation -internal enum FieldEditorKind: Equatable { - case json - case phpSerialized - case blobHex - case boolean - case enumPicker(values: [String]) - case setPicker(values: [String]) - case multiLine - case singleLine -} - @MainActor internal enum FieldEditorResolver { + static func resolve(field: FieldEditState) -> FieldEditorKind { + if let editor = field.editor { return editor } + return resolve( + for: field.columnTypeEnum, + isLongText: field.isLongText, + originalValue: field.originalValue + ) + } + + static func resolve(context: FieldEditorContext) -> FieldEditorKind { + if let editor = context.editor { return editor } + return resolve( + for: context.columnType, + isLongText: context.isLongText, + originalValue: context.originalValue + ) + } + static func resolve( for type: ColumnType, isLongText: Bool, diff --git a/TablePro/Views/Structure/CreateTableGridDelegate+Inspector.swift b/TablePro/Views/Structure/CreateTableGridDelegate+Inspector.swift new file mode 100644 index 000000000..206163f02 --- /dev/null +++ b/TablePro/Views/Structure/CreateTableGridDelegate+Inspector.swift @@ -0,0 +1,30 @@ +// +// CreateTableGridDelegate+Inspector.swift +// TablePro +// +// Supplies the right inspector with the selected new-table row and routes +// its edits back through the same commit path as an inline grid edit. +// + +import Foundation +import TableProPluginKit + +extension CreateTableGridDelegate: InspectorRowSource { + func inspectorRow(atDisplayRow displayRow: Int) -> InspectorRow? { + StructureInspectorRowBuilder.row( + atDisplayRow: displayRow, + tab: structureTab, + provider: StructureRowProvider( + changeManager: structureChangeManager, + tab: structureTab, + databaseType: connection.type, + additionalFields: [.primaryKey] + ), + canEditSchema: true + ) + } + + func commitInspectorField(displayRow: Int, fieldIndex: Int, value: String?) { + dataGridDidEditCell(row: displayRow, column: fieldIndex, newValue: value) + } +} diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index e505415f1..c0fada3fe 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -81,6 +81,8 @@ struct CreateTableView: View { } .navigationTitle(String(localized: "Create Table")) .onAppear { + selectionState.indices = [] + coordinator?.inspectorRowSource = gridDelegate gridDelegate.onSelectedRowsChanged = { self.selectedRows = $0 } updateGridDelegate() if structureChangeManager.workingColumns.isEmpty { @@ -94,6 +96,9 @@ struct CreateTableView: View { selectionState.indices = [] coordinator?.createTableActions = nil coordinator?.toolbarState.hasCreateTablePending = false + if coordinator?.inspectorRowSource === gridDelegate { + coordinator?.inspectorRowSource = nil + } } .onChange(of: selectedRows) { _, newRows in selectionState.indices = newRows } .onChange(of: selectedTab) { updateGridDelegate() } @@ -243,6 +248,7 @@ struct CreateTableView: View { ) gridDelegate.structureTab = structureTab gridDelegate.orderedFields = provider.orderedColumnFields + coordinator?.inspectorRowSourceRevision += 1 } private var structureGrid: some View { diff --git a/TablePro/Views/Structure/StructureGridDelegate+Inspector.swift b/TablePro/Views/Structure/StructureGridDelegate+Inspector.swift new file mode 100644 index 000000000..69d4ae1ce --- /dev/null +++ b/TablePro/Views/Structure/StructureGridDelegate+Inspector.swift @@ -0,0 +1,25 @@ +// +// StructureGridDelegate+Inspector.swift +// TablePro +// +// Supplies the right inspector with the selected structure row and routes +// its edits back through the same commit path as an inline grid edit. +// + +import Foundation + +extension StructureGridDelegate: InspectorRowSource { + func inspectorRow(atDisplayRow displayRow: Int) -> InspectorRow? { + guard let provider = currentProvider else { return nil } + return StructureInspectorRowBuilder.row( + atDisplayRow: displayRow, + tab: selectedTab, + provider: provider, + canEditSchema: connection.type.supportsSchemaEditing + ) + } + + func commitInspectorField(displayRow: Int, fieldIndex: Int, value: String?) { + dataGridDidEditCell(row: displayRow, column: fieldIndex, newValue: value) + } +} diff --git a/TablePro/Views/Structure/StructureInspectorRowBuilder.swift b/TablePro/Views/Structure/StructureInspectorRowBuilder.swift new file mode 100644 index 000000000..807e38457 --- /dev/null +++ b/TablePro/Views/Structure/StructureInspectorRowBuilder.swift @@ -0,0 +1,66 @@ +// +// StructureInspectorRowBuilder.swift +// TablePro +// +// Builds the inspector payload for a structure grid row. Field names, values, +// and editors all come from the same provider the grid renders, so the panel +// and the grid can never disagree about what a row holds. +// + +import Foundation + +@MainActor +internal enum StructureInspectorRowBuilder { + static func row( + atDisplayRow displayRow: Int, + tab: StructureTab, + provider: StructureRowProvider, + canEditSchema: Bool + ) -> InspectorRow? { + switch tab { + case .columns, .indexes, .foreignKeys: + break + case .ddl, .parts, .triggers: + return nil + } + + guard let values = provider.row(at: displayRow) else { return nil } + + let names = provider.columns + let dropdownOptions = provider.customDropdownOptions + let typePickerColumns = provider.typePickerColumns + let modified = provider.modifiedFieldIndices(atDisplay: displayRow) + + let fields = names.indices.map { index -> InspectorRowField in + InspectorRowField( + name: names[index], + value: index < values.count ? values[index] : nil, + editor: editor( + at: index, + dropdownOptions: dropdownOptions, + typePickerColumns: typePickerColumns + ), + isModified: modified.contains(index) + ) + } + + return InspectorRow( + fields: fields, + isEditable: canEditSchema && !provider.isPendingDelete(atDisplay: displayRow) + ) + } + + private static func editor( + at index: Int, + dropdownOptions: [Int: [String]], + typePickerColumns: Set + ) -> FieldEditorKind { + if let options = dropdownOptions[index], !options.isEmpty { + return .enumPicker(values: options) + } + if typePickerColumns.contains(index) { + return .typePicker + } + return .schemaText + } +} diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift index cf0bf1967..6eb2d59c7 100644 --- a/TablePro/Views/Structure/StructureRowProvider.swift +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -166,6 +166,51 @@ final class StructureRowProvider { return cachedRows[index].row } + func sourceIndex(atDisplay displayRow: Int) -> Int? { + guard displayRow >= 0, displayRow < cachedRows.count else { return nil } + return cachedRows[displayRow].sourceIndex + } + + /// Fields whose working value differs from the value the schema was loaded with. + /// An entity that does not exist on the server yet has nothing to compare against, + /// so it reports no modified fields and the grid's inserted-row tint carries that state. + func modifiedFieldIndices(atDisplay displayRow: Int) -> Set { + guard let sourceIndex = sourceIndex(atDisplay: displayRow), + let working = row(at: displayRow), + let original = originalRow(atSource: sourceIndex) else { return [] } + return Set(working.indices.filter { index in + let originalValue = index < original.count ? original[index] : nil + return working[index] != originalValue + }) + } + + func isPendingDelete(atDisplay displayRow: Int) -> Bool { + guard let sourceIndex = sourceIndex(atDisplay: displayRow) else { return false } + return changeManager.deleteInsertState(for: sourceIndex, tab: tab).isDeleted + } + + private func originalRow(atSource sourceIndex: Int) -> [String?]? { + switch tab { + case .columns: + guard sourceIndex < changeManager.workingColumns.count else { return nil } + let id = changeManager.workingColumns[sourceIndex].id + guard let original = changeManager.currentColumns.first(where: { $0.id == id }) else { return nil } + return Self.row(for: original, orderedColumnFields: orderedColumnFields) + case .indexes: + guard sourceIndex < changeManager.workingIndexes.count else { return nil } + let id = changeManager.workingIndexes[sourceIndex].id + guard let original = changeManager.currentIndexes.first(where: { $0.id == id }) else { return nil } + return Self.row(for: original) + case .foreignKeys: + guard sourceIndex < changeManager.workingForeignKeys.count else { return nil } + let id = changeManager.workingForeignKeys[sourceIndex].id + guard let original = changeManager.currentForeignKeys.first(where: { $0.id == id }) else { return nil } + return Self.row(for: original) + case .ddl, .parts, .triggers: + return nil + } + } + // MARK: - Private Helpers private struct IndexedRow { @@ -181,54 +226,68 @@ final class StructureRowProvider { switch tab { case .columns: return changeManager.workingColumns.enumerated().map { index, column in - let row = orderedColumnFields.map { field -> String? in - switch field { - case .name: column.name - case .type: column.dataType - case .nullable: column.isNullable ? "YES" : "NO" - case .defaultValue: column.defaultValue ?? "" - case .primaryKey: column.isPrimaryKey ? "YES" : "NO" - case .autoIncrement: column.autoIncrement ? "YES" : "NO" - case .comment: column.comment ?? "" - case .charset: column.charset ?? "" - case .collation: column.collation ?? "" - } - } - return IndexedRow(sourceIndex: index, row: row) + IndexedRow(sourceIndex: index, row: row(for: column, orderedColumnFields: orderedColumnFields)) } case .indexes: return changeManager.workingIndexes.enumerated().map { index, indexInfo in - let columnsStr = indexInfo.columns.map { col in - if let prefix = indexInfo.columnPrefixes[col] { - return "\(col)(\(prefix))" - } - return col - }.joined(separator: ", ") - return IndexedRow(sourceIndex: index, row: [ - indexInfo.name, - columnsStr, - indexInfo.type.rawValue, - indexInfo.isUnique ? "YES" : "NO", - indexInfo.whereClause ?? "" - ]) + IndexedRow(sourceIndex: index, row: row(for: indexInfo)) } case .foreignKeys: return changeManager.workingForeignKeys.enumerated().map { index, fk in - IndexedRow(sourceIndex: index, row: [ - fk.name, - fk.columns.joined(separator: ", "), - fk.referencedTable, - fk.referencedColumns.joined(separator: ", "), - fk.referencedSchema ?? "", - fk.onDelete.rawValue, - fk.onUpdate.rawValue - ]) + IndexedRow(sourceIndex: index, row: row(for: fk)) } case .ddl, .parts, .triggers: return [] } } + private static func row( + for column: EditableColumnDefinition, + orderedColumnFields: [StructureColumnField] + ) -> [String?] { + orderedColumnFields.map { field -> String? in + switch field { + case .name: column.name + case .type: column.dataType + case .nullable: column.isNullable ? "YES" : "NO" + case .defaultValue: column.defaultValue ?? "" + case .primaryKey: column.isPrimaryKey ? "YES" : "NO" + case .autoIncrement: column.autoIncrement ? "YES" : "NO" + case .comment: column.comment ?? "" + case .charset: column.charset ?? "" + case .collation: column.collation ?? "" + } + } + } + + private static func row(for index: EditableIndexDefinition) -> [String?] { + let columnsStr = index.columns.map { col in + if let prefix = index.columnPrefixes[col] { + return "\(col)(\(prefix))" + } + return col + }.joined(separator: ", ") + return [ + index.name, + columnsStr, + index.type.rawValue, + index.isUnique ? "YES" : "NO", + index.whereClause ?? "" + ] + } + + private static func row(for fk: EditableForeignKeyDefinition) -> [String?] { + [ + fk.name, + fk.columns.joined(separator: ", "), + fk.referencedTable, + fk.referencedColumns.joined(separator: ", "), + fk.referencedSchema ?? "", + fk.onDelete.rawValue, + fk.onUpdate.rawValue + ] + } + private static func applyFilterAndSort( _ rows: [IndexedRow], filterText: String?, diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 14f69fd01..cbb4718a6 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -104,6 +104,9 @@ struct TableStructureView: View { .onAppear { coordinator?.toolbarState.hasStructureChanges = structureChangeManager.hasChanges + selectionState.indices = [] + coordinator?.inspectorRowSource = gridDelegate + gridDelegate.onSelectedRowsChanged = { self.selectedRows = $0 } gridDelegate.coordinator = coordinator gridDelegate.sortHandler = { [self] column, ascending in @@ -135,6 +138,9 @@ struct TableStructureView: View { coordinator?.toolbarState.hasStructureChanges = false coordinator?.structureActions = nil coordinator?.structureFooterState.deactivate(owner: footerOwnerId) + if coordinator?.inspectorRowSource === gridDelegate { + coordinator?.inspectorRowSource = nil + } selectionState.indices = [] } .onChange(of: structureChangeManager.hasChanges) { _, newValue in @@ -343,6 +349,7 @@ struct TableStructureView: View { gridDelegate.selectedTab = selectedTab gridDelegate.currentProvider = provider gridDelegate.orderedFields = provider.orderedColumnFields + coordinator?.inspectorRowSourceRevision += 1 let moveRowHandler: ((Int, Int) -> Void)? = { guard selectedTab == .columns, diff --git a/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift b/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift index d62ca3b9c..11d3b0b26 100644 --- a/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift +++ b/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift @@ -103,6 +103,58 @@ struct RowDisplayCacheTests { #expect(cache.box(forID: .existing(100))?.values.first == "fresh") } + @Test("Clearing a row drops its formatted values and keeps the box") + func clearValuesDropsFormattedText() throws { + let cache = RowDisplayCache() + let id = RowID.existing(1) + let box = makeBox(["old", "VARCHAR(255)", "YES"]) + cache.setBox(box, forID: id, cost: cost(of: ["old", "VARCHAR(255)", "YES"])) + + cache.clearValues(forID: id) + + let cleared = try #require(cache.box(forID: id)) + #expect(cleared === box) + #expect(cleared.values.count == 3) + #expect(cleared.values.allSatisfy { $0 == nil }) + } + + @Test("Clearing one row leaves the others formatted") + func clearValuesLeavesOtherRows() { + let cache = RowDisplayCache() + cache.setBox(makeBox(["a"]), forID: .existing(0), cost: 1) + cache.setBox(makeBox(["b"]), forID: .existing(1), cost: 1) + + cache.clearValues(forID: .existing(1)) + + #expect(cache.box(forID: .existing(0))?.values.first == "a") + #expect(cache.box(forID: .existing(1))?.values.first == nil) + } + + @Test("Clearing an uncached row does nothing") + func clearValuesForUnknownRow() { + let cache = RowDisplayCache() + cache.setBox(makeBox(["a"]), forID: .existing(0), cost: 1) + + cache.clearValues(forID: .existing(9)) + + #expect(cache.box(forID: .existing(9)) == nil) + #expect(cache.box(forID: .existing(0))?.values.first == "a") + } + + @Test("A cleared row accepts fresh values and keeps serving them") + func clearedRowRefills() { + let cache = RowDisplayCache() + let id = RowID.existing(2) + let box = makeBox(["old"]) + cache.setBox(box, forID: id, cost: 3) + cache.clearValues(forID: id) + + box.values[0] = "new" + cache.setBox(box, forID: id, cost: 3) + + #expect(cache.box(forID: id)?.values.first == "new") + } + @Test("Inserted row IDs of both kinds round-trip") func mixedRowIDKinds() { let cache = RowDisplayCache() diff --git a/TableProTests/Models/MultiRowEditStateTests.swift b/TableProTests/Models/MultiRowEditStateTests.swift index eb022728e..6f387aa4d 100644 --- a/TableProTests/Models/MultiRowEditStateTests.swift +++ b/TableProTests/Models/MultiRowEditStateTests.swift @@ -1007,4 +1007,85 @@ struct MultiRowEditStateTests { } } } + + @Suite("Schema fields") + struct SchemaFields { + private func schemaFields() -> [InspectorRowField] { + [ + InspectorRowField(name: "Name", value: "email", editor: .schemaText), + InspectorRowField(name: "Type", value: "VARCHAR(255)", editor: .typePicker), + InspectorRowField(name: "Nullable", value: "YES", editor: .enumPicker(values: ["YES", "NO"]), + isModified: true) + ] + } + + @Test("Schema fields keep their editor, values, and committed-edit marker") + func configureCarriesFieldDescription() { + let sut = MultiRowEditState() + sut.configure(schemaFields: schemaFields(), displayRow: 2) + + #expect(sut.fields.map(\.columnName) == ["Name", "Type", "Nullable"]) + #expect(sut.fields.map(\.originalValue) == ["email", "VARCHAR(255)", "YES"]) + #expect(sut.fields[1].editor == .typePicker) + #expect(sut.fields.allSatisfy(\.isSchemaField)) + #expect(sut.fields[2].hasCommittedEdit) + #expect(sut.fields[0].hasCommittedEdit == false) + #expect(sut.selectedRowIndices == [2]) + } + + @Test("A committed schema edit is not a pending sidebar edit") + func schemaFieldsAreNotPendingEdits() { + let sut = MultiRowEditState() + sut.configure(schemaFields: schemaFields(), displayRow: 0) + + #expect(sut.hasEdits == false) + #expect(sut.getEditedFields().isEmpty) + } + + @Test("Reconfiguring the same row keeps field identity so editors keep focus") + func sameRowKeepsFieldIdentity() { + let sut = MultiRowEditState() + sut.configure(schemaFields: schemaFields(), displayRow: 1) + let ids = sut.fields.map(\.id) + + sut.configure(schemaFields: schemaFields(), displayRow: 1) + #expect(sut.fields.map(\.id) == ids) + } + + @Test("Selecting another row gives fresh editors") + func differentRowResetsFieldIdentity() { + let sut = MultiRowEditState() + sut.configure(schemaFields: schemaFields(), displayRow: 1) + let ids = sut.fields.map(\.id) + + sut.configure(schemaFields: schemaFields(), displayRow: 2) + #expect(sut.fields.map(\.id) != ids) + } + + @Test("Editing a schema field reports the new value to the owning grid") + func editReportsNewValue() { + let sut = MultiRowEditState() + sut.configure(schemaFields: schemaFields(), displayRow: 0) + + var committed: [(Int, String?)] = [] + sut.onFieldChanged = { index, value in committed.append((index, value.asText)) } + sut.updateField(at: 0, value: "user_email") + + #expect(committed.count == 1) + #expect(committed.first?.0 == 0) + #expect(committed.first?.1 == "user_email") + } + + @Test("Retyping the existing value reports nothing") + func unchangedValueReportsNothing() { + let sut = MultiRowEditState() + sut.configure(schemaFields: schemaFields(), displayRow: 0) + + var committed = 0 + sut.onFieldChanged = { _, _ in committed += 1 } + sut.updateField(at: 0, value: "email") + + #expect(committed == 0) + } + } } diff --git a/TableProTests/Models/UI/GridSelectionOwnerTests.swift b/TableProTests/Models/UI/GridSelectionOwnerTests.swift new file mode 100644 index 000000000..ee5552f5b --- /dev/null +++ b/TableProTests/Models/UI/GridSelectionOwnerTests.swift @@ -0,0 +1,47 @@ +// +// GridSelectionOwnerTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("GridSelectionOwner") +struct GridSelectionOwnerTests { + @Test("A table tab showing data owns a data selection") + func tableTabWithDataMode() { + #expect(GridSelectionOwner.resolve(tabType: .table, resultsViewMode: .data) == .dataGrid) + } + + @Test("A table tab showing structure owns a schema selection") + func tableTabWithStructureMode() { + #expect(GridSelectionOwner.resolve(tabType: .table, resultsViewMode: .structure) == .schemaGrid) + } + + @Test("JSON view still reads the data rows it renders") + func tableTabWithJsonMode() { + #expect(GridSelectionOwner.resolve(tabType: .table, resultsViewMode: .json) == .dataGrid) + } + + @Test("A query tab's results are data rows") + func queryTab() { + #expect(GridSelectionOwner.resolve(tabType: .query, resultsViewMode: .data) == .dataGrid) + } + + @Test("The new-table grid owns a schema selection whatever its results mode") + func createTableTab() { + #expect(GridSelectionOwner.resolve(tabType: .createTable, resultsViewMode: .data) == .schemaGrid) + #expect(GridSelectionOwner.resolve(tabType: .createTable, resultsViewMode: .structure) == .schemaGrid) + } + + @Test("Tabs with no row grid own no selection", arguments: [TabType.erDiagram, .serverDashboard, .usersRoles]) + func tabsWithoutRows(tabType: TabType) { + #expect(GridSelectionOwner.resolve(tabType: tabType, resultsViewMode: .data) == .none) + } + + @Test("No tab means nothing to inspect") + func noTab() { + #expect(GridSelectionOwner.resolve(tabType: nil, resultsViewMode: nil) == .none) + } +} diff --git a/TableProTests/Views/Structure/StructureGridDelegateInspectorTests.swift b/TableProTests/Views/Structure/StructureGridDelegateInspectorTests.swift new file mode 100644 index 000000000..74b1218cd --- /dev/null +++ b/TableProTests/Views/Structure/StructureGridDelegateInspectorTests.swift @@ -0,0 +1,158 @@ +// +// StructureGridDelegateInspectorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor @Suite("Structure grid delegates as inspector row sources") +struct StructureGridDelegateInspectorTests { + private func connection() -> DatabaseConnection { + DatabaseConnection( + name: "Test", + host: "localhost", + port: 3_306, + database: "test", + username: "root", + type: .mysql + ) + } + + private func loadedManager() -> StructureChangeManager { + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "users", + columns: [ + ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "email", dataType: "VARCHAR(255)", isNullable: true, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil) + ], + indexes: [], + foreignKeys: [], + primaryKey: ["id"] + ) + return manager + } + + private func makeDelegate( + manager: StructureChangeManager, + filterText: String? = nil + ) -> StructureGridDelegate { + let delegate = StructureGridDelegate( + structureChangeManager: manager, + selectedTab: .columns, + connection: connection(), + tableName: "users", + coordinator: nil + ) + let provider = StructureRowProvider( + changeManager: manager, + tab: .columns, + databaseType: .mysql, + additionalFields: [.primaryKey], + filterText: filterText + ) + delegate.currentProvider = provider + delegate.orderedFields = provider.orderedColumnFields + return delegate + } + + private func fieldIndex(_ delegate: StructureGridDelegate, _ field: StructureColumnField) throws -> Int { + try #require(delegate.orderedFields.firstIndex(of: field)) + } + + @Test("The published row describes the structure grid, not the data grid") + func publishedRowDescribesStructure() throws { + let delegate = makeDelegate(manager: loadedManager()) + let row = try #require(delegate.inspectorRow(atDisplayRow: 1)) + + #expect(row.fields.map(\.name) == delegate.orderedFields.map(\.displayName)) + #expect(row.fields.first?.value == "email") + } + + @Test("An inspector edit lands on the column the display row points at") + func editResolvesFilteredDisplayRow() throws { + let manager = loadedManager() + let delegate = makeDelegate(manager: manager, filterText: "email") + + delegate.commitInspectorField( + displayRow: 0, + fieldIndex: try fieldIndex(delegate, .name), + value: "user_email" + ) + + #expect(manager.workingColumns[0].name == "id") + #expect(manager.workingColumns[1].name == "user_email") + } + + @Test("An inspector edit records a pending schema change") + func editRecordsPendingChange() throws { + let manager = loadedManager() + let delegate = makeDelegate(manager: manager) + + delegate.commitInspectorField( + displayRow: 1, + fieldIndex: try fieldIndex(delegate, .type), + value: "TEXT" + ) + + #expect(manager.hasChanges) + #expect(manager.workingColumns[1].dataType == "TEXT") + } + + @Test("A flag field commits the value the dropdown offers") + func flagEditCommitsBooleanValue() throws { + let manager = loadedManager() + let delegate = makeDelegate(manager: manager) + + delegate.commitInspectorField( + displayRow: 1, + fieldIndex: try fieldIndex(delegate, .nullable), + value: "NO" + ) + + #expect(manager.workingColumns[1].isNullable == false) + } + + @Test("Without a provider the delegate publishes nothing") + func withoutProviderPublishesNothing() { + let delegate = StructureGridDelegate( + structureChangeManager: loadedManager(), + selectedTab: .columns, + connection: connection(), + tableName: "users", + coordinator: nil + ) + + #expect(delegate.inspectorRow(atDisplayRow: 0) == nil) + } + + @Test("The new-table grid publishes its own rows and takes edits") + func createTableDelegatePublishesRows() throws { + let manager = StructureChangeManager() + manager.addNewColumn() + let delegate = CreateTableGridDelegate( + structureChangeManager: manager, + structureTab: .columns, + connection: connection() + ) + let provider = StructureRowProvider( + changeManager: manager, + tab: .columns, + databaseType: .mysql, + additionalFields: [.primaryKey] + ) + delegate.orderedFields = provider.orderedColumnFields + + let row = try #require(delegate.inspectorRow(atDisplayRow: 0)) + #expect(row.isEditable) + + let nameIndex = try #require(delegate.orderedFields.firstIndex(of: .name)) + delegate.commitInspectorField(displayRow: 0, fieldIndex: nameIndex, value: "sku") + #expect(manager.workingColumns[0].name == "sku") + } +} diff --git a/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift b/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift new file mode 100644 index 000000000..c6bae8893 --- /dev/null +++ b/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift @@ -0,0 +1,181 @@ +// +// StructureInspectorRowBuilderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor @Suite("StructureInspectorRowBuilder") +struct StructureInspectorRowBuilderTests { + private func loadedManager() -> StructureChangeManager { + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "users", + columns: [ + ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "email", dataType: "VARCHAR(255)", isNullable: true, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil) + ], + indexes: [], + foreignKeys: [], + primaryKey: ["id"] + ) + return manager + } + + private func provider( + _ manager: StructureChangeManager, + tab: StructureTab = .columns, + filterText: String? = nil, + sortDescriptor: StructureSortDescriptor? = nil + ) -> StructureRowProvider { + StructureRowProvider( + changeManager: manager, + tab: tab, + databaseType: .mysql, + additionalFields: [.primaryKey], + filterText: filterText, + sortDescriptor: sortDescriptor + ) + } + + private func value(_ row: InspectorRow, named name: String) -> String? { + row.fields.first { $0.name == name }?.value + } + + @Test("Fields carry the structure row's own names and values") + func fieldsComeFromTheStructureRow() throws { + let manager = loadedManager() + let row = try #require(StructureInspectorRowBuilder.row( + atDisplayRow: 1, + tab: .columns, + provider: provider(manager), + canEditSchema: true + )) + + #expect(value(row, named: String(localized: "Name")) == "email") + #expect(value(row, named: String(localized: "Type")) == "VARCHAR(255)") + #expect(value(row, named: String(localized: "Nullable")) == "YES") + #expect(row.isEditable) + } + + @Test("A filtered grid resolves the display row to the right column") + func filteredDisplayRowResolvesToSourceEntity() throws { + let manager = loadedManager() + let row = try #require(StructureInspectorRowBuilder.row( + atDisplayRow: 0, + tab: .columns, + provider: provider(manager, filterText: "email"), + canEditSchema: true + )) + + #expect(value(row, named: String(localized: "Name")) == "email") + } + + @Test("A sorted grid resolves the display row to the right column") + func sortedDisplayRowResolvesToSourceEntity() throws { + let manager = loadedManager() + let row = try #require(StructureInspectorRowBuilder.row( + atDisplayRow: 0, + tab: .columns, + provider: provider(manager, sortDescriptor: StructureSortDescriptor(column: 0, ascending: false)), + canEditSchema: true + )) + + #expect(value(row, named: String(localized: "Name")) == "id") + } + + @Test("Flag fields offer the same YES and NO list the grid offers") + func flagFieldsUseDropdownEditors() throws { + let manager = loadedManager() + let row = try #require(StructureInspectorRowBuilder.row( + atDisplayRow: 0, + tab: .columns, + provider: provider(manager), + canEditSchema: true + )) + + let nullable = try #require(row.fields.first { $0.name == String(localized: "Nullable") }) + #expect(nullable.editor == .enumPicker(values: StructureRowProvider.booleanOptions)) + + let type = try #require(row.fields.first { $0.name == String(localized: "Type") }) + #expect(type.editor == .typePicker) + + let name = try #require(row.fields.first { $0.name == String(localized: "Name") }) + #expect(name.editor == .schemaText) + } + + @Test("An edited field is reported as modified") + func editedFieldIsMarkedModified() throws { + let manager = loadedManager() + var column = manager.workingColumns[1] + column.dataType = "TEXT" + manager.updateColumn(id: column.id, with: column) + + let row = try #require(StructureInspectorRowBuilder.row( + atDisplayRow: 1, + tab: .columns, + provider: provider(manager), + canEditSchema: true + )) + + let type = try #require(row.fields.first { $0.name == String(localized: "Type") }) + #expect(type.isModified) + let name = try #require(row.fields.first { $0.name == String(localized: "Name") }) + #expect(!name.isModified) + } + + @Test("A row pending deletion is read-only") + func pendingDeleteRowIsReadOnly() throws { + let manager = loadedManager() + manager.deleteColumn(id: manager.workingColumns[1].id) + + let row = try #require(StructureInspectorRowBuilder.row( + atDisplayRow: 1, + tab: .columns, + provider: provider(manager), + canEditSchema: true + )) + + #expect(!row.isEditable) + } + + @Test("An engine without schema editing gets a read-only row") + func withoutSchemaEditingRowIsReadOnly() throws { + let manager = loadedManager() + let row = try #require(StructureInspectorRowBuilder.row( + atDisplayRow: 0, + tab: .columns, + provider: provider(manager), + canEditSchema: false + )) + + #expect(!row.isEditable) + } + + @Test("Tabs without a row grid supply nothing", arguments: [StructureTab.ddl, .parts, .triggers]) + func nonGridTabsSupplyNothing(tab: StructureTab) { + let manager = loadedManager() + #expect(StructureInspectorRowBuilder.row( + atDisplayRow: 0, + tab: tab, + provider: provider(manager, tab: tab), + canEditSchema: true + ) == nil) + } + + @Test("A display row past the end supplies nothing") + func outOfRangeDisplayRowSuppliesNothing() { + let manager = loadedManager() + #expect(StructureInspectorRowBuilder.row( + atDisplayRow: 9, + tab: .columns, + provider: provider(manager), + canEditSchema: true + ) == nil) + } +} diff --git a/TableProTests/Views/Structure/StructureRowProviderTests.swift b/TableProTests/Views/Structure/StructureRowProviderTests.swift index da33e8576..894566ed3 100644 --- a/TableProTests/Views/Structure/StructureRowProviderTests.swift +++ b/TableProTests/Views/Structure/StructureRowProviderTests.swift @@ -103,4 +103,99 @@ struct StructureRowProviderTests { #expect(names(provider) == ["alpha", "alpine"]) #expect(provider.filteredToSourceMap == [0, 3]) } + + @Test("A display row maps to the source entity behind it") + func sourceIndexFollowsDisplayOrder() { + let provider = makeProvider( + columnNames: ["alpha", "beta", "gamma", "alpine"], + filterText: "al", + sortDescriptor: StructureSortDescriptor(column: 0, ascending: false) + ) + #expect(provider.sourceIndex(atDisplay: 0) == 3) + #expect(provider.sourceIndex(atDisplay: 1) == 0) + #expect(provider.sourceIndex(atDisplay: 2) == nil) + #expect(provider.sourceIndex(atDisplay: -1) == nil) + } +} + +@MainActor @Suite("StructureRowProvider modified and deleted state") +struct StructureRowProviderChangeStateTests { + private func loadedManager() -> StructureChangeManager { + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "users", + columns: [ + ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "email", dataType: "VARCHAR(255)", isNullable: true, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil) + ], + indexes: [IndexInfo(name: "idx_email", columns: ["email"], isUnique: false, + isPrimary: false, type: "BTREE")], + foreignKeys: [], + primaryKey: ["id"] + ) + return manager + } + + private func provider( + _ manager: StructureChangeManager, + tab: StructureTab = .columns, + filterText: String? = nil + ) -> StructureRowProvider { + StructureRowProvider( + changeManager: manager, + tab: tab, + databaseType: .mysql, + additionalFields: [.primaryKey], + filterText: filterText + ) + } + + @Test("An untouched column reports no modified fields") + func untouchedColumnHasNoModifiedFields() { + let manager = loadedManager() + #expect(provider(manager).modifiedFieldIndices(atDisplay: 0).isEmpty) + } + + @Test("Only the edited field is reported, resolved through the display map") + func editedFieldIsReportedThroughFilter() throws { + let manager = loadedManager() + var column = manager.workingColumns[1] + column.dataType = "TEXT" + manager.updateColumn(id: column.id, with: column) + + let filtered = provider(manager, filterText: "email") + let typeIndex = try #require(filtered.orderedColumnFields.firstIndex(of: .type)) + #expect(filtered.modifiedFieldIndices(atDisplay: 0) == [typeIndex]) + } + + @Test("An index's edited field is reported") + func editedIndexFieldIsReported() { + let manager = loadedManager() + var index = manager.workingIndexes[0] + index.isUnique = true + manager.updateIndex(id: index.id, with: index) + + #expect(provider(manager, tab: .indexes).modifiedFieldIndices(atDisplay: 0) == [3]) + } + + @Test("A column that does not exist on the server yet reports no modified fields") + func newColumnReportsNoModifiedFields() { + let manager = loadedManager() + manager.addNewColumn() + + let sut = provider(manager) + #expect(sut.modifiedFieldIndices(atDisplay: 2).isEmpty) + } + + @Test("A column pending deletion is reported as deleted") + func pendingDeleteIsReported() { + let manager = loadedManager() + manager.deleteColumn(id: manager.workingColumns[1].id) + + let sut = provider(manager) + #expect(sut.isPendingDelete(atDisplay: 1)) + #expect(!sut.isPendingDelete(atDisplay: 0)) + } } diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index f6e2a6476..9265f40a9 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -113,6 +113,8 @@ Toggle the inspector with `Cmd+Option+I`. With a row selected, it lists every co With no row selected, it shows table statistics: data, index, and total size, row count, average row size, engine, collation, and created and updated dates (fields vary by database). +In the Structure tab, the inspector follows the structure grid instead: select a column, index, or foreign key and it lists that row's own fields. The fields are editable where the grid is, with the same type list and YES/NO menus, and an edit joins the pending changes you apply with Save. A row you have marked for deletion is read-only. + Row inspector Row inspector