From 498c1a356060d04e90d85e6774d3c0bfee5152be Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 16:06:47 +0700 Subject: [PATCH 1/9] feat(structure): rebuild the Structure tab as one Definition page with an inspector --- .claude/rules/data-grid.md | 1 + apple/.swiftlint.yml | 2 +- .../Core/DataGrid/DataGridModel.swift | 7 + .../TablePro/Core/Menu/FileMenuBuilder.swift | 4 + .../CreateTableStatementComposer.swift | 35 +- .../SchemaStatementGenerator.swift | 2 +- .../StructureChangeManager.swift | 162 +++-- ...nSplitViewController+FileMenuActions.swift | 4 + ...inSplitViewController+MenuValidation.swift | 14 +- .../Connection/ConnectionToolbarState.swift | 2 - .../Models/Query/ResultStatusModel.swift | 4 - .../Models/Query/StatusBarSnapshot.swift | 6 - .../Schema/CreateTableDraftBuilder.swift | 171 +++-- .../TablePro/Models/Schema/SchemaChange.swift | 51 ++ .../Models/Schema/SchemaDraftIssue.swift | 23 +- .../Schema/SchemaEntityOperations.swift | 11 +- ...uctureTab.swift => StructureSection.swift} | 25 +- .../Models/UI/DataGridConfiguration.swift | 6 + .../TablePro/Models/UI/FieldEditorKind.swift | 5 - .../Models/UI/InspectorFieldLayout.swift | 63 -- .../Models/UI/InspectorRowSource.swift | 37 -- .../TablePro/Models/UI/InspectorSubject.swift | 56 +- .../Models/UI/MultiRowEditState.swift | 66 +- .../Models/UI/PendingChangeKind.swift | 38 +- .../Models/UI/RowInspectorContext.swift | 51 +- .../TablePro/Resources/Localizable.xcstrings | 205 ++++++ .../DataFiles/DataFileRowDetailsView.swift | 1 - .../DataGrid/Cells/DataGridCellContent.swift | 5 +- .../Cells/DataGridCellPresenter.swift | 8 +- .../DataGrid/Cells/DataGridMetrics.swift | 10 + .../DataGrid/DataGridContainerView.swift | 31 +- .../Views/DataGrid/DataGridScrollView.swift | 57 +- .../Views/DataGrid/DataGridTableView.swift | 17 +- .../Views/DataGrid/DataGridView.swift | 7 +- .../DataGridViewController+Changes.swift | 29 + .../DataGridViewController+Selection.swift | 53 +- .../DataGrid/DataGridViewController.swift | 12 + .../Main/Child/MainEditorContentView.swift | 29 +- .../MainContentCoordinator+Protection.swift | 79 +-- .../MainContentCoordinator+Refresh.swift | 2 +- .../MainContentCoordinator+SQLPreview.swift | 14 +- .../MainContentCoordinator+Structure.swift | 25 + .../MainContentCoordinator+TabClosing.swift | 2 - ...nContentCoordinator+TableRowsRefresh.swift | 4 +- .../Extensions/MainContentView+Bindings.swift | 42 +- .../MainContentView+EventHandlers.swift | 38 +- .../Extensions/MainContentView+Helpers.swift | 16 +- .../Extensions/MainContentView+Setup.swift | 25 +- .../MainContentCommandActions+Structure.swift | 117 ++++ .../MainContentCommandActions+UndoState.swift | 78 ++- .../Main/MainContentCommandActions.swift | 90 ++- .../Views/Main/MainContentCoordinator.swift | 8 - .../TablePro/Views/Main/MainContentView.swift | 50 +- .../Views/Results/ResultStatusBar.swift | 17 - .../FieldEditors/FieldEditorContent.swift | 17 +- .../FieldEditors/FieldEditorContext.swift | 12 +- .../FieldEditors/SchemaTextFieldView.swift | 49 -- .../FieldEditors/TypePickerFieldView.swift | 44 -- .../FieldEditors/ValuePickerFieldView.swift | 101 --- .../RowInspector/InspectorFieldListView.swift | 18 +- .../RowInspector/InspectorFieldRow.swift | 64 +- .../Views/RowInspector/RowInspectorView.swift | 69 +- .../Views/RowInspector/TableInfoView.swift | 120 ++-- .../FieldEditors/FieldEditorResolver.swift | 1 - .../Views/Structure/ClickHousePartsView.swift | 2 +- .../Structure/CreateTableActionHandler.swift | 13 - .../Views/Structure/CreateTableDraft.swift | 450 +++++++++++-- .../Structure/CreateTableFormEditor.swift | 90 --- .../Structure/CreateTableGridAdapter.swift | 327 ---------- .../Views/Structure/CreateTableView.swift | 610 ++++-------------- .../Inspector/CreateTablePropertiesView.swift | 117 ++++ .../StructureDefinitionFormView.swift | 151 +++++ .../StructureInspectorControls.swift | 234 +++++++ .../Inspector/StructureInspectorForm.swift | 116 ++++ .../StructureInspectorPresentation.swift | 121 ++++ .../Inspector/StructureInspectorView.swift | 90 +++ .../StructureTablePropertiesView.swift | 439 +++++++++++++ .../Views/Structure/StructureDDLPane.swift | 133 ++++ .../Structure/StructureDefinitionEditor.swift | 329 ++++++++++ .../Structure/StructureDefinitionPage.swift | 242 +++++++ .../Views/Structure/StructureEditGate.swift | 9 +- .../Structure/StructureEditingContext.swift | 97 +++ .../StructureEditingSession+Apply.swift | 1 + .../StructureEditingSession+Baseline.swift | 44 -- ...ructureEditingSession+ColumnReorder.swift} | 15 +- .../StructureEditingSession+Commands.swift | 133 ++++ .../StructureEditingSession+Loading.swift | 290 +++++++++ .../Structure/StructureEditingSession.swift | 155 ++--- .../Structure/StructureFooterPolicy.swift | 99 +-- .../StructureGridAdapter+Inspector.swift | 28 - .../Structure/StructureGridAdapter.swift | 546 ---------------- .../Structure/StructureGridStateStore.swift | 67 +- .../Structure/StructureGridSupport.swift | 109 ++-- .../StructureInspectorRowBuilder.swift | 75 --- .../Views/Structure/StructureLoadState.swift | 64 ++ .../Structure/StructureRowProvider.swift | 72 +-- .../Views/Structure/StructureSavePlan.swift | 4 +- ...ft => StructureSectionAdapter+Menus.swift} | 136 ++-- .../Structure/StructureSectionAdapter.swift | 514 +++++++++++++++ .../Structure/StructureServerSupport.swift | 4 +- .../Structure/StructureTabAvailability.swift | 43 -- .../Structure/StructureTabDataState.swift | 41 -- .../StructureTableRebuildHandler.swift | 2 +- .../StructureViewActionHandler.swift | 23 - .../Views/Structure/TableOptionsView.swift | 281 -------- .../TableStructureView+DataLoading.swift | 259 -------- .../TableStructureView+EditGate.swift | 38 -- .../Structure/TableStructureView+Schema.swift | 187 ------ .../TableStructureView+TableComment.swift | 85 --- .../TableStructureView+TableOptions.swift | 46 -- .../Views/Structure/TableStructureView.swift | 452 +++---------- .../Views/Structure/TriggerDetailView.swift | 16 +- .../Database/TriggerInfoMappingTests.swift | 26 +- .../CreateTableCommentCompositionTests.swift | 2 +- .../IndexDecliningDriverTests.swift | 2 +- .../SchemaOperationRefusalTests.swift | 4 +- .../StructureChangeManagerSaveHoldTests.swift | 11 +- .../StructureChangeManagerStagingTests.swift | 239 +++++++ ...uctureChangeManagerTableCommentTests.swift | 41 +- ...uctureChangeManagerTableOptionsTests.swift | 12 - .../StructureChangeManagerUndoTests.swift | 38 +- .../StructureChangeNameReuseTests.swift | 52 +- ...ctureEditingSessionTableOptionsTests.swift | 8 - .../TableCommentStatementTests.swift | 2 +- .../TableProTests/Helpers/TestFixtures.swift | 9 + .../Models/InspectorFieldLayoutTests.swift | 126 ---- .../Models/InspectorSubjectTests.swift | 39 +- .../Models/MultiRowEditStateTests.swift | 98 --- .../Models/PendingChangeKindTests.swift | 13 + .../Models/ResultStatusModelTests.swift | 15 - .../Schema/CreateTableDraftBuilderTests.swift | 68 +- .../Schema/CreateTableFormStateTests.swift | 34 +- .../DataGrid/DataGridFittingHeightTests.swift | 336 ++++++++++ .../Main/CommandActionsDispatchTests.swift | 158 ++--- .../MainContentCoordinatorRefreshTests.swift | 19 +- .../Main/ResultStatusBarLayoutTests.swift | 28 +- .../Main/StructureActionHandlerTests.swift | 220 ------- .../Main/StructureCommandRoutingTests.swift | 187 ++++++ .../Views/Main/TabCloseProtectionTests.swift | 8 +- .../Views/Main/TriggerStructTests.swift | 9 + .../Views/Main/UserTypeTabStateTests.swift | 1 - .../InspectorPopOutWindowTests.swift | 1 - .../InspectorValueThemeTests.swift | 4 +- .../SplitItemGlassBackgroundTests.swift | 2 +- .../CreateTableDraftCommandTests.swift | 145 +++++ .../CreateTableDraftCompositionTests.swift | 152 ++++- .../StructureAvailabilityTests.swift | 136 ++++ .../StructureCodeStandInThemeTests.swift | 2 +- .../StructureDefinitionEditorTests.swift | 230 +++++++ .../Structure/StructureEditGateTests.swift | 1 - .../StructureEditingSessionTests.swift | 358 +++++++--- ...tructureEditingSupportFieldDiffTests.swift | 39 +- .../StructureFooterPolicyTests.swift | 225 ++++--- .../StructureGridAdapterAddRowTests.swift | 204 ------ .../StructureGridAdapterInspectorTests.swift | 278 -------- .../StructureGridStateStoreTests.swift | 204 ++++-- .../StructureGridValueFilterEditTests.swift | 102 +-- .../StructureIndexTypeMenuTests.swift | 72 ++- .../StructureInspectorFormTests.swift | 228 +++++++ .../StructureInspectorPresentationTests.swift | 173 +++++ .../StructureInspectorRowBuilderTests.swift | 182 ------ .../StructureListGridAccessibilityTests.swift | 2 +- .../Structure/StructureLoadStateTests.swift | 148 +++++ .../StructureRowMenuRouteTests.swift | 111 ++-- ...uctureRowProviderBooleanOptionsTests.swift | 8 +- .../Structure/StructureRowProviderTests.swift | 2 +- .../StructureSectionAdapterTests.swift | 285 ++++++++ .../StructureServerSupportTests.swift | 4 +- .../StructureTabAvailabilityTests.swift | 69 -- .../StructureTabDataStateTests.swift | 88 --- .../SidebarAddMenuUITests.swift | 24 +- .../StructureSaveConfirmationUITests.swift | 2 +- .../TableChangeReloadUITests.swift | 2 +- 173 files changed, 8594 insertions(+), 6505 deletions(-) rename apple/TablePro/Models/Schema/{StructureTab.swift => StructureSection.swift} (52%) delete mode 100644 apple/TablePro/Models/UI/InspectorFieldLayout.swift delete mode 100644 apple/TablePro/Models/UI/InspectorRowSource.swift create mode 100644 apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Structure.swift create mode 100644 apple/TablePro/Views/Main/MainContentCommandActions+Structure.swift delete mode 100644 apple/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift delete mode 100644 apple/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift delete mode 100644 apple/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift delete mode 100644 apple/TablePro/Views/Structure/CreateTableActionHandler.swift delete mode 100644 apple/TablePro/Views/Structure/CreateTableFormEditor.swift delete mode 100644 apple/TablePro/Views/Structure/CreateTableGridAdapter.swift create mode 100644 apple/TablePro/Views/Structure/Inspector/CreateTablePropertiesView.swift create mode 100644 apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift create mode 100644 apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift create mode 100644 apple/TablePro/Views/Structure/Inspector/StructureInspectorForm.swift create mode 100644 apple/TablePro/Views/Structure/Inspector/StructureInspectorPresentation.swift create mode 100644 apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift create mode 100644 apple/TablePro/Views/Structure/Inspector/StructureTablePropertiesView.swift create mode 100644 apple/TablePro/Views/Structure/StructureDDLPane.swift create mode 100644 apple/TablePro/Views/Structure/StructureDefinitionEditor.swift create mode 100644 apple/TablePro/Views/Structure/StructureDefinitionPage.swift create mode 100644 apple/TablePro/Views/Structure/StructureEditingContext.swift delete mode 100644 apple/TablePro/Views/Structure/StructureEditingSession+Baseline.swift rename apple/TablePro/Views/Structure/{TableStructureView+ColumnReorder.swift => StructureEditingSession+ColumnReorder.swift} (93%) create mode 100644 apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift create mode 100644 apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift delete mode 100644 apple/TablePro/Views/Structure/StructureGridAdapter+Inspector.swift delete mode 100644 apple/TablePro/Views/Structure/StructureGridAdapter.swift delete mode 100644 apple/TablePro/Views/Structure/StructureInspectorRowBuilder.swift create mode 100644 apple/TablePro/Views/Structure/StructureLoadState.swift rename apple/TablePro/Views/Structure/{StructureGridAdapter+Menus.swift => StructureSectionAdapter+Menus.swift} (69%) create mode 100644 apple/TablePro/Views/Structure/StructureSectionAdapter.swift delete mode 100644 apple/TablePro/Views/Structure/StructureTabAvailability.swift delete mode 100644 apple/TablePro/Views/Structure/StructureTabDataState.swift delete mode 100644 apple/TablePro/Views/Structure/StructureViewActionHandler.swift delete mode 100644 apple/TablePro/Views/Structure/TableOptionsView.swift delete mode 100644 apple/TablePro/Views/Structure/TableStructureView+DataLoading.swift delete mode 100644 apple/TablePro/Views/Structure/TableStructureView+EditGate.swift delete mode 100644 apple/TablePro/Views/Structure/TableStructureView+Schema.swift delete mode 100644 apple/TablePro/Views/Structure/TableStructureView+TableComment.swift delete mode 100644 apple/TablePro/Views/Structure/TableStructureView+TableOptions.swift create mode 100644 apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift delete mode 100644 apple/TableProTests/Models/InspectorFieldLayoutTests.swift create mode 100644 apple/TableProTests/Views/DataGrid/DataGridFittingHeightTests.swift delete mode 100644 apple/TableProTests/Views/Main/StructureActionHandlerTests.swift create mode 100644 apple/TableProTests/Views/Main/StructureCommandRoutingTests.swift create mode 100644 apple/TableProTests/Views/Structure/CreateTableDraftCommandTests.swift create mode 100644 apple/TableProTests/Views/Structure/StructureAvailabilityTests.swift create mode 100644 apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift delete mode 100644 apple/TableProTests/Views/Structure/StructureGridAdapterAddRowTests.swift delete mode 100644 apple/TableProTests/Views/Structure/StructureGridAdapterInspectorTests.swift create mode 100644 apple/TableProTests/Views/Structure/StructureInspectorFormTests.swift create mode 100644 apple/TableProTests/Views/Structure/StructureInspectorPresentationTests.swift delete mode 100644 apple/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift create mode 100644 apple/TableProTests/Views/Structure/StructureLoadStateTests.swift create mode 100644 apple/TableProTests/Views/Structure/StructureSectionAdapterTests.swift delete mode 100644 apple/TableProTests/Views/Structure/StructureTabAvailabilityTests.swift delete mode 100644 apple/TableProTests/Views/Structure/StructureTabDataStateTests.swift diff --git a/.claude/rules/data-grid.md b/.claude/rules/data-grid.md index 272876fd8b..ef60182664 100644 --- a/.claude/rules/data-grid.md +++ b/.claude/rules/data-grid.md @@ -30,6 +30,7 @@ Three layers, knowledge pointing one way: an owner's adapter, then `DataGridMode - **Row height, row count, reloads, inserts and removes reach both panes in one call**, so their document heights never differ. - **Vertical scroll stays in step through bounds-change notifications**: each pane's clip moves the other with `clipView.scroll(to:)` and `reflectScrolledClipView`, only when its y differs. Anywhere else scroll with `NSView.scroll(_:)`: moving a clip's x that way leaves the header behind. - **The frozen pane forwards every wheel event to the main scroll view**, so one momentum engine moves both. +- **A `fitsRows` grid is as tall as its rows and scrolls only sideways.** It publishes `model.fittingHeight` for its host to size it, hands a vertical gesture to the enclosing scroll view (the axis is decided once per gesture), and reveals a row by scrolling that page. The Structure tab's sections use it. - **Frozen columns take at most two thirds of the grid** (`DataGridMetrics.maximumFrozenShare`). Columns past that scroll, and Freeze Up to This Column is refused in the menu. - **`DataGridTableView.scrollToVisible(_:)` widens the rect leftward by the frozen width**, so a revealed column never parks under the frozen pane. - **A column moves only within its side of the freeze line.** A freeze or unfreeze rebuilds both panes' column lists from the set, writing width and position explicitly: remove-then-add alone strands a column at the end and resets its width. diff --git a/apple/.swiftlint.yml b/apple/.swiftlint.yml index c31e1bc9cd..698a5f7bcd 100644 --- a/apple/.swiftlint.yml +++ b/apple/.swiftlint.yml @@ -214,7 +214,7 @@ custom_rules: severity: error appstorage_key_namespace: name: "AppStorage Key Namespace" - regex: '@AppStorage\(\s*"(?!com\.TablePro)(?!(?:hideExportSuccessDialog|skipSchemaPreview|structureCodeFontSize)")[^"\\]+"' + regex: '@AppStorage\(\s*"(?!com\.TablePro)(?!(?:hideExportSuccessDialog|structureCodeFontSize)")[^"\\]+"' excluded: ['.*/(Packages|Plugins)/'] message: "Route an @AppStorage key through the preferences layer, under com.TablePro." severity: error diff --git a/apple/TablePro/Core/DataGrid/DataGridModel.swift b/apple/TablePro/Core/DataGrid/DataGridModel.swift index 9e9cd17f68..5e665060df 100644 --- a/apple/TablePro/Core/DataGrid/DataGridModel.swift +++ b/apple/TablePro/Core/DataGrid/DataGridModel.swift @@ -51,6 +51,8 @@ final class DataGridModel: ObservableObject { @Published private(set) var displayRevision = 0 @Published private(set) var selectionRevision = 0 @Published private(set) var editabilityRevision = 0 + // What a fitsRows grid needs to show every row; the mounted view measures it. + @Published private(set) var fittingHeight: CGFloat = 0 private(set) weak var mountedView: (any DataGridMountedView)? private var orderRevision = 0 @@ -456,6 +458,11 @@ final class DataGridModel: ObservableObject { var isMounted: Bool { mountedView != nil } + func setFittingHeight(_ height: CGFloat) { + guard height != fittingHeight else { return } + fittingHeight = height + } + func revealColumn(_ id: DataGridColumnID) { guard let position = columns.displayPosition(of: id) else { return } mountedView?.revealColumn(position) diff --git a/apple/TablePro/Core/Menu/FileMenuBuilder.swift b/apple/TablePro/Core/Menu/FileMenuBuilder.swift index 7c01c4f0f6..0c01e01829 100644 --- a/apple/TablePro/Core/Menu/FileMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/FileMenuBuilder.swift @@ -74,6 +74,10 @@ enum FileMenuBuilder { shortcut: .saveChanges, keyboard: keyboard ), + MenuItemFactory.item( + String(localized: "Discard Changes…"), + action: #selector(MainSplitViewController.discardChanges(_:)) + ), MenuItemFactory.item( String(localized: "Save As…"), action: #selector(MainSplitViewController.saveDocumentAs(_:)), diff --git a/apple/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift b/apple/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift index 67550010d3..32bbab5f40 100644 --- a/apple/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift +++ b/apple/TablePro/Core/SchemaTracking/CreateTableStatementComposer.swift @@ -9,8 +9,7 @@ import TableProPluginKit struct CreateTableStatements { let statements: [String] let issues: [SchemaDraftIssue] - /// The name the statement actually creates, trimmed. The draft's own field may carry - /// surrounding whitespace, and opening the tab under that text loads a table nobody made. + // Trimmed: opening a tab under the draft's untrimmed text loads a table nobody made. let tableName: String? var preview: String { @@ -18,8 +17,7 @@ struct CreateTableStatements { } } -/// One statement per element, because a driver is only obliged to run one statement per -/// `execute(query:)`: SQLite prepares with a nil tail, so anything after the first semicolon never runs. +// One statement per element: a driver need only run one per execute, and SQLite drops anything after the first. @MainActor enum CreateTableStatementComposer { static func compose( @@ -33,12 +31,12 @@ enum CreateTableStatementComposer { var issues = plan.issues let columnRefusals = definition.columns.compactMap { driver.schemaOperationRefusal(.addColumn($0)) } - issues += columnRefusals.map { SchemaDraftIssue(tab: .columns, row: nil, message: $0) } - var refusedIndexRows: Set = [] - for (row, index) in plan.indexes.enumerated() { + issues += columnRefusals.map { SchemaDraftIssue(section: .columns, row: nil, message: $0) } + var refusedIndexes: Set = [] + for (position, index) in plan.indexes.enumerated() { guard let reason = driver.schemaOperationRefusal(.addIndex(index)) else { continue } - refusedIndexRows.insert(row) - issues.append(SchemaDraftIssue(tab: .indexes, row: row, message: reason)) + refusedIndexes.insert(position) + issues.append(SchemaDraftIssue(section: .indexes, row: plan.indexRow(at: position), message: reason)) } guard columnRefusals.isEmpty else { return CreateTableStatements(statements: [], issues: issues, tableName: definition.tableName) @@ -46,17 +44,17 @@ enum CreateTableStatementComposer { guard let createTable = driver.generateCreateTableStatements(definition: definition), !createTable.isEmpty else { issues.append(SchemaDraftIssue( - tab: .columns, row: nil, + section: .columns, row: nil, message: String(localized: "This database cannot create a table from the visual editor.") )) return CreateTableStatements(statements: [], issues: issues, tableName: definition.tableName) } var statements = createTable - for (row, index) in plan.indexes.enumerated() where !refusedIndexRows.contains(row) { + for (position, index) in plan.indexes.enumerated() where !refusedIndexes.contains(position) { guard let sql = driver.generateAddIndexSQL(table: definition.tableName, index: index) else { issues.append(SchemaDraftIssue( - tab: .indexes, row: row, + section: .indexes, row: plan.indexRow(at: position), message: String(localized: "Create Table cannot add an index on this database.") )) continue @@ -64,6 +62,17 @@ enum CreateTableStatementComposer { statements.append(sql) } + for (position, constraint) in plan.checkConstraints.enumerated() { + guard let sql = driver.generateAddCheckConstraintSQL(table: definition.tableName, constraint: constraint) else { + issues.append(SchemaDraftIssue( + section: .checkConstraints, row: plan.checkConstraintRow(at: position), + message: String(localized: "Create Table cannot add a check constraint on this database.") + )) + continue + } + statements.append(sql) + } + if let comment = plan.tableComment { if let sql = driver.objectCommentStatement( name: definition.tableName, @@ -74,7 +83,7 @@ enum CreateTableStatementComposer { statements.append(sql) } else { issues.append(SchemaDraftIssue( - tab: .columns, row: nil, + section: .columns, row: nil, message: String(localized: "This database cannot store a table comment.") )) } diff --git a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 5ecd62935d..b641439237 100644 --- a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -58,7 +58,7 @@ struct SchemaStatementGenerator { throw NSError( domain: "SchemaStatementGenerator", code: -1, - userInfo: [NSLocalizedDescriptionKey: String(format: String(localized: "Unsupported schema operation: %@"), change.description)] + userInfo: [NSLocalizedDescriptionKey: String(format: String(localized: "Unsupported schema operation: %@"), change.localizedDescription)] ) } for stmt in stmts { diff --git a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift index c836751365..ee207790e4 100644 --- a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -32,6 +32,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { @Published var workingForeignKeys: [EditableForeignKeyDefinition] = [] @Published var workingCheckConstraints: [EditableCheckConstraintDefinition] = [] @Published var workingPrimaryKey: [String] = [] + private var removedKeyPositions: [UUID: Int] = [:] @Published var tableName: String? @@ -88,14 +89,12 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { var canUndo: Bool { !isHeldForSave && (undoManager.canUndo || hasTableCommentRun) } var canRedo: Bool { !isHeldForSave && !hasTableCommentRun && undoManager.canRedo } - /// The DDL and Triggers sub-tabs show no grid, so undo there reaches only the comment. - var undoesTableCommentNext: Bool { - hasTableCommentRun || (undoManager.canUndo && undoManager.undoActionName == Self.tableCommentActionName) + var undoMenuItemTitle: String { + guard !hasTableCommentRun else { return undoManager.undoMenuTitle(forUndoActionName: Self.tableCommentActionName) } + return undoManager.undoMenuItemTitle } - var redoesTableCommentNext: Bool { - !hasTableCommentRun && undoManager.canRedo && undoManager.redoActionName == Self.tableCommentActionName - } + var redoMenuItemTitle: String { undoManager.redoMenuItemTitle } /// Mirrors `DataChangeManager.registerUndo`. The `groupingLevel` check is what lets /// `performAsOneUndoStep` nest: inside one, a group is already open and this adds to it rather @@ -228,12 +227,78 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { // MARK: - Column Operations + // The key is the table's, so a Primary Key cell stages the key change the drivers write, not a column change. func updateColumn(id: UUID, with newColumn: EditableColumnDefinition) { - stageEdit(id: id, with: newColumn, using: Self.columnOperations) + guard let previous = workingColumns.first(where: { $0.id == id }) else { + stageEdit(id: id, with: newColumn, using: Self.columnOperations) + return + } + let togglesKey = previous.isPrimaryKey != newColumn.isPrimaryKey + // A staged key names columns by their new names; a key nobody changed follows a rename by itself. + let renamesStagedKeyMember = previous.name != newColumn.name + && isStaged(.primaryKey) && workingPrimaryKey.contains(previous.name) + guard togglesKey || renamesStagedKeyMember else { + stageEdit(id: id, with: newColumn, using: Self.columnOperations) + return + } + performAsOneUndoStep { + stageEdit(id: id, with: newColumn, using: Self.columnOperations) + if togglesKey { + let names = Set([previous.name, newColumn.name] + currentColumns.filter { $0.id == id }.map(\.name)) + stagePrimaryKeyMembership(of: newColumn.name, replacing: names, isMember: newColumn.isPrimaryKey) + } else { + stagePrimaryKey(workingPrimaryKey.map { $0 == previous.name ? newColumn.name : $0 }) + } + } + } + + private func stagePrimaryKeyMembership(of name: String, replacing names: Set, isMember: Bool) { + var new = keyInWorkingNames.filter { !names.contains($0) } + if isMember { new.append(name) } + stagePrimaryKey(new) + } + + // The loaded key names columns as they were read; once a change to the key is staged, it names them as they are now. + private var keyInWorkingNames: [String] { + guard !isStaged(.primaryKey) else { return workingPrimaryKey } + return workingPrimaryKey.map { name in + guard let id = currentColumns.first(where: { $0.name == name })?.id, + let working = workingColumns.first(where: { $0.id == id }) else { return name } + return working.name + } + } + + private func stagePrimaryKey(_ new: [String]) { + let old = workingPrimaryKey + guard new != old else { return } + registerUndo(String(localized: "Change Primary Key")) { target in + target.applySchemaUndo(.primaryKeyChange(old: old, new: new)) + } + workingPrimaryKey = new + let key = SchemaChangeIdentifier.primaryKey + if new != currentPrimaryKey { + pendingChanges[key] = .modifyPrimaryKey(old: currentPrimaryKey, new: new) + trackChangeKey(key) + } else { + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) + } + workingCopyDidChange() } + + // A staged key loses a column that goes, so it never names one Save cannot find. func deleteColumn(id: UUID) { - stageDeletion(id: id, using: Self.columnOperations) + guard let column = workingColumns.first(where: { $0.id == id }), + isStaged(.primaryKey), workingPrimaryKey.contains(column.name) else { + stageDeletion(id: id, using: Self.columnOperations) + return + } + performAsOneUndoStep { + stageDeletion(id: id, using: Self.columnOperations) + removedKeyPositions[id] = workingPrimaryKey.firstIndex(of: column.name) + stagePrimaryKey(workingPrimaryKey.filter { $0 != column.name }) + } } // MARK: - Index Operations @@ -312,7 +377,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { let key = operations.identifier(id) if let currentIndex = self[keyPath: operations.current].firstIndex(where: { $0.id == id }) { let oldEntity = self[keyPath: operations.current][currentIndex] - if oldEntity != newEntity { + if operations.needsStatement(oldEntity, newEntity) { pendingChanges[key] = operations.modification(oldEntity, newEntity) trackChangeKey(key) } else { @@ -367,7 +432,12 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { deletionUndo: { SchemaUndoAction.columnDelete(column: $0, at: $1) }, addActionName: String(localized: "Add Column"), editActionName: String(localized: "Edit Column"), - deleteActionName: String(localized: "Delete Column") + deleteActionName: String(localized: "Delete Column"), + needsStatement: { loaded, working in + var working = working + working.isPrimaryKey = loaded.isPrimaryKey + return loaded != working + } ) private static let indexOperations = SchemaEntityOperations( @@ -418,39 +488,50 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { // MARK: - Row-Specific Undo Delete - /// Clear the deletion mark for the entity at `row` in `tab`. Mirrors - /// `DataChangeManager.undoRowDeletion(rowID:)`: the global NSUndoManager - /// stack is intentionally left alone. The original `applySchemaUndo(...)` - /// handler the deletion registered remains on the stack; if global Cmd+Z - /// later invokes it, the handler finds `pendingChanges` no longer marks - /// this row as deleted and treats the redo as a no-op for this entity. The - /// row-specific affordance and the global undo stack are independent - /// affordances. The data tab uses the same separation. - func undoDelete(for tab: StructureTab, at row: Int) { + // The row's own Undo Delete leaves the global stack alone, like the data tab's; an edit made before the + // delete is staged again, since the working copy still shows it. + func undoDelete(section: StructureSection, id: UUID) { guard !isHeldForSave else { return } - let key: SchemaChangeIdentifier - switch tab { + switch section { case .columns: - guard row < workingColumns.count else { return } - key = .column(workingColumns[row].id) - case .indexes: - guard row < workingIndexes.count else { return } - key = .index(workingIndexes[row].id) - case .foreignKeys: - guard row < workingForeignKeys.count else { return } - key = .foreignKey(workingForeignKeys[row].id) - case .checkConstraints: - guard row < workingCheckConstraints.count else { return } - key = .checkConstraint(workingCheckConstraints[row].id) - case .ddl, .parts, .triggers, .options: - return + undoDeletion(id: id, using: Self.columnOperations) + restoreKeyMembership(ofColumn: id) + case .indexes: undoDeletion(id: id, using: Self.indexOperations) + case .foreignKeys: undoDeletion(id: id, using: Self.foreignKeyOperations) + case .checkConstraints: undoDeletion(id: id, using: Self.checkConstraintOperations) } + } + + // A column whose delete took it out of a staged key goes back into that key, at the place it held. + private func restoreKeyMembership(ofColumn id: UUID) { + let position = removedKeyPositions.removeValue(forKey: id) + guard let column = workingColumns.first(where: { $0.id == id }), column.isPrimaryKey, + !workingPrimaryKey.contains(column.name) else { return } + var key = workingPrimaryKey + key.insert(column.name, at: min(position ?? key.count, key.count)) + stagePrimaryKey(key) + } + + private func undoDeletion(id: UUID, using operations: SchemaEntityOperations) { + let key = operations.identifier(id) guard pendingChanges[key]?.isDelete == true else { return } - pendingChanges.removeValue(forKey: key) - untrackChangeKey(key) + restageEdit(id: id, using: operations) workingCopyDidChange() } + private func restageEdit(id: UUID, using operations: SchemaEntityOperations) { + let key = operations.identifier(id) + guard let loaded = self[keyPath: operations.current].first(where: { $0.id == id }), + let working = self[keyPath: operations.working].first(where: { $0.id == id }), + operations.needsStatement(loaded, working) else { + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) + return + } + pendingChanges[key] = operations.modification(loaded, working) + trackChangeKey(key) + } + // MARK: - Validation /// Runs after every change to the working copy, undo and redo included: first what the change @@ -973,7 +1054,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { return } let current = self[keyPath: operations.current][currentIndex] - if old != current { + if operations.needsStatement(current, old) { pendingChanges[key] = operations.modification(current, old) trackChangeKey(key) } else { @@ -1008,8 +1089,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { } let key = operations.identifier(entity.id) if self[keyPath: operations.current].contains(where: { $0.id == entity.id }) { - pendingChanges.removeValue(forKey: key) - untrackChangeKey(key) + restageEdit(id: entity.id, using: operations) } else { if let row, row < self[keyPath: operations.working].count { self[keyPath: operations.working].insert(entity, at: row) @@ -1045,7 +1125,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { /// merges the result of this method with `modifiedColumns` from /// `StructureEditingSupport` field-diff helpers to build the final /// `RowVisualState`. - func deleteInsertState(for row: Int, tab: StructureTab) -> (isDeleted: Bool, isInserted: Bool) { + func deleteInsertState(for row: Int, tab: StructureSection) -> (isDeleted: Bool, isInserted: Bool) { switch tab { case .columns: return rowState(at: row, using: Self.columnOperations) @@ -1055,8 +1135,6 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { return rowState(at: row, using: Self.foreignKeyOperations) case .checkConstraints: return rowState(at: row, using: Self.checkConstraintOperations) - case .ddl, .parts, .triggers, .options: - return (false, false) } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift index ba189144e1..879cd2c8c3 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift @@ -14,6 +14,10 @@ extension MainSplitViewController { commandActions?.saveFileAs() } + @objc func discardChanges(_ sender: Any?) { + commandActions?.discardChanges() + } + /// New Tab and Close Tab used to run AppKit's own `newWindowForTab:` and `performClose:`, /// which named windows because a tab was a window. They act on the tab list now, so the /// menu and the strip's own controls cannot disagree. diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index df49ca366a..ffed17eda9 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -43,6 +43,8 @@ struct MenuValidationContext: Equatable { // A batch whose COMMIT is on the wire is executing yet unstoppable, and Cmd+. must dim rather than fire into it. var isQueryStoppable = false var hasQueryText = false + // A Create Table tab runs its statement with Execute (Cmd+Return), as its old Create Table button did. + var canCreateDraftTable = false var canRunAIQueryActions = false var canClearQuery = false var canClearResults = false @@ -50,6 +52,8 @@ struct MenuValidationContext: Equatable { // Save commits an edit still open in the grid before it looks for changes. var hasOpenGridEdit = false var hasDataPendingChanges = false + // The selected tab's staged structure edits, or its grid edits; Create Table has nothing to return to. + var canDiscardChanges = false var hasRowSelection = false // Copy with headers and Copy as JSON read the data grid's columns, so the structure grid's selection does not count. var hasDataGridRowSelection = false @@ -175,6 +179,8 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(saveDocument(_:)): return context.isConnected && !context.isReadOnly && (context.hasPendingChanges || context.hasOpenGridEdit) + case #selector(discardChanges(_:)): + return context.isConnected && context.canDiscardChanges case #selector(saveDocumentAs(_:)): return context.isConnected && context.isQueryTab case #selector(exportQueryResults(_:)): @@ -434,6 +440,7 @@ extension MainSplitViewController: NSMenuItemValidation { private static let browseContentSelectors: Set = [ #selector(refreshDatabase(_:)), #selector(saveDocument(_:)), + #selector(discardChanges(_:)), #selector(addRow(_:)), #selector(restorePreviousValues(_:)), #selector(editDocument(_:)), @@ -477,8 +484,9 @@ extension MainSplitViewController: NSMenuItemValidation { private static func isQueryCommandEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool? { switch selector { - case #selector(executeQuery(_:)), - #selector(executeAllStatements(_:)), + case #selector(executeQuery(_:)): + return context.isConnected && (context.hasQueryText || context.canCreateDraftTable) + case #selector(executeAllStatements(_:)), #selector(executeQueryWithoutLimit(_:)): return context.isConnected && context.hasQueryText case #selector(formatQuery(_:)): @@ -582,12 +590,14 @@ extension MainSplitViewController: NSMenuItemValidation { isQueryExecuting: actions.isQueryExecuting, isQueryStoppable: actions.isQueryStoppable, hasQueryText: actions.hasQueryText, + canCreateDraftTable: actions.canCreateDraftTable, canRunAIQueryActions: actions.aiQueryActionAvailability.isEnabled, canClearQuery: actions.canClearQuery, canClearResults: actions.canClearResults, hasPendingChanges: actions.hasPendingChanges, hasOpenGridEdit: actions.hasOpenGridEdit, hasDataPendingChanges: actions.hasDataPendingChanges, + canDiscardChanges: actions.canDiscardChanges, hasRowSelection: actions.hasRowSelection, hasDataGridRowSelection: actions.hasDataGridRowSelection, hasTableSelection: actions.hasTableSelection, diff --git a/apple/TablePro/Models/Connection/ConnectionToolbarState.swift b/apple/TablePro/Models/Connection/ConnectionToolbarState.swift index 38ee3fb4a8..ed8460e957 100644 --- a/apple/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/apple/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -130,8 +130,6 @@ final class ConnectionToolbarState: ObservableObject { /// Whether there are pending data grid changes (for SQL preview button) @Published var hasDataPendingChanges: Bool = false - /// Whether the structure view has pending schema changes - @Published var hasStructureChanges: Bool = false // Bumped when a diagram's staged edits come or go; the pending state reads the diagram itself. @Published var diagramEditsRevision = 0 diff --git a/apple/TablePro/Models/Query/ResultStatusModel.swift b/apple/TablePro/Models/Query/ResultStatusModel.swift index 6e9925fe0f..665075df18 100644 --- a/apple/TablePro/Models/Query/ResultStatusModel.swift +++ b/apple/TablePro/Models/Query/ResultStatusModel.swift @@ -48,9 +48,6 @@ struct ResultStatusControls: Equatable { /// First, Previous, Next, Last and the page number, which an engine that cannot skip rows has /// no use for. The rows-per-page menu stays, because it still sets how many leading rows load. var showsPageNavigation = false - /// The structure editor's add and remove pair, which is this bar's trailing cluster while the - /// structure editor is the content. - var showsStructureActions = false /// Only the data grid has cells to sum. JSON and Map report the row selection, not cells. var showsSelectionSummary = false } @@ -95,7 +92,6 @@ struct ResultStatusModel: Equatable { let isTable = snapshot.tabType == .table controls.showsModeSwitcher = snapshot.availableModes.count > 1 - controls.showsStructureActions = viewMode == .structure && snapshot.hasStructureActions controls.showsExecution = viewMode.reportsExecution /// A plan keeps the bar so it stays choosable and pinnable, and gives up everything the bar diff --git a/apple/TablePro/Models/Query/StatusBarSnapshot.swift b/apple/TablePro/Models/Query/StatusBarSnapshot.swift index 3202cbd7e7..d241272bc2 100644 --- a/apple/TablePro/Models/Query/StatusBarSnapshot.swift +++ b/apple/TablePro/Models/Query/StatusBarSnapshot.swift @@ -21,8 +21,6 @@ struct StatusBarSnapshot: Equatable { let hasTableName: Bool /// The modes this tab can switch between, which the bar's leading control offers. let availableModes: [ResultsViewMode] - /// Whether the structure editor has published an add/remove pair for this tab. - let hasStructureActions: Bool let pagination: PaginationState let statusMessage: String? let paginationCapability: PaginationCapability @@ -40,7 +38,6 @@ struct StatusBarSnapshot: Equatable { isValueFiltered: Bool = false, hasTableName: Bool, availableModes: [ResultsViewMode] = [], - hasStructureActions: Bool = false, isQueryPlan: Bool = false, pagination: PaginationState, statusMessage: String?, @@ -55,7 +52,6 @@ struct StatusBarSnapshot: Equatable { self.isValueFiltered = isValueFiltered self.hasTableName = hasTableName self.availableModes = availableModes - self.hasStructureActions = hasStructureActions self.isQueryPlan = isQueryPlan self.pagination = pagination self.statusMessage = statusMessage @@ -72,7 +68,6 @@ struct StatusBarSnapshot: Equatable { tableRows: TableRows?, displayRowCount: Int? = nil, isFetching: Bool = false, - hasStructureActions: Bool = false, isQueryPlan: Bool = false, paginationCapability: PaginationCapability = .offset, hasServerOutput: Bool = false, @@ -98,7 +93,6 @@ struct StatusBarSnapshot: Equatable { hasSpatialColumn: !(tab?.display.spatialColumns.isEmpty ?? true), hasServerOutput: hasServerOutput ), - hasStructureActions: hasStructureActions, isQueryPlan: isQueryPlan, pagination: pagination, // A run of several statements leaves each one's message on its own result, not on the tab. diff --git a/apple/TablePro/Models/Schema/CreateTableDraftBuilder.swift b/apple/TablePro/Models/Schema/CreateTableDraftBuilder.swift index 6f62620de5..783d9ab282 100644 --- a/apple/TablePro/Models/Schema/CreateTableDraftBuilder.swift +++ b/apple/TablePro/Models/Schema/CreateTableDraftBuilder.swift @@ -6,56 +6,43 @@ import Foundation import TableProPluginKit -/// What a Create Table draft comes to: the statement plan it will run, and every row that is not -/// ready to run. +// The statements a Create Table draft will run, and every row that is not ready to. struct CreateTablePlan { - /// Never carries indexes. An index is always its own statement, see `CreateTableDraftBuilder`. + // Never carries indexes or check constraints: each is its own statement after the CREATE. let definition: PluginCreateTableDefinition? let indexes: [PluginIndexDefinition] let issues: [SchemaDraftIssue] - /// Nil writes no comment. Set after the `CREATE TABLE`, as its own statement. + // Nil writes no comment. Set after the CREATE, as its own statement. var tableComment: String? + var checkConstraints: [PluginCheckConstraintDefinition] = [] + // Working-list rows of `indexes` and `checkConstraints`, since blank and incomplete rows are skipped. + var indexRows: [Int] = [] + var checkConstraintRows: [Int] = [] var isReadyToCreate: Bool { definition != nil && issues.isEmpty } + + func indexRow(at position: Int) -> Int { + indexRows.indices.contains(position) ? indexRows[position] : position + } + + func checkConstraintRow(at position: Int) -> Int { + checkConstraintRows.indices.contains(position) ? checkConstraintRows[position] : position + } } -/// Turns the Create Table editor's working rows into a statement plan. -/// -/// This exists because the view used to do it inline, in three filters that each demanded a -/// non-empty `name` and then dropped whatever failed: -/// -/// foreignKeys: workingForeignKeys.filter { !$0.name.isEmpty && !$0.columns.isEmpty && … } -/// -/// A new foreign key row's name is always empty and nothing in the grid fills it, so every foreign -/// key the user entered was deleted on the way to the driver, with no message and an SQL Preview -/// that agreed. No dialect requires the name, three drivers discard it, and the filter left out the -/// predicate that does matter: an empty referenced column list renders `REFERENCES "t" ()`, which -/// SQLite, PostgreSQL and DuckDB all reject. -/// -/// So the split is: this type answers whether a row is **complete**, the driver answers how to -/// **spell** it, and `ForeignKeyDialect` answers what the engine's grammar takes. Three row states, -/// and the middle one is the whole point: -/// -/// - **blank**: nothing typed. Ignored, because the editor seeds a blank column and `+` seeds blank -/// rows, and neither is something the user asked to create. -/// - **incomplete**: begun and unusable. An issue the user is shown. Never a silent drop. -/// - **complete**: emitted. +// Turns the working rows into a plan. This type says whether a row is complete, the driver how to spell it, and +// `ForeignKeyDialect` what the engine's grammar takes. A blank row is ignored (the editor seeds them), a begun one +// that cannot run is an issue, never a silent drop: requiring a foreign key's name once dropped every key (#2691). @MainActor enum CreateTableDraftBuilder { - /// Indexes are never folded into the `CREATE TABLE` statement, on any engine. - /// - /// PostgreSQL and DuckDB used to append their `CREATE INDEX` statements to the same returned - /// string, and that only ran because `PQexec` and `duckdb_query` take a whole batch. SQLite's - /// driver prepares with `sqlite3_prepare_v2(db, sql, -1, &stmt, nil)` and steps once, so a - /// second statement in the string is compiled by nobody: the index would vanish exactly the way - /// the foreign keys did. Every index therefore goes through `generateAddIndexSQL` as its own - /// statement, which is also what the existing-table path in `SchemaStatementGenerator` does. + // Indexes and checks are never folded into the CREATE: SQLite steps one statement per execute. static func plan( tableName: String, options: CreateTableOptions, columns: [EditableColumnDefinition], indexes: [EditableIndexDefinition], foreignKeys: [EditableForeignKeyDefinition], + checkConstraints: [EditableCheckConstraintDefinition] = [], dialect: ForeignKeyDialect, includesEngineOptions: Bool, defaults: CreateTableOptionDefaults = CreateTableOptionDefaults(), @@ -69,16 +56,17 @@ enum CreateTableDraftBuilder { let resolvedForeignKeys = resolveForeignKeys( foreignKeys, columnNames: columnNames, dialect: dialect, into: &issues ) + let resolvedChecks = resolveCheckConstraints(checkConstraints, into: &issues) let trimmedName = tableName.trimmingCharacters(in: .whitespaces) if trimmedName.isEmpty { issues.append(SchemaDraftIssue( - tab: .columns, row: nil, message: String(localized: "The table needs a name.") + section: .columns, row: nil, message: String(localized: "The table needs a name.") )) } if resolvedColumns.isEmpty, !suppliesItsOwnKey { issues.append(SchemaDraftIssue( - tab: .columns, row: nil, + section: .columns, row: nil, message: String(localized: "The table needs at least one column with a name and a type.") )) } @@ -103,18 +91,18 @@ enum CreateTableDraftBuilder { return CreateTablePlan( definition: definition, - indexes: resolvedIndexes.map { $0.toPlugin() }, + indexes: resolvedIndexes.map { $0.definition.toPlugin() }, issues: issues, - tableComment: ObjectCommentDraft.normalized(options.comment) + tableComment: ObjectCommentDraft.normalized(options.comment), + checkConstraints: resolvedChecks.map { $0.definition.toPlugin() }, + indexRows: resolvedIndexes.map(\.row), + checkConstraintRows: resolvedChecks.map(\.row) ) } // MARK: - Columns - /// A column the user marked auto-increment without ticking Primary Key is promoted here rather - /// than in each driver. The old code passed the promotion through `primaryKeyColumns` only, and - /// SQLite reads `isPrimaryKey` alone, so on SQLite that column got neither a primary key nor - /// `AUTOINCREMENT`. Reconciling the two once leaves every driver reading one answer. + // An auto-increment column with no primary key ticked is promoted here, once: SQLite reads `isPrimaryKey` alone. private static func resolveColumns( _ columns: [EditableColumnDefinition], into issues: inout [SchemaDraftIssue] @@ -130,19 +118,19 @@ enum CreateTableDraftBuilder { } if name.isEmpty { issues.append(SchemaDraftIssue( - tab: .columns, row: row, message: String(localized: "This column needs a name.") + section: .columns, row: row, message: String(localized: "This column needs a name.") )) continue } if type.isEmpty { issues.append(SchemaDraftIssue( - tab: .columns, row: row, message: String(localized: "This column needs a type.") + section: .columns, row: row, message: String(localized: "This column needs a type.") )) continue } if !column.isNullable, column.hasNullDefault { issues.append(SchemaDraftIssue( - tab: .columns, row: row, + section: .columns, row: row, message: String(localized: "This column does not allow NULL, so its default cannot be NULL.") )) } @@ -163,7 +151,7 @@ enum CreateTableDraftBuilder { for position in duplicatePositions(of: resolved.map(\.name)) { issues.append(SchemaDraftIssue( - tab: .columns, row: sourceRows[position], + section: .columns, row: sourceRows[position], message: String( format: String(localized: "Another column is already named %@."), resolved[position].name ) @@ -175,15 +163,13 @@ enum CreateTableDraftBuilder { // MARK: - Indexes - /// An index does need a name, unlike a foreign key: `CREATE INDEX` has nowhere to put an - /// anonymous one. So the missing name is reported rather than required in silence. + // Unlike a foreign key, an index needs a name: CREATE INDEX has nowhere to put an anonymous one. private static func resolveIndexes( _ indexes: [EditableIndexDefinition], columnNames: Set, into issues: inout [SchemaDraftIssue] - ) -> [EditableIndexDefinition] { - var resolved: [EditableIndexDefinition] = [] - var sourceRows: [Int] = [] + ) -> [Resolved] { + var resolved: [Resolved] = [] for (row, index) in indexes.enumerated() { let name = index.name.trimmingCharacters(in: .whitespaces) @@ -193,14 +179,14 @@ enum CreateTableDraftBuilder { } if columns.isEmpty { issues.append(SchemaDraftIssue( - tab: .indexes, row: row, + section: .indexes, row: row, message: String(localized: "This index needs at least one column.") )) continue } if name.isEmpty { issues.append(SchemaDraftIssue( - tab: .indexes, row: row, message: String(localized: "This index needs a name.") + section: .indexes, row: row, message: String(localized: "This index needs a name.") )) continue } @@ -209,22 +195,22 @@ enum CreateTableDraftBuilder { normalized.columns = columns if let unknown = normalized.referencedColumnNames.first(where: { !columnNames.contains($0) }) { issues.append(SchemaDraftIssue( - tab: .indexes, row: row, + section: .indexes, row: row, message: String( format: String(localized: "The table has no column named %@."), unknown ) )) continue } - resolved.append(normalized) - sourceRows.append(row) + resolved.append(Resolved(definition: normalized, row: row)) } - for position in duplicatePositions(of: resolved.map(\.name)) { + for position in duplicatePositions(of: resolved.map(\.definition.name)) { issues.append(SchemaDraftIssue( - tab: .indexes, row: sourceRows[position], + section: .indexes, row: resolved[position].row, message: String( - format: String(localized: "Another index is already named %@."), resolved[position].name + format: String(localized: "Another index is already named %@."), + resolved[position].definition.name ) )) } @@ -254,7 +240,7 @@ enum CreateTableDraftBuilder { let names = resolved.map(\.name) for position in duplicatePositions(of: names) where !names[position].isEmpty { issues.append(SchemaDraftIssue( - tab: .foreignKeys, row: sourceRows[position], + section: .foreignKeys, row: sourceRows[position], message: String( format: String(localized: "Another foreign key is already named %@."), resolved[position].name @@ -265,9 +251,7 @@ enum CreateTableDraftBuilder { return resolved } - /// The constraint name is deliberately absent from every test here. It is optional in every - /// dialect TablePro speaks, three drivers discard it, and demanding it is what deleted the - /// user's foreign keys. + // No test here demands the name: it is optional in every dialect, and demanding it deleted the user's keys. private static func resolveForeignKey( _ foreignKey: EditableForeignKeyDefinition, row: Int, @@ -294,7 +278,7 @@ enum CreateTableDraftBuilder { } func report(_ message: String) { - issues.append(SchemaDraftIssue(tab: .foreignKeys, row: row, message: message)) + issues.append(SchemaDraftIssue(section: .foreignKeys, row: row, message: message)) } if normalized.columns.isEmpty { @@ -344,8 +328,61 @@ enum CreateTableDraftBuilder { return normalized } + // MARK: - Check constraints + + // Every driver writes a check as ADD CONSTRAINT CHECK (), so both are required. + private static func resolveCheckConstraints( + _ constraints: [EditableCheckConstraintDefinition], + into issues: inout [SchemaDraftIssue] + ) -> [Resolved] { + var resolved: [Resolved] = [] + + for (row, constraint) in constraints.enumerated() { + let name = constraint.name.trimmingCharacters(in: .whitespaces) + let expression = constraint.expression.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty, expression.isEmpty { + continue + } + if expression.isEmpty { + issues.append(SchemaDraftIssue( + section: .checkConstraints, row: row, + message: String(localized: "This check constraint needs an expression.") + )) + continue + } + if name.isEmpty { + issues.append(SchemaDraftIssue( + section: .checkConstraints, row: row, + message: String(localized: "This check constraint needs a name.") + )) + continue + } + var normalized = constraint + normalized.name = name + normalized.expression = expression + resolved.append(Resolved(definition: normalized, row: row)) + } + + for position in duplicatePositions(of: resolved.map(\.definition.name)) { + issues.append(SchemaDraftIssue( + section: .checkConstraints, row: resolved[position].row, + message: String( + format: String(localized: "Another check constraint is already named %@."), + resolved[position].definition.name + ) + )) + } + + return resolved + } + // MARK: - Helpers + private struct Resolved { + let definition: Definition + let row: Int + } + private static func trimmedOrNil(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty else { return nil } return trimmed @@ -355,12 +392,8 @@ enum CreateTableDraftBuilder { values.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } } - /// Every position after the first that repeats a name already seen, so the row the user is most - /// likely still editing is the one flagged. - /// - /// These are positions in the resolved list, not grid rows. A row skipped for being blank or - /// incomplete shortens the list, so the caller maps them back through its own `sourceRows` or - /// the message points at a different row than the duplicate. + // Positions in the resolved list after the first that repeat a name, so the row likely still being edited is + // flagged. The caller maps them back to working rows: skipped rows shorten the list. private static func duplicatePositions(of names: [String]) -> [Int] { var seen: Set = [] var duplicates: [Int] = [] diff --git a/apple/TablePro/Models/Schema/SchemaChange.swift b/apple/TablePro/Models/Schema/SchemaChange.swift index ef68b9bbd2..e99da8c518 100644 --- a/apple/TablePro/Models/Schema/SchemaChange.swift +++ b/apple/TablePro/Models/Schema/SchemaChange.swift @@ -88,6 +88,57 @@ enum SchemaChange: Hashable, Equatable { } } + // What an error or a review shows a person; `description` stays English for logs. + var localizedDescription: String { + switch self { + case .addColumn(let col): + return String(format: String(localized: "Add column “%@”"), col.name) + case .modifyColumn(let old, let new): + return old.name == new.name + ? String(format: String(localized: "Change column “%@”"), new.name) + : String(format: String(localized: "Change column “%@” to “%@”"), old.name, new.name) + case .deleteColumn(let col): + return String(format: String(localized: "Delete column “%@”"), col.name) + case .addIndex(let idx): + return String(format: String(localized: "Add index “%@”"), idx.name) + case .modifyIndex(let old, let new): + return old.name == new.name + ? String(format: String(localized: "Change index “%@”"), new.name) + : String(format: String(localized: "Change index “%@” to “%@”"), old.name, new.name) + case .deleteIndex(let idx): + return String(format: String(localized: "Delete index “%@”"), idx.name) + case .addForeignKey(let fk): + return String(format: String(localized: "Add foreign key “%@”"), fk.name) + case .modifyForeignKey(let old, let new): + return old.name == new.name + ? String(format: String(localized: "Change foreign key “%@”"), new.name) + : String(format: String(localized: "Change foreign key “%@” to “%@”"), old.name, new.name) + case .deleteForeignKey(let fk): + return String(format: String(localized: "Delete foreign key “%@”"), fk.name) + case .addCheckConstraint(let constraint): + return String(format: String(localized: "Add check constraint “%@”"), constraint.name) + case .modifyCheckConstraint(let old, let new): + return old.name == new.name + ? String(format: String(localized: "Change check constraint “%@”"), new.name) + : String(format: String(localized: "Change check constraint “%@” to “%@”"), old.name, new.name) + case .deleteCheckConstraint(let constraint): + return String(format: String(localized: "Delete check constraint “%@”"), constraint.name) + case .modifyPrimaryKey(_, let new): + return new.isEmpty + ? String(localized: "Remove the primary key") + : String(format: String(localized: "Set the primary key to %@"), new.joined(separator: ", ")) + case .modifyTableComment: + return String(localized: "Change the table comment") + case .modifyTableCharacterSet(_, let new): + let target = [new.characterSet, new.collation].compactMap { $0 }.joined(separator: " ") + return new.convertsColumns + ? String(format: String(localized: "Convert the table to %@"), target) + : String(format: String(localized: "Set the table's default character set to %@"), target) + case .modifyAutoIncrement(_, let new): + return String(format: String(localized: "Set the next AUTO_INCREMENT value to %@"), String(new)) + } + } + var description: String { switch self { case .addColumn(let col): diff --git a/apple/TablePro/Models/Schema/SchemaDraftIssue.swift b/apple/TablePro/Models/Schema/SchemaDraftIssue.swift index 66dc0be6ff..ec986d6188 100644 --- a/apple/TablePro/Models/Schema/SchemaDraftIssue.swift +++ b/apple/TablePro/Models/Schema/SchemaDraftIssue.swift @@ -5,30 +5,23 @@ import Foundation -/// A row the user began and did not finish, named where the user can act on it. -/// -/// The distinction this type exists to draw is between a row that is blank, which is the editor's -/// own placeholder and carries no intent, and a row that holds some of what a constraint needs and -/// not the rest. The first is ignored. The second used to be dropped from the generated statement -/// with no message at all, which is how a filled-in foreign key came to vanish between the grid and -/// the SQL Preview (#2691). +// A row the user began and did not finish. A blank row carries no intent and is ignored; a begun one is named +// here, never dropped from the statement in silence (#2691). struct SchemaDraftIssue: Equatable, Identifiable, Sendable { - let tab: StructureTab - /// Zero-based position in the tab's grid, or nil for an issue about the draft as a whole. + let section: StructureSection + // Zero-based position in the section's working list, or nil for an issue about the draft as a whole. let row: Int? let message: String - var id: String { "\(tab.rawValue)-\(row.map(String.init) ?? "-")-\(message)" } + var id: String { "\(section.rawValue)-\(row.map(String.init) ?? "-")-\(message)" } - /// The message with its row and tab in front, for a reader who is looking at another tab. - /// - /// Rows are numbered from one here because that is how the grid's row gutter numbers them. + // Numbered from one, the way the grid's row gutter counts. var qualifiedMessage: String { guard let row else { - return String(format: String(localized: "%1$@: %2$@"), tab.displayName, message) + return String(format: String(localized: "%1$@: %2$@"), section.displayName, message) } return String( - format: String(localized: "%1$@ row %2$lld: %3$@"), tab.displayName, Int64(row + 1), message + format: String(localized: "%1$@ row %2$lld: %3$@"), section.displayName, Int64(row + 1), message ) } } diff --git a/apple/TablePro/Models/Schema/SchemaEntityOperations.swift b/apple/TablePro/Models/Schema/SchemaEntityOperations.swift index ef8919da49..36415b38b5 100644 --- a/apple/TablePro/Models/Schema/SchemaEntityOperations.swift +++ b/apple/TablePro/Models/Schema/SchemaEntityOperations.swift @@ -2,17 +2,10 @@ // SchemaEntityOperations.swift // TablePro // -// Describes one structure-editor entity kind to the generic staging path. -// import Foundation -/// What distinguishes one structure entity from another when it is staged. -/// -/// Columns, indexes, foreign keys and check constraints are staged by an identical sequence: -/// register an undo, record a pending change under the entity's identifier, then write the -/// working array. Only the arrays, the enum cases and the undo action names differ, so those -/// are supplied here and the sequence itself lives once in `StructureChangeManager`. +// One structure entity kind, for the staging sequence `StructureChangeManager` runs once for all four. struct SchemaEntityOperations where Entity.ID == UUID { let working: ReferenceWritableKeyPath let current: KeyPath @@ -26,4 +19,6 @@ struct SchemaEntityOperations where Entity.ID let addActionName: String let editActionName: String let deleteActionName: String + // Whether the working copy needs a statement; a column's key flag is the table's key change, not the column's. + var needsStatement: (_ loaded: Entity, _ working: Entity) -> Bool = { $0 != $1 } } diff --git a/apple/TablePro/Models/Schema/StructureTab.swift b/apple/TablePro/Models/Schema/StructureSection.swift similarity index 52% rename from apple/TablePro/Models/Schema/StructureTab.swift rename to apple/TablePro/Models/Schema/StructureSection.swift index e652c0c8ed..1591be0043 100644 --- a/apple/TablePro/Models/Schema/StructureTab.swift +++ b/apple/TablePro/Models/Schema/StructureSection.swift @@ -1,28 +1,37 @@ // -// StructureTab.swift +// StructureSection.swift // TablePro // import Foundation -enum StructureTab: String, CaseIterable, Hashable { +// The kinds of definition a table's structure stages edits for. Read-only panes are StructurePane. +enum StructureSection: String, CaseIterable, Hashable { case columns case indexes case foreignKeys case checkConstraints - case triggers - case options - case ddl - case parts var displayName: String { switch self { case .columns: String(localized: "Columns") case .indexes: String(localized: "Indexes") case .foreignKeys: String(localized: "Foreign Keys") - case .checkConstraints: String(localized: "Constraints") + case .checkConstraints: String(localized: "Check Constraints") + } + } +} + +enum StructurePane: String, CaseIterable, Hashable { + case definition + case triggers + case ddl + case parts + + var displayName: String { + switch self { + case .definition: String(localized: "Definition") case .triggers: String(localized: "Triggers") - case .options: String(localized: "Options") case .ddl: "DDL" case .parts: "Parts" } diff --git a/apple/TablePro/Models/UI/DataGridConfiguration.swift b/apple/TablePro/Models/UI/DataGridConfiguration.swift index e6313ae64d..3f05e7c2b8 100644 --- a/apple/TablePro/Models/UI/DataGridConfiguration.swift +++ b/apple/TablePro/Models/UI/DataGridConfiguration.swift @@ -45,4 +45,10 @@ struct DataGridConfiguration: Equatable { // VoiceOver's name for the grid ("Active Sessions"); nil reads "Data grid". var accessibilityLabel: String? + + // As tall as its rows, inside a page that scrolls vertically for it; the grid scrolls only sideways. + var fitsRows = false + + // Off where an empty cell means "none set", as a structure list's Default or Comment does, not an empty string. + var marksEmptyText = true } diff --git a/apple/TablePro/Models/UI/FieldEditorKind.swift b/apple/TablePro/Models/UI/FieldEditorKind.swift index 5de538e35e..4386adc89b 100644 --- a/apple/TablePro/Models/UI/FieldEditorKind.swift +++ b/apple/TablePro/Models/UI/FieldEditorKind.swift @@ -21,11 +21,6 @@ internal enum FieldEditorKind: Equatable { /// a one-line value or a JSON document, which are two different editors over the same list. case arrayElements(element: ArrayElementEditor, values: [String]) case vector - case typePicker - /// A value with suggestions the user may ignore: the menu writes correct SQL, typing writes - /// your own. `enumPicker` cannot serve, because its list is the whole set of legal values. - case valuePicker(options: [GridMenuOption]) - case schemaText case multiLine case singleLine } diff --git a/apple/TablePro/Models/UI/InspectorFieldLayout.swift b/apple/TablePro/Models/UI/InspectorFieldLayout.swift deleted file mode 100644 index 5119772e49..0000000000 --- a/apple/TablePro/Models/UI/InspectorFieldLayout.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// InspectorFieldLayout.swift -// TablePro -// - -import Foundation - -/// How a field arranges its label against its editor. -/// -/// The arrangement follows whose labels these are, not how big the editor is. A data row's label is -/// a column name, which is user data of no bounded length: measured against a realistic 400-column -/// set, the widest renders at 259.8pt, wider than the whole pane at its 270pt minimum. No label lane -/// can hold that, and a lane wide enough to try takes the width the value needs, so at the minimum a -/// side-by-side data row truncates the name *and* the value. A schema row's label comes from a -/// closed, authored vocabulary (`StructureColumnField.displayName` plus the index, foreign-key and -/// check-constraint headers), whose widest member is "Ref Columns" at 66.4pt, so the lane Apple's own -/// inspectors use is both correct and achievable there. -/// -/// Nothing here can be derived at runtime from the rows themselves. Measured: a `PreferenceKey` -/// max-reduce inside a `List` sees only the realized rows, 39pt against a true widest of 296pt, so a -/// content-derived lane would change width as the user scrolls. A custom `AlignmentID` does not -/// resolve across a `List`'s rows at all (145pt of spread; the same guide in one `VStack` gives 0). -/// `Form(.columns)` and `Grid` do align, and both forfeit the laziness `InspectorFieldListView` -/// depends on. -/// -/// Both switches are exhaustive on purpose rather than `default`-armed. A new editor kind has to be -/// classified for each provenance instead of inheriting whichever arm was written first; two -/// `default`-armed switches over `FieldEditorKind` are what let `.typePicker` escape the value-font -/// domain and render a structure row's Name and Type in two different fonts. -internal enum InspectorFieldLayout: Equatable { - /// Label in a fixed trailing-aligned lane, editor beside it, one line high. - case inline - /// Label and type above, editor spanning the full width below. - case stacked - - internal static func resolve(for kind: FieldEditorKind, isSchemaField: Bool) -> InspectorFieldLayout { - isSchemaField ? schemaLayout(for: kind) : dataLayout(for: kind) - } - - /// A data field always stacks, whatever it holds. Choosing per row from the value's own length - /// would give the pane two label lanes and two value lanes alternating down the list, drop the - /// type badge from exactly the rows too narrow to carry it, and reflow a row from one shape to - /// the other while the user is typing into it. - private static func dataLayout(for kind: FieldEditorKind) -> InspectorFieldLayout { - switch kind { - case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .schemaText, .typePicker, - .valuePicker, .multiLine, .json, .phpSerialized, .binaryValue, .blobHex, .image, .geometry: - return .stacked - } - } - - /// A schema field's label is short and authored, so the lane holds it and the row stays one line - /// high. The editors that need the pane's width still take it. - private static func schemaLayout(for kind: FieldEditorKind) -> InspectorFieldLayout { - switch kind { - case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .schemaText, .typePicker, - .valuePicker: - return .inline - case .multiLine, .json, .phpSerialized, .binaryValue, .blobHex, .image, .geometry: - return .stacked - } - } -} diff --git a/apple/TablePro/Models/UI/InspectorRowSource.swift b/apple/TablePro/Models/UI/InspectorRowSource.swift deleted file mode 100644 index e819603517..0000000000 --- a/apple/TablePro/Models/UI/InspectorRowSource.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 - let isEditable: Bool - - init(name: String, value: String?, editor: FieldEditorKind, isModified: Bool = false, isEditable: Bool = true) { - self.name = name - self.value = value - self.editor = editor - self.isModified = isModified - self.isEditable = isEditable - } -} - -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/apple/TablePro/Models/UI/InspectorSubject.swift b/apple/TablePro/Models/UI/InspectorSubject.swift index cb1df8b970..71403965d7 100644 --- a/apple/TablePro/Models/UI/InspectorSubject.swift +++ b/apple/TablePro/Models/UI/InspectorSubject.swift @@ -5,22 +5,13 @@ import Foundation -/// What the inspector is currently inspecting, and the two lines it draws for it above its content. -/// -/// The pane had no subject at all before: it multiplexed three unrelated tabs, so there was nothing -/// one title could name and the header carried a picker instead. Naming the subject is what lets -/// the inspector say which row of which table is on screen, which is the first thing a reader of an -/// inspector needs and the thing the old pane never showed. -/// -/// A schema grid is a first-class case rather than an afterthought. The structure and create-table -/// editors feed the same pane through `InspectorRowSource`, and their selection is a column -/// definition, not a row of data: it has no position in a result and no row identity, so a subject -/// that assumed one would render "Row 0 of 0" over a perfectly good column. +// What the inspector is inspecting, as the two lines it draws above its content. A structure definition has no +// position in a result, so it is named by what it is and whose it is, never "Row 0 of 0". internal enum InspectorSubject: Equatable { case empty case tableRow(table: String, position: RowPosition?) case multipleRows(table: String, count: Int) - case columnDefinition(column: String, table: String?) + case definition(name: String, section: StructureSection, table: String?) case tableOnly(table: String) internal struct RowPosition: Equatable { @@ -33,20 +24,18 @@ internal enum InspectorSubject: Equatable { } } - /// The bold first line: the thing being inspected. internal var title: String? { switch self { case .empty: return nil case .tableRow(let table, _), .multipleRows(let table, _), .tableOnly(let table): return table - case .columnDefinition(let column, _): - return column + case .definition(let name, let section, _): + return name.isEmpty ? Self.newDefinitionTitle(section) : name } } - /// The secondary second line: where the thing sits. Nil where there is nothing useful to add, - /// so the header collapses to one line rather than drawing an empty one. + // Nil where there is nothing to add, so the header collapses to one line. internal var subtitle: String? { switch self { case .empty: @@ -60,11 +49,38 @@ internal enum InspectorSubject: Equatable { ) case .multipleRows(_, let count): return String(format: String(localized: "%d rows selected"), count) - case .columnDefinition(_, let table): - guard let table else { return String(localized: "Column") } - return String(format: String(localized: "Column of %@"), table) + case .definition(_, let section, let table): + guard let table else { return Self.kindName(section) } + return Self.kindName(section, of: table) case .tableOnly: return String(localized: "Table") } } + + private static func newDefinitionTitle(_ section: StructureSection) -> String { + switch section { + case .columns: String(localized: "New Column") + case .indexes: String(localized: "New Index") + case .foreignKeys: String(localized: "New Foreign Key") + case .checkConstraints: String(localized: "New Check Constraint") + } + } + + private static func kindName(_ section: StructureSection) -> String { + switch section { + case .columns: String(localized: "Column") + case .indexes: String(localized: "Index") + case .foreignKeys: String(localized: "Foreign Key") + case .checkConstraints: String(localized: "Check Constraint") + } + } + + private static func kindName(_ section: StructureSection, of table: String) -> String { + switch section { + case .columns: String(format: String(localized: "Column of %@"), table) + case .indexes: String(format: String(localized: "Index of %@"), table) + case .foreignKeys: String(format: String(localized: "Foreign Key of %@"), table) + case .checkConstraints: String(format: String(localized: "Check Constraint of %@"), table) + } + } } diff --git a/apple/TablePro/Models/UI/MultiRowEditState.swift b/apple/TablePro/Models/UI/MultiRowEditState.swift index 294d697f48..b4c2c95517 100644 --- a/apple/TablePro/Models/UI/MultiRowEditState.swift +++ b/apple/TablePro/Models/UI/MultiRowEditState.swift @@ -21,15 +21,9 @@ 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? - // Resolved once: it costs a JSONSerialization and a PHP-serialized parse, and view bodies re-parsed it on every hover. var resolvedEditor: FieldEditorKind? - // A schema field has no data type, so it offers no type badge and no NULL or DEFAULT state. - var isSchemaField: Bool = false - // Shown without an editor: refusing the edit further down left a pending value here that Save never writes. var isServerOwned: Bool = false @@ -46,7 +40,7 @@ struct FieldEditState: Identifiable { var declaredType: String? - // Recorded by the grid owning a schema row, or the change manager for rows that disagree, which no pending value can hold. + // Recorded by the change manager for rows that disagree, which no pending value can hold. var hasCommittedEdit: Bool = false var originalValue: String? @@ -124,8 +118,6 @@ final class MultiRowEditState: ObservableObject { // The rows an edit is staged against, captured when the selection was configured, never a display position. @Published private(set) var rowIDs: [RowID] = [] - // The schema row the fields came from, for a grid whose rows have no identity. - private var schemaDisplayRow: Int? @Published private(set) var allRows: [[String?]] = [] // Per row of allRows: the columns it has no field for. @@ -177,9 +169,8 @@ final class MultiRowEditState: ObservableObject { cellWriter: CellWriterSupport = .none ) { let columnsChanged = self.columns != columns - let selectionChanged = self.rowIDs != rowIDs || schemaDisplayRow != nil + let selectionChanged = self.rowIDs != rowIDs - schemaDisplayRow = nil self.rowIDs = rowIDs self.allRows = allRows self.absentCells = absentCells @@ -332,7 +323,7 @@ final class MultiRowEditState: ObservableObject { let reveals = AppSettingsManager.shared.dataGrid.showInvisibleCharacters guard reveals != revealsInvisibleCharacters else { return } revealsInvisibleCharacters = reveals - for index in fields.indices where !fields[index].isSchemaField && fields[index].editor == nil { + for index in fields.indices { let field = fields[index] let resolved = FieldEditorResolver.resolve( for: field.columnTypeEnum, @@ -348,54 +339,6 @@ final class MultiRowEditState: ObservableObject { } } - // Field ids survive a reconfigure of the same row so a commit does not rebuild the editors and drop focus. - func configure(schemaFields: [InspectorRowField], displayRow: Int) { - let names = schemaFields.map(\.name) - let reusedIds = schemaDisplayRow == displayRow && columns == names ? fields.map(\.id) : [] - - schemaDisplayRow = displayRow - rowIDs = [] - absentCells = [] - unloadedCells = [] - expressionCells = [] - databaseType = nil - cellWriter = .none - rowsAreInserted = false - 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] - } - if field.editor == nil { - state.resolvedEditor = FieldEditorResolver.resolve( - for: .text(rawType: nil), - isLongText: false, - originalValue: field.value - ) - } - state.isServerOwned = !field.isEditable - return state - } - } - // Read from the store, not a copy an earlier render captured; a field's value binding answers with this. func currentText(at index: Int) -> String { guard fields.indices.contains(index) else { return "" } @@ -408,7 +351,7 @@ final class MultiRowEditState: ObservableObject { } func setValueContext(for field: FieldEditState, isWritable: Bool) -> SetValueContext? { - guard let databaseType, !field.isSchemaField else { return nil } + guard let databaseType else { return nil } return SetValueContext( columnType: field.columnTypeEnum, isNullable: field.isNullable, @@ -647,7 +590,6 @@ final class MultiRowEditState: ObservableObject { onFieldReverted = nil onFieldRemoved = nil onDetachedFieldChanged = nil - schemaDisplayRow = nil rowIDs = [] allRows = [] absentCells = [] diff --git a/apple/TablePro/Models/UI/PendingChangeKind.swift b/apple/TablePro/Models/UI/PendingChangeKind.swift index df375dd97d..93e2626fc7 100644 --- a/apple/TablePro/Models/UI/PendingChangeKind.swift +++ b/apple/TablePro/Models/UI/PendingChangeKind.swift @@ -5,17 +5,8 @@ import Foundation -/// What the window's Save command would commit, which is also whether it can commit at all. -/// -/// One value rather than five booleans read in five places. `updateToolbarPendingState()` folded -/// four of the five sources into `hasPendingChanges` and never read the fifth, so a Users & Roles -/// tab with staged principals left both the toolbar's commit button and Cmd+S dim while -/// `saveChanges()` already carried the branch that would have applied them. -/// -/// The tab decides the kind, because two kinds can be staged at once and only one of them is the -/// one the user is looking at. It decides nothing the user reads: the commit control's label is -/// `ToolbarContextResolver.commitVerb(for:)`, from the tab kind alone, because this value comes and -/// goes with every edit and a label that followed it moved the titlebar while the user typed. +// What the window's Save would commit, and whether it can commit at all. The tab decides the kind, since two kinds +// can be staged at once; the commit label comes from the tab kind alone, so it does not move while the user types. internal enum PendingChangeKind: Equatable, Hashable, Sendable { case data case structure @@ -23,7 +14,7 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable { case principals case file - /// Nil when nothing is staged for this tab, which is what leaves the commit control dim. + // Nil when nothing is staged for this tab, which leaves the commit control dim. internal static func resolve( tabType: TabType?, hasDataChanges: Bool, @@ -41,8 +32,7 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable { } switch tabType { case .createTable: - /// A definition that is not yet committable is not a pending change: the tab's own - /// validity gate is what `hasCreateTablePending` already answers. + // A definition that is not yet committable is not a pending change. return hasCreateTablePending ? .createTable : nil case .usersRoles: return hasPrincipalChanges ? .principals : nil @@ -56,8 +46,24 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable { } } - /// Structure outranks data, and data outranks a dirty file, because a structure edit rewrites - /// the table the staged rows are going into and has to be named first. + // Narrower than the commit: a dirty file and staged principals have no SQL to show, and a structure session + // previews only from its own Structure view, so staged ALTERs behind the Data view leave Preview with nothing. + internal static func offersPreview( + of kind: PendingChangeKind?, + hasDataChanges: Bool, + previewsSchemaChanges: Bool + ) -> Bool { + switch kind { + case .data, .createTable: + return true + case .structure: + return hasDataChanges || previewsSchemaChanges + case .principals, .file, nil: + return false + } + } + + // A structure edit rewrites the table the staged rows go into, so it is named before data, and data before a file. private static func contentKind( hasDataChanges: Bool, hasStructureChanges: Bool, diff --git a/apple/TablePro/Models/UI/RowInspectorContext.swift b/apple/TablePro/Models/UI/RowInspectorContext.swift index 7a1eb23bd1..8ebe1f73d2 100644 --- a/apple/TablePro/Models/UI/RowInspectorContext.swift +++ b/apple/TablePro/Models/UI/RowInspectorContext.swift @@ -5,41 +5,25 @@ import Foundation -/// Everything the inspector draws, and nothing else. -/// -/// This used to be one `InspectorContext` carrying the row, the JSON snapshot and a ten-row text -/// summary of the grid for the assistant, so a selection change rebuilt the assistant's context and -/// an assistant change rebuilt the row's. Splitting them along the surfaces that read them is what -/// lets each be `Equatable`: the old struct held an untyped `[(column:value:type:)]` tuple array, -/// which no equality could be written for, so every rebuild was a change whether or not anything -/// had moved. -/// -/// The fields themselves are not here. They live in `MultiRowEditState`, which both the data grid -/// and the schema grid already configure, and duplicating them into a second array is what made the -/// old tuple path dead computation: it ran the display-format and blob-format pipeline over every -/// cell on every tick and the view read only whether it was nil. +// Everything the inspector draws. The assistant's context is separate, so a selection change never rebuilds it. +// The row's fields are not here: they live in `MultiRowEditState`. internal struct RowInspectorContext: Equatable { internal let subject: InspectorSubject - /// Whether a row is selected at all, which the mode choice turns on. Separate from the subject - /// because a subject exists for a table with no row selected. + // Separate from the subject: a subject exists for a table with no row selected. internal let hasRow: Bool internal let isEditable: Bool internal let isRowDeleted: Bool - /// Present only on a tab that actually has a table. The old context handed over the - /// coordinator's one metadata slot unconditionally, latest-wins and cleared only on teardown, so - /// a query tab, a dashboard or an ER diagram showed the statistics of whichever table had been - /// opened last, including after its tab was closed. + // Only on a tab that has a table, so a query tab never shows the statistics of the last table opened. internal let tableMetadata: TableMetadata? - /// The same row the fields mode shows, as raw cell values, because the JSON mode decides from - /// the column's own type whether a value prints quoted. + // Raw cell values, because the JSON rendering quotes a value by its column's own type. internal let jsonRow: JSONRowSnapshot? - /// The table a structure row's type picker offers user-defined types for. Nil outside one. - internal let userDefinedTypeScope: DatabaseScope? + // Set while a Structure view or a Create Table tab is on screen; the inspector then edits its definitions. + internal var structure: StructureInspectorSource? internal static let empty = RowInspectorContext( subject: .empty, @@ -48,14 +32,25 @@ internal struct RowInspectorContext: Equatable { isRowDeleted: false, tableMetadata: nil, jsonRow: nil, - userDefinedTypeScope: nil + structure: nil ) } -/// What the assistant needs to know about the window it is docked in. -/// -/// Kept apart from the row context so that moving the selection does not rebuild it, and so that a -/// window whose assistant was never revealed never pays for the grid summary at all. +// The structure editor on screen, compared by identity. The inspector observes the editor itself, so a selection +// or an edit redraws it without a new context. +internal enum StructureInspectorSource: Equatable { + case table(StructureEditingSession) + case createTable(CreateTableDraft) + + internal static func == (lhs: StructureInspectorSource, rhs: StructureInspectorSource) -> Bool { + switch (lhs, rhs) { + case (.table(let left), .table(let right)): left === right + case (.createTable(let left), .createTable(let right)): left === right + default: false + } + } +} + internal struct AssistantContext: Equatable { internal let currentQuery: String? internal let queryResults: String? diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index faf51f7f82..f9c6cadf13 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -455,6 +455,9 @@ }, "No foreign key links %@ to the tables before it, so it is cross joined." : { + }, + "No Foreign Keys" : { + }, "No row holds the values it was loaded with. Another client may have changed or deleted it." : { @@ -699,6 +702,9 @@ }, "The changes were saved, but the type could not be read back. Reload before editing it again." : { + }, + "The changes you have not saved will be lost." : { + }, "The client certificate and key could not be written to a temporary file." : { "extractionState" : "manual", @@ -997,6 +1003,9 @@ } } } + }, + "-- Error generating SQL: %@" : { + }, "-- Once the rows are in" : { "localizations" : { @@ -4039,6 +4048,9 @@ } } } + }, + "%@ DDL" : { + }, "%@ does not accept this column change." : { "localizations" : { @@ -9387,9 +9399,15 @@ } } } + }, + "%d changes to this table's structure will be lost." : { + }, "%d characters" : { + }, + "%d Check Constraints Selected" : { + }, "%d columns" : { "localizations" : { @@ -9430,6 +9448,9 @@ } } } + }, + "%d Columns Selected" : { + }, "%d connected" : { "localizations" : { @@ -9876,6 +9897,9 @@ } } } + }, + "%d Foreign Keys Selected" : { + }, "%d included" : { "localizations" : { @@ -9916,6 +9940,9 @@ } } } + }, + "%d Indexes Selected" : { + }, "%d item(s) affected" : { "extractionState" : "manual", @@ -19973,6 +20000,9 @@ } } } + }, + "Add check constraint “%@”" : { + }, "Add Column" : { "localizations" : { @@ -20013,6 +20043,9 @@ } } } + }, + "Add column “%@”" : { + }, "Add Column…" : { "localizations" : { @@ -20496,6 +20529,9 @@ } } } + }, + "Add foreign key “%@”" : { + }, "Add Global Index" : { "extractionState" : "manual", @@ -20590,6 +20626,9 @@ } } } + }, + "Add index “%@”" : { + }, "Add indexes to improve query performance on frequently searched columns" : { "localizations" : { @@ -25520,6 +25559,9 @@ } } } + }, + "Another check constraint is already named %@." : { + }, "Another column is already named %@." : { "localizations" : { @@ -35750,6 +35792,32 @@ } } }, + "Change check constraint “%@”" : { + + }, + "Change check constraint “%@” to “%@”" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change check constraint “%1$@” to “%2$@”" + } + } + } + }, + "Change column “%@”" : { + + }, + "Change column “%@” to “%@”" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change column “%1$@” to “%2$@”" + } + } + } + }, "Change File" : { "extractionState" : "stale", "localizations" : { @@ -35831,6 +35899,19 @@ } } }, + "Change foreign key “%@”" : { + + }, + "Change foreign key “%@” to “%@”" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change foreign key “%1$@” to “%2$@”" + } + } + } + }, "Change Host to a name the certificate covers." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -35843,6 +35924,19 @@ } } }, + "Change index “%@”" : { + + }, + "Change index “%@” to “%@”" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Change index “%1$@” to “%2$@”" + } + } + } + }, "Change Not Saved" : { "localizations" : { "fr" : { @@ -36092,6 +36186,9 @@ } } } + }, + "Change the table comment" : { + }, "Change this in Settings > Integrations." : { "extractionState" : "stale", @@ -37177,6 +37274,9 @@ } } } + }, + "Check Constraint" : { + }, "Check constraint must have a name and an expression" : { "localizations" : { @@ -37187,6 +37287,12 @@ } } } + }, + "Check Constraint of %@" : { + + }, + "Check Constraints" : { + }, "Check constraints need %@ or later." : { "extractionState" : "manual", @@ -50210,6 +50316,9 @@ } } } + }, + "Convert the table to %@" : { + }, "Converting the table's character set rewrites every text column and loses characters the new one cannot hold." : { @@ -54912,6 +55021,9 @@ }, "Couldn't read the character sets" : { + }, + "Couldn't Read the DDL" : { + }, "Couldn't read the referenced table" : { "localizations" : { @@ -54931,6 +55043,9 @@ }, "Couldn't read the table options" : { + }, + "Couldn't Read the Triggers" : { + }, "Couldn't Refresh Materialized View" : { "localizations" : { @@ -56038,6 +56153,9 @@ } } } + }, + "Create Table cannot add a check constraint on this database." : { + }, "Create Table cannot add an index on this database." : { "localizations" : { @@ -60872,6 +60990,9 @@ } } } + }, + "Decrease Font Size" : { + }, "Decrypt" : { "localizations" : { @@ -62152,6 +62273,9 @@ } } } + }, + "Delete check constraint “%@”" : { + }, "Delete Column" : { "localizations" : { @@ -62192,6 +62316,9 @@ } } } + }, + "Delete column “%@”" : { + }, "Delete Columns" : { "localizations" : { @@ -62465,6 +62592,9 @@ } } } + }, + "Delete foreign key “%@”" : { + }, "Delete Group" : { "localizations" : { @@ -62555,6 +62685,9 @@ } } } + }, + "Delete index “%@”" : { + }, "Delete is off by default, so a first run cannot remove a row from the target." : { "localizations" : { @@ -84442,6 +84575,9 @@ } } } + }, + "Foreign Key" : { + }, "Foreign key %@ matches %@ on the target but the names differ." : { "localizations" : { @@ -84502,6 +84638,9 @@ } } } + }, + "Foreign Key of %@" : { + }, "Foreign key points at a column that does not exist: %@" : { "localizations" : { @@ -93548,6 +93687,9 @@ } } } + }, + "Increase Font Size" : { + }, "Increment" : { @@ -93763,6 +93905,9 @@ } } } + }, + "Index of %@" : { + }, "Index proposal on %@" : { "localizations" : { @@ -106717,6 +106862,9 @@ } } } + }, + "Marked for deletion. Undo or Undo Delete brings it back." : { + }, "Match" : { "localizations" : { @@ -113381,6 +113529,12 @@ } } } + }, + "New Check Constraint" : { + + }, + "New Column" : { + }, "New Connection" : { "localizations" : { @@ -113764,6 +113918,9 @@ } } } + }, + "New Foreign Key" : { + }, "New Group" : { "localizations" : { @@ -113859,6 +114016,9 @@ } } } + }, + "New Index" : { + }, "New Jump Host" : { "localizations" : { @@ -115473,6 +115633,9 @@ } } } + }, + "No Columns" : { + }, "No Columns Defined" : { "localizations" : { @@ -116456,6 +116619,9 @@ } } } + }, + "No DDL Available" : { + }, "No DDL found for %@." : { "extractionState" : "manual", @@ -117073,6 +117239,9 @@ } } } + }, + "No Indexes" : { + }, "No Indexes Defined" : { "localizations" : { @@ -144607,6 +144776,9 @@ } } } + }, + "Refreshing reads the table structure again and discards the changes you have not saved." : { + }, "Refreshing will discard all unsaved changes." : { "localizations" : { @@ -146799,6 +146971,9 @@ } } } + }, + "Remove the primary key" : { + }, "Remove the second entry." : { "extractionState" : "manual", @@ -154727,6 +154902,9 @@ } } } + }, + "Safe Mode is read-only for this connection." : { + }, "Safe mode level" : { "localizations" : { @@ -159750,6 +159928,9 @@ }, "Select a column" : { + }, + "Select a definition to edit it" : { + }, "Select a dump file this engine's own tool wrote." : { "localizations" : { @@ -160484,6 +160665,9 @@ } } } + }, + "Select one to edit it" : { + }, "Select Plugin" : { "localizations" : { @@ -162967,6 +163151,15 @@ } } } + }, + "Set the next AUTO_INCREMENT value to %@" : { + + }, + "Set the primary key to %@" : { + + }, + "Set the table's default character set to %@" : { + }, "Set to NULL" : { "localizations" : { @@ -183718,6 +183911,9 @@ } } } + }, + "The change to this table's structure will be lost." : { + }, "The changes were edited while the save was checking other tables. Save again to include them." : { @@ -191621,6 +191817,9 @@ } } } + }, + "The table is being read again after the save." : { + }, "The table is no longer reachable." : { "localizations" : { @@ -193791,6 +193990,12 @@ } } } + }, + "This check constraint needs a name." : { + + }, + "This check constraint needs an expression." : { + }, "This client does not have access to that connection." : { "localizations" : { diff --git a/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift b/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift index f7d8bc992e..20ae8e73fa 100644 --- a/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileRowDetailsView.swift @@ -31,7 +31,6 @@ struct DataFileRowDetailsView: View { editState: editState, isEditable: controller.isEditable && !controller.isBusy, databaseType: Self.databaseType, - userDefinedTypeScope: nil, offersDatabaseValues: false ) } diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift index be6494909c..b32be5c107 100644 --- a/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellContent.swift @@ -23,7 +23,8 @@ struct DataGridCellContent { static func placeholder( for rawValue: PluginCellValue, isAbsent: Bool = false, - unloadedByteCount: Int? = nil + unloadedByteCount: Int? = nil, + marksEmptyText: Bool = true ) -> DataGridCellPlaceholder? { if let unloadedByteCount { return .unloaded(byteCount: unloadedByteCount) } guard !isAbsent else { return .absent } @@ -32,7 +33,7 @@ struct DataGridCellContent { return .null case .text(let value): if value == PluginCellValue.defaultMarkerText { return .defaultMarker } - return value.isEmpty ? .empty : nil + return value.isEmpty && marksEmptyText ? .empty : nil case .bytes: return nil } diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift index 4db4c80598..8f7745aac0 100644 --- a/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridCellPresenter.swift @@ -16,6 +16,7 @@ final class DataGridCellPresenter { let findMatch: GridFindState.Match? let isEditable: Bool let checkboxColumns: Set + let marksEmptyText: Bool let palette: DataGridCellPalette let scope: ThemeScope let databaseType: DatabaseType? @@ -55,6 +56,7 @@ final class DataGridCellPresenter { findMatch: model.find.currentMatch, isEditable: model.configuration.isEditable, checkboxColumns: model.configuration.checkboxColumns, + marksEmptyText: model.configuration.marksEmptyText, palette: registry.palette, scope: model.themeScope, databaseType: model.configuration.traits.databaseType, @@ -248,7 +250,8 @@ final class DataGridCellPresenter { placeholder: DataGridCellContent.placeholder( for: rawValue, isAbsent: row.isAbsent(dataIndex), - unloadedByteCount: row.unloadedByteCount(dataIndex) + unloadedByteCount: row.unloadedByteCount(dataIndex), + marksEmptyText: pass.marksEmptyText ) ), state: DataGridCellState( @@ -294,7 +297,8 @@ final class DataGridCellPresenter { switch DataGridCellContent.placeholder( for: rawValue, isAbsent: row.isAbsent(dataIndex), - unloadedByteCount: row.unloadedByteCount(dataIndex) + unloadedByteCount: row.unloadedByteCount(dataIndex), + marksEmptyText: model.configuration.marksEmptyText ) { case .null: return String(localized: "NULL") case .empty: return String(localized: "Empty") diff --git a/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift b/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift index 05091ede89..54adc3e2e6 100644 --- a/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift +++ b/apple/TablePro/Views/DataGrid/Cells/DataGridMetrics.swift @@ -21,4 +21,14 @@ enum DataGridMetrics { static func rowHeight(_ setting: DataGridRowHeight, font: NSFont) -> CGFloat { max(CGFloat(setting.rawValue), (font.ascender - font.descender + font.leading).rounded(.up)) } + + // An overlay scroller floats over the last row, so only a legacy one adds height. + static func fittingHeight( + headerHeight: CGFloat, + rowCount: Int, + rowPitch: CGFloat, + horizontalScrollerHeight: CGFloat + ) -> CGFloat { + headerHeight + CGFloat(max(rowCount, 0)) * rowPitch + horizontalScrollerHeight + } } diff --git a/apple/TablePro/Views/DataGrid/DataGridContainerView.swift b/apple/TablePro/Views/DataGrid/DataGridContainerView.swift index 836a407943..a7aa4aa40f 100644 --- a/apple/TablePro/Views/DataGrid/DataGridContainerView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridContainerView.swift @@ -17,6 +17,15 @@ final class DataGridContainerView: NSView { private var firstResponderObservation: NSKeyValueObservation? private var windowObservers: [NSObjectProtocol] = [] private var scrollObservers: [NSObjectProtocol] = [] + private var pageScrollObserver: NSObjectProtocol? + + // A grid sized to its rows moves only with its page, so the page's scroll stands in for its own. + var followsPageScroll = false { + didSet { + guard followsPageScroll != oldValue else { return } + observePageScroll() + } + } var frozenPaneWidth: CGFloat = 0 { didSet { @@ -33,7 +42,10 @@ final class DataGridContainerView: NSView { addSubview(frozen.scrollView) frozen.scrollView.isHidden = true frozen.scrollView.wheelTarget = main.scrollView - main.scrollView.onTile = { [weak self] in self?.layoutFrozenPane() } + main.scrollView.onTile = { [weak self] in + self?.layoutFrozenPane() + self?.controller?.updateFittingHeight() + } observeScrolling() } @@ -127,9 +139,25 @@ final class DataGridContainerView: NSView { isSyncingScroll = false } + private func observePageScroll() { + if let pageScrollObserver { + NotificationCenter.default.removeObserver(pageScrollObserver) + } + pageScrollObserver = nil + guard followsPageScroll, window != nil, let clip = enclosingScrollView?.contentView else { return } + pageScrollObserver = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: clip, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.controller?.mainViewportDidMove() } + } + } + func tearDown() { scrollObservers.forEach(NotificationCenter.default.removeObserver) scrollObservers.removeAll() + followsPageScroll = false stopObservingWindow() } @@ -138,6 +166,7 @@ final class DataGridContainerView: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() stopObservingWindow() + observePageScroll() guard let window else { refreshFocus() return diff --git a/apple/TablePro/Views/DataGrid/DataGridScrollView.swift b/apple/TablePro/Views/DataGrid/DataGridScrollView.swift index 99afcdf289..ec8e8cee6c 100644 --- a/apple/TablePro/Views/DataGrid/DataGridScrollView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridScrollView.swift @@ -11,6 +11,16 @@ final class DataGridScrollView: NSScrollView { var onTile: (() -> Void)? // The frozen pane hands every wheel event to the main pane, so one momentum engine moves both. weak var wheelTarget: NSScrollView? + private var wheelRouting = DataGridWheelRouting() + + // The page the grid sits in scrolls vertically for it, so the main pane keeps only its sideways scroll. + var fitsRows = false { + didSet { + guard fitsRows != oldValue, paneKind == .main else { return } + hasVerticalScroller = !fitsRows + verticalScrollElasticity = fitsRows ? .none : .automatic + } + } init(kind: DataGridPane.Kind) { paneKind = kind @@ -43,11 +53,23 @@ final class DataGridScrollView: NSScrollView { } override func scrollWheel(with event: NSEvent) { - guard let wheelTarget else { - super.scrollWheel(with: event) + if let wheelTarget { + wheelTarget.scrollWheel(with: event) return } - wheelTarget.scrollWheel(with: event) + if fitsRows, let page = superview?.enclosingScrollView { + let axis = wheelRouting.axis( + phase: event.phase, + momentumPhase: event.momentumPhase, + deltaX: event.scrollingDeltaX, + deltaY: event.scrollingDeltaY + ) + if axis == .vertical { + page.scrollWheel(with: event) + return + } + } + super.scrollWheel(with: event) } // The frozen pane repeats cells the main table already publishes, so VoiceOver sees one table. @@ -61,3 +83,32 @@ final class DataGridScrollView: NSScrollView { return scroller.frame.height } } + +// One axis per trackpad gesture, momentum included, so a swipe never splits between the grid and its page. +// A wheel with no phases is a mouse, and each notch decides alone. +struct DataGridWheelRouting { + enum Axis { + case vertical + case horizontal + } + + private var gestureAxis: Axis? + + mutating func axis(phase: NSEvent.Phase, momentumPhase: NSEvent.Phase, deltaX: CGFloat, deltaY: CGFloat) -> Axis { + if phase.isEmpty, momentumPhase.isEmpty { + return Self.axis(deltaX: deltaX, deltaY: deltaY) ?? .vertical + } + if phase.contains(.began) || phase.contains(.mayBegin) { + gestureAxis = Self.axis(deltaX: deltaX, deltaY: deltaY) + } else if gestureAxis == nil, !phase.isEmpty { + // A gesture can begin with no movement; its first move decides. + gestureAxis = Self.axis(deltaX: deltaX, deltaY: deltaY) + } + return gestureAxis ?? .vertical + } + + static func axis(deltaX: CGFloat, deltaY: CGFloat) -> Axis? { + guard deltaX != 0 || deltaY != 0 else { return nil } + return abs(deltaY) >= abs(deltaX) ? .vertical : .horizontal + } +} diff --git a/apple/TablePro/Views/DataGrid/DataGridTableView.swift b/apple/TablePro/Views/DataGrid/DataGridTableView.swift index d1edf45a3e..618010def0 100644 --- a/apple/TablePro/Views/DataGrid/DataGridTableView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridTableView.swift @@ -51,7 +51,22 @@ final class DataGridTableView: NSTableView { // A revealed column must never park under the frozen pane, so the rect widens leftward by its width. override func scrollToVisible(_ rect: NSRect) -> Bool { guard paneKind == .main, let controller else { return super.scrollToVisible(rect) } - return super.scrollToVisible(controller.revealRect(for: rect, in: self)) + let widened = controller.revealRect(for: rect, in: self) + guard controller.model.configuration.fitsRows else { return super.scrollToVisible(widened) } + // Only sideways. AppKit calls this itself while the pane resizes, with rects spanning the header, so the page + // moves only for a reveal the grid starts (`scrollRowToVisible`, `reveal(row:displayColumn:)`). + let visible = visibleRect + return super.scrollToVisible(NSRect(x: widened.minX, y: visible.midY, width: widened.width, height: 1)) + } + + // Up, Down and type-select reveal through here; a table that fits its rows has nothing to scroll, so the page moves. + override func scrollRowToVisible(_ row: Int) { + guard paneKind == .main, let controller, controller.model.configuration.fitsRows else { + super.scrollRowToVisible(row) + return + } + guard row >= 0, row < numberOfRows else { return } + controller.revealOnPage(rect(ofRow: row)) } // MARK: - Keyboard diff --git a/apple/TablePro/Views/DataGrid/DataGridView.swift b/apple/TablePro/Views/DataGrid/DataGridView.swift index 1ed325eb32..8f66e7123c 100644 --- a/apple/TablePro/Views/DataGrid/DataGridView.swift +++ b/apple/TablePro/Views/DataGrid/DataGridView.swift @@ -16,7 +16,12 @@ struct DataGridView: View { var body: some View { VStack(spacing: 0) { DataGridFindBarSlot(model: model, find: model.find) - DataGridRepresentable(model: model) + if model.configuration.fitsRows { + DataGridRepresentable(model: model) + .frame(height: model.fittingHeight) + } else { + DataGridRepresentable(model: model) + } } } } diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift index ab5f2b8bd8..9553566c2e 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Changes.swift @@ -41,6 +41,7 @@ extension DataGridViewController { applyRowNumberWidth() syncSortIndicators() redrawVisibleRows() + updateFittingHeight() } // MARK: - Structural changes @@ -60,6 +61,7 @@ extension DataGridViewController { updateHighlightedColumns(model.selection) syncSortIndicators() startBackgroundPrewarm() + updateFittingHeight() } private func columnsDidChange() { @@ -88,6 +90,7 @@ extension DataGridViewController { } applyRowNumberWidth() startBackgroundPrewarm() + updateFittingHeight() } private func removeRows(_ indexes: IndexSet) { @@ -102,6 +105,7 @@ extension DataGridViewController { pane.tableView.removeRows(at: indexes, withAnimation: []) } applyRowNumberWidth() + updateFittingHeight() } private func updateRows(_ rows: IndexSet, columns displayColumns: IndexSet?) { @@ -169,6 +173,31 @@ extension DataGridViewController { pane.tableView.tile() } } + updateFittingHeight() + } + + // The table's own header, rows and legacy scroller: what a fitsRows grid needs to show every row. + var measuredFittingHeight: CGFloat { + let table = main.tableView + return DataGridMetrics.fittingHeight( + headerHeight: table.headerView?.frame.height ?? 0, + rowCount: table.numberOfRows, + rowPitch: table.rowHeight + table.intercellSpacing.height, + horizontalScrollerHeight: main.scrollView.legacyHorizontalScrollerHeight + ) + } + + // Published on the next turn: this runs inside SwiftUI updates and layout passes, which refuse a model change. + func updateFittingHeight() { + guard model.configuration.fitsRows, !isFittingHeightScheduled, + measuredFittingHeight != model.fittingHeight else { return } + isFittingHeightScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isFittingHeightScheduled = false + guard self.model.mountedView === self, self.model.configuration.fitsRows else { return } + self.model.setFittingHeight(self.measuredFittingHeight) + } } // The clip view keeps its offset in points, so a new row height would otherwise bring other rows into view. diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift b/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift index 7728930d6a..f3fb692a25 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController+Selection.swift @@ -180,10 +180,13 @@ extension DataGridViewController { func reveal(row: Int, displayColumn: Int?) { let table = main.tableView guard row >= 0, row < table.numberOfRows else { return } - table.scrollRowToVisible(row) - if let displayColumn { - revealColumn(displayColumn) + revealingInGrid { + table.scrollRowToVisible(row) + if let displayColumn { + revealColumn(displayColumn) + } } + revealOnPage(table.rect(ofRow: row)) } // Horizontal only; a frozen column is on screen at every offset. @@ -193,7 +196,46 @@ extension DataGridViewController { let index = main.tableIndex(forDisplayPosition: displayColumn, columns: model.columns) else { return } let columnRect = table.rect(ofColumn: index) let visible = table.visibleRect - _ = table.scrollToVisible(NSRect(x: columnRect.minX, y: visible.midY, width: columnRect.width, height: 1)) + revealingInGrid { + _ = table.scrollToVisible(NSRect(x: columnRect.minX, y: visible.midY, width: columnRect.width, height: 1)) + } + } + + private func revealingInGrid(_ body: () -> Void) { + let wasRevealing = isRevealingInGrid + isRevealingInGrid = true + body() + isRevealingInGrid = wasRevealing + } + + // A grid sized to its rows shows them all, so a row it reveals is brought into view by the page it sits in. + // Kept until the grid has grown to a row it just took, since the page lays out after this turn. + // A reveal the grid cannot show within two turns is dropped, so a later layout never scrolls the page on its own. + func revealOnPage(_ rectInTable: NSRect) { + guard model.configuration.fitsRows, !isRevealingInGrid else { return } + pendingPageReveal = rectInTable + performPageReveal() + guard pendingPageReveal != nil else { return } + DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { [weak self] in + self?.performPageReveal() + self?.pendingPageReveal = nil + } + } + } + + func performPageReveal() { + guard let rect = pendingPageReveal else { return } + let target = container.convert(rect, from: main.tableView) + let bounds = container.bounds + guard target.maxY <= bounds.maxY + 1 else { return } + pendingPageReveal = nil + let visible = container.visibleRect + guard target.minY < visible.minY || target.maxY > visible.maxY else { return } + // Kept within the grid's width: the grid already moved its columns, so the page moves only vertically. + let minX = min(max(target.minX, bounds.minX), bounds.maxX) + let maxX = max(min(target.maxX, bounds.maxX), minX) + _ = container.scrollToVisible(NSRect(x: minX, y: target.minY, width: maxX - minX, height: target.height)) } func revealActiveCell() { @@ -299,6 +341,9 @@ extension DataGridViewController { while let event = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { if event.type == .leftMouseUp { break } _ = main.tableView.autoscroll(with: event) + if model.configuration.fitsRows { + _ = container.autoscroll(with: event) + } guard let cell = dragCell(at: event.locationInWindow, rowsOnly: drag.mode == .rows) else { continue } if drag.mode == .rows ? cell.row != start.row : cell != start { moved = true diff --git a/apple/TablePro/Views/DataGrid/DataGridViewController.swift b/apple/TablePro/Views/DataGrid/DataGridViewController.swift index 3776585b3e..ea5af37b7b 100644 --- a/apple/TablePro/Views/DataGrid/DataGridViewController.swift +++ b/apple/TablePro/Views/DataGrid/DataGridViewController.swift @@ -36,6 +36,9 @@ final class DataGridViewController: NSViewController, DataGridMountedView { var prewarmTask: Task? var prewarmResumeTask: Task? var reorderToolTipOwner: NSString? + var isFittingHeightScheduled = false + var pendingPageReveal: NSRect? + var isRevealingInGrid = false private var observers: [NSObjectProtocol] = [] private var cancellables: Set = [] private var themeCancellable: AnyCancellable? @@ -109,6 +112,7 @@ final class DataGridViewController: NSViewController, DataGridMountedView { knownWidths = [:] knownWidthsSawRows = false savedWidthIDs = [] + pendingPageReveal = nil mount() } @@ -124,6 +128,7 @@ final class DataGridViewController: NSViewController, DataGridMountedView { prewarmTask = nil prewarmResumeTask?.cancel() prewarmResumeTask = nil + pendingPageReveal = nil observers.forEach(NotificationCenter.default.removeObserver) observers.removeAll() cancellables.removeAll() @@ -153,6 +158,11 @@ final class DataGridViewController: NSViewController, DataGridMountedView { needsViewportRestore = false applyStagedViewport() } + updateFittingHeight() + if pendingPageReveal != nil { + // The page scrolls once its own layout is done, so its document already holds the grid's new height. + DispatchQueue.main.async { [weak self] in self?.performPageReveal() } + } } // MARK: - DataGridMountedView @@ -297,6 +307,8 @@ final class DataGridViewController: NSViewController, DataGridMountedView { applyRowReorder(configuration.rowReorder, to: table) } main.tableView.setAccessibilityLabel(configuration.accessibilityLabel ?? String(localized: "Data grid")) + main.scrollView.fitsRows = configuration.fitsRows + container.followsPageScroll = configuration.fitsRows applyRowHeight() } diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index a201566f7c..e96674225f 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -553,15 +553,15 @@ struct MainEditorContentView: View { private func createTableContent(tab: QueryTab) -> some View { Group { if let draft = coordinator.createTableDrafts[tab.id] { - CreateTableView( - connection: connection, - scope: structureScope(for: tab), - coordinator: coordinator, - draft: draft - ) + CreateTableView(draft: draft, coordinator: coordinator) } else { Color.clear - .onAppear { coordinator.createTableDrafts[tab.id] = CreateTableDraft() } + .onAppear { + coordinator.createTableDrafts[tab.id] = CreateTableDraft( + connection: connection, + scope: structureScope(for: tab) + ) + } } } .id(tab.id) @@ -576,13 +576,9 @@ struct MainEditorContentView: View { Group { if let session = coordinator.structureSessions[tab.id], session.identity == identity { TableStructureView( - tableName: tableName, - connection: connection, - databaseName: scope?.database ?? "", - schemaName: scope?.schema, + session: session, toolbarState: coordinator.toolbarState, - coordinator: coordinator, - session: session + coordinator: coordinator ) } else { Color.clear @@ -880,14 +876,12 @@ struct MainEditorContentView: View { private func statusBar(tab: QueryTab, selectedRowCount: Int) -> some View { let resolvedRows = resolvedTableRows(for: tab) - let structureFooter = coordinator.structureSessions[tab.id]?.footer ?? StructureFooterCapability() let isExecuting = coordinator.tabExecution.isBusy(tab.id) let snapshot = StatusBarSnapshot( tab: tab, tableRows: resolvedRows, displayRowCount: coordinator.displayIDs(forTab: tab.id)?.count, isFetching: isExecuting, - hasStructureActions: structureFooter.isActive, isQueryPlan: tab.display.activeExplainResult != nil, paginationCapability: coordinator.paginationCapability, hasServerOutput: !(tab.display.activeResultSet?.serverOutput.isEmpty ?? true), @@ -935,7 +929,6 @@ struct MainEditorContentView: View { onGoToPage: onGoToPage, onRequestExactCount: { coordinator.paginationCoordinator.requestExactRowCount() } ), - structureFooter: structureFooter, execution: ExecutionReadout( tabId: tab.id, execution: coordinator.tabExecution, @@ -956,9 +949,7 @@ struct MainEditorContentView: View { } }, onToggleFilters: { coordinator.toggleFilterPanel() }, - onFetchAll: { coordinator.fetchAllRows() }, - onStructureAdd: { coordinator.structureActions?.addRow?() }, - onStructureRemove: { coordinator.structureActions?.removeRow?() } + onFetchAll: { coordinator.fetchAllRows() } ) } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift index 1be85f35cf..5b0a86b3ee 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift @@ -1,9 +1,7 @@ import AppKit import Foundation -/// The single answer to "would closing or quitting destroy something the user cannot get back". -/// The close path (one tab) and the quit path (every tab of every connection) previously each -/// carried their own partial version of this and had drifted apart. +// The one answer to "would closing or quitting destroy something the user cannot get back", for close and quit alike. extension MainContentCoordinator { var hasPendingDestructiveTableOps: Bool { guard let session = DatabaseManager.shared.session(for: connectionId) else { return false } @@ -14,62 +12,34 @@ extension MainContentCoordinator { trailingPaneState?.inspector.editState.hasEdits ?? false } - /// Only the selected tab's editors are mounted, so only the selected tab has live state on the - /// coordinator to read. + // Only the selected tab's editors are mounted, so only it has live state on the coordinator. func isSelectedTab(_ tab: QueryTab) -> Bool { tabManager.selectedTabId == tab.id } - /// Work in one tab that only saving can recover. - /// - /// Where that work lives depends on whether the tab is selected, because a tab's editors are - /// mounted only while it is. The selected tab's live edits sit on the coordinator, in - /// `changeManager` and `toolbarState`; a background tab carries whatever was snapshotted into - /// `pendingChanges` the last time it was switched away from. Reading the snapshot for the - /// selected tab answers "no" for the gesture users make most, editing a cell and closing the - /// tab they are looking at, because nothing has switched away to write the snapshot yet. - /// - /// Connection-scoped work is deliberately absent. A staged TRUNCATE or an unsaved sidebar edit - /// belongs to the connection rather than to any one tab, so letting it gate a tab's close would - /// pose a question neither Save nor Don't Save could answer for the tab being closed. - /// - /// A scratch query tab is absent too, and that one is a decision rather than an omission: its - /// text is persisted with the tab and filed in `RecentlyClosedTabStore` on close, so it comes - /// back. Grid edits do not. `TabChangeSnapshot` is not `Codable` and `PersistedTab` carries no - /// change fields, so closing a table tab is the one gesture that destroys them for good. + // Connection-scoped work (a staged TRUNCATE, a sidebar edit) is left out: no tab's Save or Don't Save answers it. + // A scratch query's text is left out too: it is persisted and comes back from Reopen Closed Tab. func hasUnsavedWork(in tab: QueryTab?) -> Bool { guard let tab else { return false } return savability(of: tab) != .nothingAtRisk } - /// Whether one tab, named by id, is holding work a move would lose. - /// - /// Detaching a tab into its own window carries the `QueryTab` and nothing the coordinator holds - /// beside it, so this is what the command reads before offering itself. An id that names no tab - /// answers true: refusing a move is recoverable, performing one on a tab this window cannot see - /// is not. + // An unknown id answers true: refusing a move is recoverable, moving a tab this window cannot see is not. func hasUnsavedWork(forTab id: UUID) -> Bool { guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return true } return savability(of: tab) != .nothingAtRisk } - /// What a save can actually do for one tab, which is not the same question as whether it holds - /// work. A batch close asks both: it saves what it can reach and leaves the rest open, so the - /// two answers have to come from one switch. Reading the categories apart, in a second - /// classifier beside this one, is how the alert and the save came to disagree about what Save - /// covers. + // One switch answers both "holds work" and "can Save reach it", so a batch close's alert and save cannot disagree. enum TabWorkSavability: Equatable { case nothingAtRisk - /// Reachable without the tab on screen: its file, and the ALTERs staged in its own session. + // Reachable without the tab on screen: its file, and the ALTERs staged in its own session. case saveable - /// Only the tab's own editors can save it: grid edits, staged principals, a table draft. + // Only the tab's own editors can save it: grid edits, staged principals, a table draft. case mountedOnly } - /// The unreachable kinds are asked about first. A tab can hold more than one: a query opened - /// from a file, edited, with unsaved cell edits in its result. Answering `.saveable` for the file - /// would let a close write the file and destroy the grid edits beside it, which is the loss this - /// category exists to prevent. + // Unreachable kinds first: answering `.saveable` for a dirty file would let a close drop the grid edits beside it. func savability(of tab: QueryTab) -> TabWorkSavability { guard isSelectedTab(tab) else { switch tab.tabType { @@ -90,12 +60,7 @@ extension MainContentCoordinator { tab.content.isFileDirty ? .saveable : .nothingAtRisk } - /// The selected tab's editors are mounted, so everything they hold is reachable and saving the - /// selected tab covers all of it. A deselected tab left its editors behind, which is why - /// `savability` answers for it from what they wrote down before they went: users and roles keeps - /// its own record because its view model outlives the view while `usersRolesActions` does not, - /// so the staged principals survive a deselect even though nothing on the coordinator can still - /// reach them to apply. + // Users & Roles keeps its own record because its view model outlives `usersRolesActions`. private func liveUnsavedWork(in tab: QueryTab) -> Bool { switch tab.tabType { case .usersRoles: @@ -104,17 +69,14 @@ extension MainContentCoordinator { return toolbarState.hasCreateTablePending || hasTableDraftWork(in: tab) default: return changeManager.hasChanges - || toolbarState.hasStructureChanges || hasStagedStructureEdits(in: tab) || hasStagedTypeChanges(in: tab) } } - /// Staged ALTERs read from the tab's own session rather than from `toolbarState`, which only - /// ever describes the tab on screen. A background tab keeps its session, so this is the only - /// answer that holds once the user has switched away from it. + // Read from the tab's session, mounted or not, so it holds in Data view and in a background tab. func hasStagedStructureEdits(in tab: QueryTab) -> Bool { - (structureSessions[tab.id]?.changeManager.hasChanges ?? false) + (structureSessions[tab.id].map { $0.changeManager.hasChanges || $0.editor.hasPendingFieldDraft } ?? false) || (erDiagramModels[tab.id]?.holdsUnsavedWork ?? false) } @@ -128,22 +90,15 @@ extension MainContentCoordinator { } func hasTableDraftWork(in tab: QueryTab) -> Bool { - createTableDrafts[tab.id]?.holdsWork ?? false + createTableDrafts[tab.id].map { $0.holdsWork || $0.editor.hasPendingFieldDraft } ?? false } - /// The dot on the tab and in the window's close button. Deliberately broader than - /// `hasUnsavedWork(in:)`: a scratch query tab shows the dot because its text is unsaved, yet - /// closing it asks nothing because the text comes back. It is never narrower, so anything that - /// would raise the save prompt is marked before the user reaches for the close button. + // Never narrower than `hasUnsavedWork(in:)`; broader for a scratch tab, whose text is unsaved but comes back. func showsUnsavedIndicator(for tab: QueryTab) -> Bool { tab.showsUnsavedIndicator || hasUnsavedWork(in: tab) } - /// Pushes the selected tab's answer onto the window's close button. - /// - /// The editor's text binding does this on every keystroke, which covers typing. A command that - /// changes the text without going through that binding has to say so itself, or the dot - /// describes the tab as it was before the command ran. + // For changes that bypass the editor's text binding, which pushes the dot on every keystroke itself. func refreshUnsavedIndicator() { guard let tab = tabManager.selectedTab, let window = contentWindow else { return } let showsIndicator = showsUnsavedIndicator(for: tab) @@ -156,13 +111,11 @@ extension MainContentCoordinator { changeManager.hasChanges || hasPendingDestructiveTableOps || hasSidebarEdits - || toolbarState.hasStructureChanges || toolbarState.hasCreateTablePending || tabManager.tabs.contains { hasUnsavedWork(in: $0) } } - /// Tabs resolved against live view state (cursor offset, sort column names) so a reopened tab - /// comes back where the user left it. + // Resolved against live view state (cursor, sort column names) so a reopened tab comes back where it was left. func tabsForRecoveryCapture() -> [QueryTab] { tabManager.tabs.map { enrichedForPersistence($0) } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index 244e8e2069..3bc6fa3447 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -58,7 +58,7 @@ extension MainContentCoordinator { return } if tab.display.resultsViewMode == .structure { - structureActions?.refresh?() + activeStructureSession?.refresh() return } reloadActiveTableData(hasPendingTableOps: hasPendingTableOps, onDiscard: onDiscard) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift index ff9cafde52..3d10617af6 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift @@ -11,7 +11,7 @@ import TableProPluginKit extension MainContentCoordinator { // MARK: - SQL Preview - /// Routes SQL preview request to the appropriate handler based on current tab mode + // A Structure view with nothing staged previews the grid edits, which is what its Save would write. func handlePreviewSQL( pendingTruncates: Set, pendingDeletes: Set, @@ -24,9 +24,10 @@ extension MainContentCoordinator { } if let tabId = tabManager.selectedTabId, userTypeEditors[tabId]?.hasChanges == true { previewUserTypeChanges(tabId: tabId) - } else if tabManager.selectedTab?.display.resultsViewMode == .structure { - // Structure view handles its own preview via direct call - structureActions?.previewSQL?() + } else if tabManager.selectedTab?.tabType == .createTable { + activeCreateTableDraft?.previewSQL(coordinator: self) + } else if let session = activeStructureSession, session.hasChanges { + session.previewChanges() } else { generatePreviewSQL( pendingTruncates: pendingTruncates, @@ -36,7 +37,6 @@ extension MainContentCoordinator { } } - /// Generate SQL preview of all pending changes with inlined parameters func generatePreviewSQL( pendingTruncates: Set, pendingDeletes: Set, @@ -50,7 +50,9 @@ extension MainContentCoordinator { ) toolbarState.previewStatements = plan.displayStatements } catch { - toolbarState.previewStatements = ["-- Error generating SQL: \(error.localizedDescription)"] + toolbarState.previewStatements = [ + String(format: String(localized: "-- Error generating SQL: %@"), error.localizedDescription) + ] } activeSheet = .sqlPreview } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Structure.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Structure.swift new file mode 100644 index 0000000000..c1eef9e90e --- /dev/null +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Structure.swift @@ -0,0 +1,25 @@ +// +// MainContentCoordinator+Structure.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + // The selected tab's structure session while its Structure view is on screen. Commands act on it; a session + // behind the Data view keeps its staged edits for the close prompt but takes no commands. + var activeStructureSession: StructureEditingSession? { + guard let tab = tabManager.selectedTab, tab.display.resultsViewMode == .structure else { return nil } + return structureSessions[tab.id] + } + + var activeCreateTableDraft: CreateTableDraft? { + guard let tab = tabManager.selectedTab, tab.tabType == .createTable else { return nil } + return createTableDrafts[tab.id] + } + + // Whichever structure editor is on screen, for the inspector. + var activeStructureEditor: StructureDefinitionEditor? { + activeStructureSession?.editor ?? activeCreateTableDraft?.editor + } +} diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift index 876e19e03a..b2bb6f89cc 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift @@ -95,7 +95,6 @@ extension MainContentCoordinator { createTableDrafts.removeValue(forKey: tabId) erDiagramModels.removeValue(forKey: tabId) tabsWithStagedPrincipals.remove(tabId) - toolbarState.hasStructureChanges = false toolbarState.hasCreateTablePending = false toolbarState.hasPrincipalChanges = false toolbarState.hasTypeChanges = false @@ -113,7 +112,6 @@ extension MainContentCoordinator { tableMetadataCache.removeValue(forKey: tab.id) guard isSelectedTab(tab) else { return } changeManager.clearChangesAndUndoHistory() - toolbarState.hasStructureChanges = false toolbarState.hasCreateTablePending = false toolbarState.hasPrincipalChanges = false toolbarState.hasTypeChanges = false diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift index 66afcadb7a..b87f556999 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift @@ -76,8 +76,8 @@ extension MainContentCoordinator { for tab in tabs { guard let session = structureSessions[tab.id] else { continue } let isOnScreen = tab.id == selectedId && tab.display.resultsViewMode == .structure - if isOnScreen, !session.changeManager.hasChanges, let refresh = structureActions?.refresh { - refresh() + if isOnScreen, !session.hasChanges { + session.refresh() } else { session.markStructureStale() } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 86bd465bca..3c82d37c5b 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -2,9 +2,6 @@ // MainContentView+Bindings.swift // TablePro // -// Extension containing computed bindings for MainContentView. -// Extracted to reduce main view complexity. -// import SwiftUI import TableProPluginKit @@ -12,7 +9,8 @@ import TableProPluginKit extension MainContentView { // MARK: - Inspector Selection - // Whose rows the active grid model's selection indexes: the data tab's, a schema grid's, or nobody's. + // Whose rows the active grid model's selection indexes: the data tab's, a schema grid's, or nobody's. A schema + // grid's selection is drawn by the structure inspector, which observes its editor itself. var gridSelectionOwner: GridSelectionOwner { GridSelectionOwner.resolve( tabType: coordinator.tabManager.selectedTab?.tabType, @@ -24,41 +22,27 @@ extension MainContentView { gridSelectionOwner == .dataGrid ? coordinator.activeGridModel : nil } - var selectedInspectorRow: InspectorRow? { - guard gridSelectionOwner == .schemaGrid, - let displayRow = coordinator.activeGridModel?.selection.affectedRows.first else { return nil } - return coordinator.inspectorRowSource?.inspectorRow(atDisplayRow: displayRow) + var structureInspectorSource: StructureInspectorSource? { + if let session = coordinator.activeStructureSession { return .table(session) } + if let draft = coordinator.activeCreateTableDraft { return .createTable(draft) } + return nil } // MARK: - Inspector Subject var hasInspectableRow: Bool { - switch gridSelectionOwner { - case .schemaGrid: return selectedInspectorRow != nil - case .dataGrid: return dataGridModel?.selection.isEmpty == false - case .none: return false - } + guard gridSelectionOwner == .dataGrid else { return false } + return dataGridModel?.selection.isEmpty == false } - // A schema grid's selection is a column definition with no position, so it is not named "Row 0 of 0". var inspectorSubject: InspectorSubject { switch gridSelectionOwner { - case .schemaGrid: - guard let row = selectedInspectorRow else { - guard let table = currentTab?.tableContext.tableName else { return .empty } - return .tableOnly(table: table) - } - let name = row.fields.first?.value ?? "" - return .columnDefinition( - column: name.isEmpty ? String(localized: "Column") : name, - table: currentTab?.tableContext.tableName - ) case .dataGrid: guard let table = qualifiedTableName else { return dataRowSubject(table: String(localized: "Result")) ?? .empty } return dataRowSubject(table: table) ?? .tableOnly(table: table) - case .none: + case .schemaGrid, .none: // A chart, a dashboard or a diagram owns no grid; the table is still the subject of its statistics. guard let table = qualifiedTableName else { return .empty } return .tableOnly(table: table) @@ -109,9 +93,6 @@ extension MainContentView { // MARK: - Sidebar Edit State var isSidebarEditable: Bool { - if gridSelectionOwner == .schemaGrid { - return selectedInspectorRow?.isEditable ?? false - } guard let model = dataGridModel, let tab = coordinator.tabManager.selectedTab, tab.tabType == .table || tab.tableContext.tableName != nil, @@ -176,7 +157,8 @@ extension MainContentView { metadataVersion: currentTab?.metadataVersion ?? -1, resultsViewMode: currentTab?.display.resultsViewMode ?? .data, inspectorRowSourceRevision: coordinator.inspectorRowSourceRevision, - gridDisplayRevision: coordinator.gridDisplayRevision + gridDisplayRevision: coordinator.gridDisplayRevision, + structureEditor: coordinator.activeStructureEditor.map { ObjectIdentifier($0) } ) } } @@ -188,6 +170,8 @@ struct InspectorTrigger: Equatable { let resultsViewMode: ResultsViewMode let inspectorRowSourceRevision: Int let gridDisplayRevision: Int + // A structure session or Create Table draft appears after its view first mounts, which moves nothing else here. + var structureEditor: ObjectIdentifier? } enum InspectorValueDisplayFormatResolver { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index e7c1f83bff..2278ef0f22 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -147,11 +147,9 @@ extension MainContentView { func updateSidebarEditState() { // A selection change, refresh or undo rebuilds the fields, so the typed run ends here, not in another row's step. coordinator.endInspectorEditRun() + // A structure definition is edited by the structure inspector, not the row fields. switch gridSelectionOwner { - case .schemaGrid: - updateSchemaSidebarEditState() - return - case .none: + case .schemaGrid, .none: clearSidebarEditState() return case .dataGrid: @@ -307,36 +305,4 @@ extension MainContentView { trailingPaneState.inspector.editState.onFieldRemoved = nil trailingPaneState.inspector.editState.onDetachedFieldChanged = nil } - - // Edits go back to the owning grid so they land in its change manager with one undo step, like an inline edit. - private func updateSchemaSidebarEditState() { - guard let displayRow = coordinator.activeGridModel?.selection.affectedRows.first, - let row = selectedInspectorRow - else { - clearSidebarEditState() - return - } - - trailingPaneState.inspector.editState.configure(schemaFields: row.fields, displayRow: displayRow) - - guard row.isEditable else { - clearSidebarEditHandlers() - return - } - - let capturedCoordinator = coordinator - trailingPaneState.inspector.editState.onFieldExpressionChanged = nil - trailingPaneState.inspector.editState.onFieldValuesChanged = nil - trailingPaneState.inspector.editState.onFieldReverted = nil - trailingPaneState.inspector.editState.onFieldRemoved = nil - trailingPaneState.inspector.editState.onDetachedFieldChanged = nil - trailingPaneState.inspector.editState.onFieldChanged = { fieldIndex, newValue, _ in - capturedCoordinator.inspectorRowSource?.commitInspectorField( - displayRow: displayRow, - fieldIndex: fieldIndex, - value: newValue.asText - ) - capturedCoordinator.inspectorRowSourceRevision += 1 - } - } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 5e148f871e..961b683705 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -2,9 +2,6 @@ // MainContentView+Helpers.swift // TablePro // -// Extension containing helper methods and inspector context -// for MainContentView. Extracted to reduce main view complexity. -// import SwiftUI import TableProPluginKit @@ -75,7 +72,7 @@ extension MainContentView { isRowDeleted: isSelectedRowDeleted, tableMetadata: tableMetadataForCurrentTab, jsonRow: jsonRowSnapshotForSidebar, - userDefinedTypeScope: structureTypeScope + structure: structureInspectorSource ) updateAssistantContext() } @@ -96,17 +93,6 @@ extension MainContentView { currentTab.flatMap { coordinator.currentTableMetadata(for: $0) } } - // The tab's own database and schema, never the sidebar's: a tab bound to another database edits that one. - private var structureTypeScope: DatabaseScope? { - guard let tab = currentTab, tab.tabType == .table || tab.tabType == .createTable else { return nil } - let database = tab.tableContext.databaseName ?? coordinator.browseDatabaseName - return DatabaseScope( - connectionId: coordinator.connection.id, - database: database, - schema: tab.tableContext.schemaName - ) - } - private func cachedQueryResultsSummary() -> String? { guard let tab = currentTab else { return nil } if let cache = queryResultsSummaryCache, diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 84b9a6e042..9388979b8f 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -76,19 +76,32 @@ extension MainContentView { // One resolution of what is staged, so commit, its verb and Preview SQL agree; staged principals once left Cmd+S dim. func updateToolbarPendingState() { + let selectedTab = tabManager.selectedTab + let hasDataChanges = changeManager.hasChanges || !pendingTruncates.isEmpty || !pendingDeletes.isEmpty + // The session, not the mounted view: leaving Structure for Data used to dim Save over staged edits. + let hasStagedStructure = selectedStructureSession?.hasChanges ?? false + let previewsSchemaChanges = toolbarState.hasTypeChanges || coordinator.selectedDiagramHasStagedEdits + || (hasStagedStructure && selectedTab?.display.resultsViewMode == .structure) let kind = PendingChangeKind.resolve( - tabType: tabManager.selectedTab?.tabType, - hasDataChanges: changeManager.hasChanges || !pendingTruncates.isEmpty || !pendingDeletes.isEmpty, - hasStructureChanges: toolbarState.hasStructureChanges || toolbarState.hasTypeChanges + tabType: selectedTab?.tabType, + hasDataChanges: hasDataChanges, + hasStructureChanges: hasStagedStructure || toolbarState.hasTypeChanges || coordinator.selectedDiagramHasStagedEdits, hasCreateTablePending: toolbarState.hasCreateTablePending, hasPrincipalChanges: toolbarState.hasPrincipalChanges, - isFileDirty: tabManager.selectedTab?.content.isFileDirty ?? false + isFileDirty: selectedTab?.content.isFileDirty ?? false ) toolbarState.pendingChange = kind toolbarState.hasPendingChanges = kind != nil - // Narrower than the commit: a dirty query file and staged principals raise the commit but have no grid SQL to show. - toolbarState.hasDataPendingChanges = kind == .data || kind == .structure + toolbarState.hasDataPendingChanges = PendingChangeKind.offersPreview( + of: kind, + hasDataChanges: hasDataChanges, + previewsSchemaChanges: previewsSchemaChanges + ) + } + + var selectedStructureSession: StructureEditingSession? { + tabManager.selectedTabId.flatMap { coordinator.structureSessions[$0] } } // The edited dot is written directly: the unsaved work it reports stays in the window under an agent conversation. diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+Structure.swift b/apple/TablePro/Views/Main/MainContentCommandActions+Structure.swift new file mode 100644 index 0000000000..d6fb86f73c --- /dev/null +++ b/apple/TablePro/Views/Main/MainContentCommandActions+Structure.swift @@ -0,0 +1,117 @@ +// +// MainContentCommandActions+Structure.swift +// TablePro +// + +import Foundation + +// Structure commands go to the selected tab's own session or Create Table draft, which hold their edits mounted or not. +extension MainContentCommandActions { + // MARK: - Save and discard + + // From Data, only when no grid edit is ahead of it: Save stays live after the user leaves Structure. + var stagedStructureSession: StructureEditingSession? { + guard let coordinator, + let tab = coordinator.tabManager.selectedTab, + let session = coordinator.structureSessions[tab.id], + session.hasChanges else { return nil } + if tab.display.resultsViewMode == .structure { return session } + return hasDataWork ? nil : session + } + + var canDiscardChanges: Bool { discardTarget != nil } + + func discardChanges() { + switch discardTarget { + case .structure(let session): + session.discardChangesAfterConfirming() + case .data: + Task { await discardDataWorkAfterConfirming() } + case nil: + break + } + } + + private enum DiscardTarget { + case structure(StructureEditingSession) + case data + } + + // Create Table has no saved state to return to. + private var discardTarget: DiscardTarget? { + guard let tab = coordinator?.tabManager.selectedTab, + tab.tabType == .table || tab.tabType == .query else { return nil } + if let session = stagedStructureSession { return .structure(session) } + return hasDataWork ? .data : nil + } + + private func discardDataWorkAfterConfirming() async { + let confirmed = await AlertHelper.confirmDestructive( + title: String(localized: "Discard Changes?"), + message: String(localized: "The changes you have not saved will be lost."), + confirmButton: String(localized: "Discard Changes"), + cancelButton: String(localized: "Cancel"), + window: closeAnchorWindow + ) + guard confirmed else { return } + discardDataWork() + } + + // MARK: - Rows + + var canAddSchemaRow: Bool { + if let session = coordinator?.activeStructureSession { return session.canAddRow } + return coordinator?.activeCreateTableDraft?.canAddRow ?? false + } + + var canRemoveSchemaRows: Bool { + if let session = coordinator?.activeStructureSession { return session.canRemoveRows } + return coordinator?.activeCreateTableDraft?.canRemoveRows ?? false + } + + var canPasteSchemaRows: Bool { + if let session = coordinator?.activeStructureSession { return session.canPasteRows } + guard let draft = coordinator?.activeCreateTableDraft, draft.canAddRow else { return false } + return draft.editor.canPaste + } + + func addSchemaRow() { + if let session = coordinator?.activeStructureSession { + session.addRow() + } else { + coordinator?.activeCreateTableDraft?.addRow() + } + } + + func removeSchemaRows() { + if let session = coordinator?.activeStructureSession { + session.removeSelectedRows() + } else { + coordinator?.activeCreateTableDraft?.removeSelectedRows() + } + } + + func duplicateSchemaRows() { + if let session = coordinator?.activeStructureSession { + session.duplicateSelectedRows() + } else if let draft = coordinator?.activeCreateTableDraft, draft.canAddRow { + draft.editor.duplicateSelection() + } + } + + func copySchemaRows() { + if let session = coordinator?.activeStructureSession { + session.copySelectedRows() + } else { + coordinator?.activeCreateTableDraft?.editor.copySelection() + } + } + + func pasteSchemaRows() { + if let session = coordinator?.activeStructureSession { + session.pasteRows() + } else if canPasteSchemaRows { + coordinator?.activeCreateTableDraft?.editor.paste() + } + } +} diff --git a/apple/TablePro/Views/Main/MainContentCommandActions+UndoState.swift b/apple/TablePro/Views/Main/MainContentCommandActions+UndoState.swift index 76dd50c879..f35e03c609 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions+UndoState.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions+UndoState.swift @@ -5,18 +5,37 @@ import AppKit -/// A connection hosts three undo domains, picked by the active tab: Users & Roles and -/// the structure editor keep their own histories, and everything else registers on -/// the window's `UndoManager`, which `TabWindowController` resolves to the selected -/// connection's own history. Availability and the menu title both have to follow -/// whichever one is live, so they resolve through the same branch the commands do. +// The structure editor's own history, which the window's undo manager never sees. +@MainActor +protocol StructureUndoHistory: AnyObject { + var canUndo: Bool { get } + var canRedo: Bool { get } + var undoMenuItemTitle: String { get } + var redoMenuItemTitle: String { get } + func undo() + func redo() +} + +extension StructureEditingSession: StructureUndoHistory {} + +extension CreateTableDraft: StructureUndoHistory {} + +// Users & Roles, the diagram and the structure editor keep their own histories; everything else registers on the +// window's undo manager. Availability, title and command resolve through the same branch so they cannot disagree. extension MainContentCommandActions { private var windowUndoManager: UndoManager? { coordinator?.contentWindow?.undoManager } - private var isStructureMode: Bool { - coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure + // A Create Table tab keeps its view mode at Data, so it is asked for by tab type. + private var showsStructureEditor: Bool { + guard let tab = coordinator?.tabManager.selectedTab else { return false } + return tab.tabType == .createTable || tab.display.resultsViewMode == .structure + } + + private var structureHistory: (any StructureUndoHistory)? { + if let session = coordinator?.activeStructureSession { return session } + return coordinator?.activeCreateTableDraft } // The diagram stages on one session per table, so it keeps its own order across them. @@ -31,12 +50,11 @@ extension MainContentCommandActions { coordinator?.tabManager.selectedTab?.tabType == .erDiagram } - /// A field still being typed into has an undo step waiting rather than registered, and the menu - /// has to offer it: the command ends the run before it reaches the undo manager. + // A field still being typed into has its step waiting rather than registered; the command ends the run first. var canUndo: Bool { if isUsersRolesTab { return coordinator?.usersRolesActions?.canUndo() ?? false } if isDiagramTab { return selectedDiagram?.canUndoStagedEdit ?? false } - if isStructureMode { return coordinator?.structureActions?.undo != nil } + if showsStructureEditor { return structureHistory?.canUndo ?? false } if coordinator?.changeManager.hasCoalescedUndoRun == true { return true } return windowUndoManager?.canUndo ?? false } @@ -44,13 +62,17 @@ extension MainContentCommandActions { var canRedo: Bool { if isUsersRolesTab { return coordinator?.usersRolesActions?.canRedo() ?? false } if isDiagramTab { return selectedDiagram?.canRedoStagedEdit ?? false } - if isStructureMode { return coordinator?.structureActions?.redo != nil } + if showsStructureEditor { return structureHistory?.canRedo ?? false } return windowUndoManager?.canRedo ?? false } var resolvedUndoTitle: String { if isUsersRolesTab { return undoMenuTitle } if isDiagramTab { return String(localized: "Undo") } + if showsStructureEditor { + guard let history = structureHistory, history.canUndo else { return String(localized: "Undo") } + return history.undoMenuItemTitle + } if coordinator?.changeManager.hasCoalescedUndoRun == true { return String(localized: "Undo Edit Cell") } @@ -61,61 +83,47 @@ extension MainContentCommandActions { var resolvedRedoTitle: String { if isUsersRolesTab { return redoMenuTitle } if isDiagramTab { return String(localized: "Redo") } + if showsStructureEditor { + guard let history = structureHistory, history.canRedo else { return String(localized: "Redo") } + return history.redoMenuItemTitle + } guard let manager = windowUndoManager, manager.canRedo else { return String(localized: "Redo") } return manager.redoMenuItemTitle } // MARK: - Commands - /// A Create Table tab keeps its `resultsViewMode` at `.data`, so it needs its own arm. Without - /// one, Cmd+Z in the visual table editor reached the window's undo manager, which owns none of - /// the draft, and the grid's own undo had no caller at all. func undoChange() { coordinator?.endInspectorEditRun() - endTableCommentRun() if isUsersRolesTab { coordinator?.usersRolesActions?.undo() return } - if coordinator?.tabManager.selectedTab?.tabType == .createTable { - coordinator?.createTableActions?.undo?() - return - } if isDiagramTab { selectedDiagram?.undoStagedEdit() return } - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - coordinator?.structureActions?.undo?() + if showsStructureEditor { + structureHistory?.undo() return } - coordinator?.contentWindow?.undoManager?.undo() + windowUndoManager?.undo() } func redoChange() { coordinator?.endInspectorEditRun() - endTableCommentRun() if isUsersRolesTab { coordinator?.usersRolesActions?.redo() return } - if coordinator?.tabManager.selectedTab?.tabType == .createTable { - coordinator?.createTableActions?.redo?() - return - } if isDiagramTab { selectedDiagram?.redoStagedEdit() return } - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - coordinator?.structureActions?.redo?() + if showsStructureEditor { + structureHistory?.redo() return } - coordinator?.contentWindow?.undoManager?.redo() - } - - private func endTableCommentRun() { - guard let coordinator, let tabId = coordinator.tabManager.selectedTabId else { return } - coordinator.structureSessions[tabId]?.changeManager.endTableCommentRun() + windowUndoManager?.redo() } } diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index a58475ef42..5a3f62be3f 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -171,9 +171,8 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Row Operations (Group A: Called Directly) func addNewRow() { - // Structure inserts a column, index or FK row by sub-tab; the data path adds data rows only. switch selectionOwner { - case .schemaGrid: coordinator?.structureActions?.addRow?() + case .schemaGrid: addSchemaRow() case .dataGrid: coordinator?.addNewRow() case .none: break } @@ -185,13 +184,13 @@ final class MainContentCommandActions: ObservableObject { return coordinator?.activeGridModel?.selectedRowIDs ?? [] } - // A Create Table tab has no structure handler; claiming its selection would make commands inert and shadow sidebar delete. - private var selectionOwner: GridSelectionOwner { + // A structure grid with no session or draft behind it takes no commands; claiming it would shadow sidebar delete. + var selectionOwner: GridSelectionOwner { let owner = GridSelectionOwner.resolve( tabType: coordinator?.tabManager.selectedTab?.tabType, resultsViewMode: coordinator?.tabManager.selectedTab?.display.resultsViewMode ) - guard owner == .schemaGrid, coordinator?.structureActions == nil else { return owner } + guard owner == .schemaGrid, coordinator?.activeStructureEditor == nil else { return owner } return .none } @@ -205,8 +204,9 @@ final class MainContentCommandActions: ObservableObject { } func deleteSelectedRows() { - if selectionOwner == .schemaGrid { - coordinator?.structureActions?.removeRow?() + // A selected definition that cannot be removed refuses here; Delete never reaches the sidebar's tables. + if selectsSchemaDefinition { + removeSchemaRows() return } @@ -220,13 +220,17 @@ final class MainContentCommandActions: ObservableObject { } func duplicateRow() { + if selectionOwner == .schemaGrid { + duplicateSchemaRows() + return + } guard let rowID = singleSelectedDataGridRowID else { return } coordinator?.duplicateRow(rowID) } func copySelectedRows() { switch selectionOwner { - case .schemaGrid: coordinator?.structureActions?.copyRows?() + case .schemaGrid: copySchemaRows() case .dataGrid: coordinator?.copyRowsToClipboard(selectedDataRowIDs) case .none: break } @@ -244,7 +248,7 @@ final class MainContentCommandActions: ObservableObject { func pasteRows() { switch selectionOwner { - case .schemaGrid: coordinator?.structureActions?.pasteRows?() + case .schemaGrid: pasteSchemaRows() case .dataGrid: coordinator?.pasteRows() case .none: break } @@ -375,15 +379,20 @@ final class MainContentCommandActions: ObservableObject { SharedSidebarState.forConnection(connection.id).sidebarLayout = layout } + // Not the data tab's state in Structure: Add Row there used to light from the rows' editability. var isCurrentTabEditable: Bool { - guard let coordinator, coordinator.tabManager.selectedTab != nil, selectionOwner != .none else { - return false + guard let coordinator, coordinator.tabManager.selectedTab != nil else { return false } + switch selectionOwner { + case .schemaGrid: return canAddSchemaRow || canRemoveSchemaRows + case .dataGrid: return coordinator.canEditActiveResult + case .none: return false } - return coordinator.canEditActiveResult } + // Add and Duplicate Row gate on this; a structure grid adds only where its section allows it. var isCurrentTabSchemaResolved: Bool { guard let coordinator, let tabId = coordinator.tabManager.selectedTabId else { return false } + if selectionOwner == .schemaGrid { return canAddSchemaRow } return coordinator.tabSessionRegistry.tableRows(for: tabId).hasAuthoritativeSchema } @@ -431,7 +440,7 @@ final class MainContentCommandActions: ObservableObject { } switch selectionOwner { case .schemaGrid: - return coordinator?.structureActions?.pasteRows != nil && TableStructureView.canPasteStructureRows + return canPasteSchemaRows case .dataGrid: return tab.tabType == .table && isCurrentTabEditable && isCurrentTabSchemaResolved && ClipboardService.shared.hasText @@ -480,7 +489,12 @@ final class MainContentCommandActions: ObservableObject { // Same check as the sidebar's Delete item, which the menu bar used to skip on engines with no statement for it. var canDropSelectedTables: Bool { - TableOperationEligibility.canDrop(selectedTables.wrappedValue, context: tableOperationEligibility) + guard !selectsSchemaDefinition else { return false } + return TableOperationEligibility.canDrop(selectedTables.wrappedValue, context: tableOperationEligibility) + } + + private var selectsSchemaDefinition: Bool { + selectionOwner == .schemaGrid && coordinator?.activeStructureEditor?.focusedSection != nil } private var tableOperationEligibility: TableOperationEligibility.Context { @@ -522,10 +536,6 @@ final class MainContentCommandActions: ObservableObject { coordinator?.isEditingActiveGrid == true } - var hasStructureChanges: Bool { - coordinator?.toolbarState.hasStructureChanges ?? false - } - // MARK: - Unsaved Changes Check // Every connection in the window: closing it closes them all, and skipping a background one is silent data loss. @@ -777,10 +787,7 @@ final class MainContentCommandActions: ObservableObject { guard await applySelectedTypeChanges() else { return false } // Data grid changes or pending table operations take priority - let hasDataChanges = coordinator.changeManager.hasChanges - || !pendingTruncates.wrappedValue.isEmpty - || !pendingDeletes.wrappedValue.isEmpty - if hasDataChanges { + if hasDataWork { return await withCheckedContinuation { continuation in coordinator.saveCompletionContinuation = continuation saveChanges() @@ -879,13 +886,19 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Data Operations (Group A: Called Directly) - // Any Structure sub-tab saves: DDL, Parts and Triggers are read-only views, and refusing left Cmd+S silently inert. - private func applyStagedStructureChanges() { - guard let coordinator, - let tabId = coordinator.tabManager.selectedTabId, - let session = coordinator.structureSessions[tabId], - session.changeManager.hasChanges else { return } - Task { _ = await session.applyStagedChanges(coordinator: coordinator) } + var hasDataWork: Bool { + coordinator?.changeManager.hasChanges == true + || !pendingTruncates.wrappedValue.isEmpty + || !pendingDeletes.wrappedValue.isEmpty + } + + // What Save would write for the grid: cell edits and the staged TRUNCATE and DROP. + func discardDataWork() { + var truncates = pendingTruncates.wrappedValue + var deletes = pendingDeletes.wrappedValue + coordinator?.handleDiscard(pendingTruncates: &truncates, pendingDeletes: &deletes) + pendingTruncates.wrappedValue = truncates + pendingDeletes.wrappedValue = deletes } func restorePreviousValues() { @@ -899,18 +912,17 @@ final class MainContentCommandActions: ObservableObject { return } if coordinator?.tabManager.selectedTab?.tabType == .createTable { - coordinator?.createTableActions?.createTable?() + guard let coordinator, let draft = coordinator.activeCreateTableDraft else { return } + draft.create(coordinator: coordinator) return } if saveSelectedDiagram() { return } if saveSelectedTypeChanges() { return } - if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { - applyStagedStructureChanges() - } else if coordinator?.changeManager.hasChanges == true - || !pendingTruncates.wrappedValue.isEmpty - || !pendingDeletes.wrappedValue.isEmpty { + if let session = stagedStructureSession, let coordinator { + Task { _ = await session.applyStagedChanges(coordinator: coordinator) } + } else if hasDataWork { // Data grid first: its edits sync to the sidebar editState, and its path uses the plugin driver for statements. var truncates = pendingTruncates.wrappedValue var deletes = pendingDeletes.wrappedValue @@ -1099,9 +1111,17 @@ final class MainContentCommandActions: ObservableObject { } func runQuery() { + if let coordinator, let draft = coordinator.activeCreateTableDraft { + draft.create(coordinator: coordinator) + return + } coordinator?.runQuery(viewport: .keepPlace) } + var canCreateDraftTable: Bool { + coordinator?.activeCreateTableDraft?.isReadyToCreate ?? false + } + func runQueryWithoutLimit() { coordinator?.runQuery(viewport: .keepPlace, bypassRowLimit: true) } diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index 69ff360f71..1e4fd80455 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -191,11 +191,6 @@ final class MainContentCoordinator: ObservableObject { weak var sidebarViewModel: SidebarViewModel? - weak var structureActions: StructureViewActionHandler? - - // Lets Save Changes (Cmd+S) route to table creation. Set by `CreateTableView` on appear. - weak var createTableActions: CreateTableActionHandler? - weak var usersRolesActions: UsersRolesActionHandler? // Not in the view: it dies on every tab and Data/Structure switch, and the close gate must see unseen tabs' staged work. @@ -212,9 +207,6 @@ final class MainContentCoordinator: ObservableObject { // `saveCompletionContinuation` is one slot: a second close gesture would overwrite it and strand the first task forever. internal var tabClosesInFlight: Set = [] - // The non-data grid that owns the selection, read by the inspector. Set by `TableStructureView` and `CreateTableView`. - weak var inspectorRowSource: (any InspectorRowSource)? - // Bumped when a published schema row changes, so the inspector re-reads it. @Published var inspectorRowSourceRevision: Int = 0 diff --git a/apple/TablePro/Views/Main/MainContentView.swift b/apple/TablePro/Views/Main/MainContentView.swift index fdc7b3f76a..3de7ffcf9c 100644 --- a/apple/TablePro/Views/Main/MainContentView.swift +++ b/apple/TablePro/Views/Main/MainContentView.swift @@ -266,11 +266,7 @@ struct MainContentView: View { } ) case .sqlPreview: - SQLReviewSheet( - isPresented: dismissBinding, - statements: coordinator.toolbarState.previewStatements, - databaseType: coordinator.toolbarState.databaseType - ) + sqlPreviewSheet(dismiss: dismissBinding) case .rewind: if let plan = coordinator.rewindPlan { RewindReviewSheet(plan: plan) { @@ -306,12 +302,32 @@ struct MainContentView: View { } } + // A structure session's preview offers its Save; a grid preview only shows what Save would write. + private func sqlPreviewSheet(dismiss: Binding) -> SQLReviewSheet { + SQLReviewSheet( + isPresented: dismiss, + statements: coordinator.toolbarState.previewStatements, + databaseType: coordinator.toolbarState.databaseType, + primaryAction: coordinator.activeStructureSession.flatMap { session -> SQLReviewSheet.PrimaryAction? in + guard session.hasChanges else { return nil } + return SQLReviewSheet.PrimaryAction( + title: String(localized: "Save Changes"), + isDestructive: false, + work: .immediate { + coordinator.activeSheet = nil + session.save() + } + ) + } + ) + } + private var pendingChangeTrigger: PendingChangeTrigger { PendingChangeTrigger( hasDataChanges: changeManager.hasChanges, pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, - hasStructureChanges: toolbarState.hasStructureChanges, + hasStructureChanges: selectedStructureSession?.hasChanges ?? false, diagramEditsRevision: toolbarState.diagramEditsRevision, selectedTabId: tabManager.selectedTabId, isFileDirty: tabManager.selectedTab?.content.isFileDirty ?? false, @@ -354,6 +370,15 @@ struct MainContentView: View { .id(ObjectIdentifier(model)) } } + .background { + if let session = selectedStructureSession { + StructureChangeWatcher(session: session) { + updateToolbarPendingState() + coordinator.refreshUnsavedIndicator() + } + .id(ObjectIdentifier(session)) + } + } .onReceive(coordinator.gridCellsEdited) { _ in scheduleInspectorUpdate() } @@ -525,3 +550,16 @@ struct MainContentView: View { ) } } + +// No view of this window observes a session whose Structure view is not on screen, yet Discard and Save change it there. +private struct StructureChangeWatcher: View { + @ObservedObject var session: StructureEditingSession + let onChange: () -> Void + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .onChange(of: session.hasChanges) { _ in onChange() } + } +} diff --git a/apple/TablePro/Views/Results/ResultStatusBar.swift b/apple/TablePro/Views/Results/ResultStatusBar.swift index 07980fcdec..170346bdc0 100644 --- a/apple/TablePro/Views/Results/ResultStatusBar.swift +++ b/apple/TablePro/Views/Results/ResultStatusBar.swift @@ -38,7 +38,6 @@ struct ResultStatusBar: View { let columnState: StatusBarColumnState let highlightState: StatusBarHighlightState let paginationCallbacks: PaginationCallbacks - let structureFooter: StructureFooterCapability let execution: ExecutionReadout /// Held, not observed: only the readout inside the report slot re-renders when it changes. let selectionSummary: SelectionSummaryState @@ -54,8 +53,6 @@ struct ResultStatusBar: View { let onCloseOtherResultSets: (UUID) -> Void let onToggleFilters: () -> Void let onFetchAll: (() -> Void)? - let onStructureAdd: () -> Void - let onStructureRemove: () -> Void @State private var showColumnPopover = false @State private var showHighlightPopover = false @@ -301,20 +298,6 @@ struct ResultStatusBar: View { if model.controls.showsPagination { paginationControls(presentation) } - if model.controls.showsStructureActions { - AddRemoveControlGroup( - addLabel: structureFooter.addLabel, - removeLabel: structureFooter.removeLabel, - canAdd: structureFooter.canAdd, - canRemove: structureFooter.canRemove, - addHelp: structureFooter.unavailableReason, - removeHelp: structureFooter.unavailableReason, - addIdentifier: "structure-footer-add", - removeIdentifier: "structure-footer-remove", - onAdd: onStructureAdd, - onRemove: onStructureRemove - ) - } } .fixedSize() } diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index 0950b8e615..b8094dbc0c 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -10,7 +10,6 @@ import SwiftUI internal struct FieldEditorContent: View { internal let context: FieldEditorContext internal let kind: FieldEditorKind - internal let databaseType: DatabaseType internal var onSetNull: (() -> Void)? internal var onSetDefault: (() -> Void)? internal var onPopOut: ((String) -> Void)? @@ -29,8 +28,8 @@ internal struct FieldEditorContent: View { /// take away the control that changes it back. private var isPicker: Bool { switch kind { - case .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker: return true - case .json, .phpSerialized, .binaryValue, .image, .geometry, .blobHex, .schemaText, .multiLine, .singleLine: + case .boolean, .enumPicker, .setPicker, .arrayElements, .vector: return true + case .json, .phpSerialized, .binaryValue, .image, .geometry, .blobHex, .multiLine, .singleLine: return false } } @@ -42,8 +41,7 @@ internal struct FieldEditorContent: View { switch kind { case .json, .phpSerialized, .binaryValue, .multiLine: return true case .geometry(let descriptor): return canExpand(kind: descriptor.textEditorKind, state: state) - case .image, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, - .valuePicker, .schemaText, .singleLine: + case .image, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .singleLine: return false } } @@ -57,8 +55,7 @@ internal struct FieldEditorContent: View { case .blobHex: return 60 case .multiLine: return ResizableFieldMetrics.defaultTextHeight case .geometry(let descriptor): return pillHeight(for: descriptor.textEditorKind) - case .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker, - .schemaText, .singleLine: + case .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .singleLine: return nil } } @@ -100,12 +97,6 @@ internal struct FieldEditorContent: View { ) case .vector: VectorFieldEditorView(context: context, onSetNull: onSetNull, onSetDefault: onSetDefault) - case .typePicker: - TypePickerFieldView(context: context, databaseType: databaseType) - case .valuePicker(let options): - ValuePickerFieldView(context: context, options: options) - case .schemaText: - SchemaTextFieldView(context: context) case .multiLine: MultiLineEditorView(context: context, onPopOut: onPopOut, isExpanded: isExpanded) case .singleLine: diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContext.swift b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContext.swift index 2fdf5ea681..46174a22b3 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContext.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/FieldEditorContext.swift @@ -23,12 +23,8 @@ internal struct FieldEditorContext { /// column type and the value only when this is nil, and resolving costs a parse of the value. internal let editor: FieldEditorKind? - /// A schema field has no NULL or DEFAULT state and no data type to badge. + // False where the store has no NULL or DEFAULT to write, as in a data file. internal let allowsNullAndDefault: Bool - internal let showsTypeBadge: Bool - - /// The table a column type picker offers user-defined types for. Nil outside a structure row. - internal let userDefinedTypeScope: DatabaseScope? internal init( columnName: String, @@ -40,9 +36,7 @@ internal struct FieldEditorContext { isReadOnly: Bool, commitBytes: ((Data) -> Void)? = nil, editor: FieldEditorKind? = nil, - allowsNullAndDefault: Bool = true, - showsTypeBadge: Bool = true, - userDefinedTypeScope: DatabaseScope? = nil + allowsNullAndDefault: Bool = true ) { self.columnName = columnName self.columnType = columnType @@ -54,8 +48,6 @@ internal struct FieldEditorContext { self.commitBytes = commitBytes self.editor = editor self.allowsNullAndDefault = allowsNullAndDefault - self.showsTypeBadge = showsTypeBadge - self.userDefinedTypeScope = userDefinedTypeScope } internal var hasMultipleValues: Bool { valueState == .multipleValues } diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift deleted file mode 100644 index 130bac1db9..0000000000 --- a/apple/TablePro/Views/RowInspector/FieldEditors/SchemaTextFieldView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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 = "" - @State private var hasSeededDraft = false - @FocusState private var isFocused: Bool - - var body: some View { - TextField(context.placeholderText, text: $draft) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled(true) - .focused($isFocused) - .disabled(context.isReadOnly) - .onAppear(perform: seedDraftOnFirstAppearance) - .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() } - } - - /// A connection switch or a trailing-pane swap removes the field and re-adds it with the draft - /// intact. Reseeding there overwrote typing not yet committed, and the blur that follows then - /// found nothing to commit. A real change of the stored value still arrives through `onChange`. - private func seedDraftOnFirstAppearance() { - guard !hasSeededDraft else { return } - hasSeededDraft = true - draft = context.value.wrappedValue - } - - private func commit() { - guard draft != context.value.wrappedValue else { return } - context.value.wrappedValue = draft - } -} diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift deleted file mode 100644 index 0fac5c11eb..0000000000 --- a/apple/TablePro/Views/RowInspector/FieldEditors/TypePickerFieldView.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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) - .help(String(localized: "Choose Type")) - .accessibilityLabel(String(localized: "Choose Type")) - .popover(isPresented: $isPickerPresented) { - UserDefinedTypeAwarePicker(scope: context.userDefinedTypeScope) { userDefinedTypes in - TypePickerContentView( - databaseType: databaseType, - currentValue: context.value.wrappedValue, - userDefinedTypes: userDefinedTypes, - onCommit: { context.value.wrappedValue = $0 }, - onDismiss: { isPickerPresented = false } - ) - } - } - } - } -} diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift deleted file mode 100644 index e75f110ec1..0000000000 --- a/apple/TablePro/Views/RowInspector/FieldEditors/ValuePickerFieldView.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// ValuePickerFieldView.swift -// TablePro -// - -import SwiftUI - -/// A field with suggestions the user may ignore: the same menu the structure grid opens from its -/// cell chevron, over the same free-form text field every other schema field uses. -/// -/// `EnumPickerView` cannot serve here. Its list is the whole set of legal values, so it has no way to -/// hold a value that is not in it, and a column default is an open-ended SQL expression. -internal struct ValuePickerFieldView: View { - internal let context: FieldEditorContext - internal let options: [GridMenuOption] - - @State private var isCustomPresented = false - - var body: some View { - HStack(spacing: 4) { - SchemaTextFieldView(context: context) - - Menu { - ForEach(Array(options.enumerated()), id: \.offset) { _, option in - menuEntry(option) - } - } label: { - Label { Text("Choose Value") } icon: { EmptyView() } - } - .labelStyle(.iconOnly) - .menuStyle(.button) - .buttonStyle(.borderless) - .menuIndicator(.visible) - .fixedSize() - .disabled(context.isReadOnly) - /// The tooltip only. The name already comes from the label's title, and an accessibility - /// label modifier on a `Menu` replaces that name with nothing rather than adding one. - .help(String(localized: "Choose Value")) - /// A SwiftUI `Menu` is an `NSPopUpButton` on macOS, so it publishes `menuButton` or - /// `popUpButton` and never `button`, and no suite has ever resolved one by its label. - /// The identifier is the hook `InspectorFieldRow`'s own value menu already carries, and - /// unlike the label it does not move with the display language. - .accessibilityIdentifier("column-default-menu") - .popover(isPresented: $isCustomPresented) { - CustomValueContentView( - initialValue: context.value.wrappedValue, - escapeStringLiteral: escapeStringLiteral, - stringLiteralPrefix: stringLiteralPrefix, - onCommit: { context.value.wrappedValue = $0 }, - onDismiss: { isCustomPresented = false } - ) - } - } - } - - @ViewBuilder - private func menuEntry(_ option: GridMenuOption) -> some View { - switch option { - case .sectionHeader(let title): - Divider() - Text(title) - case .value(let title, let sql): - Button { - context.value.wrappedValue = sql - } label: { - if sql == context.value.wrappedValue { - Label(title, systemImage: "checkmark") - } else { - Text(title) - } - } - case .clear(let title): - Button(title) { context.value.wrappedValue = "" } - case .custom(let title): - Divider() - Button(title) { isCustomPresented = true } - } - } - - /// The connected driver's own escaping, the same seam the grid's chevron menu uses, so the two - /// surfaces cannot escape one value two ways. It falls back to the shared helper only where the - /// field has no connection behind it, which is every field outside a structure row. - private var escapeStringLiteral: (String) -> String { - guard let connectionId = context.userDefinedTypeScope?.connectionId, - let driver = DatabaseManager.shared.driver(for: connectionId) else { - return SQLEscaping.escapeStringLiteral - } - return driver.escapeStringLiteral - } - - /// From the same connection the escaping comes from, so a default typed here is written the - /// way the engine reads it back. No connection means no prefix, which is what every engine but - /// SQL Server wants anyway. - private var stringLiteralPrefix: String { - guard let connectionId = context.userDefinedTypeScope?.connectionId, - let driver = DatabaseManager.shared.driver(for: connectionId) else { - return "" - } - return SQLStringLiteralPrefix.forDatabaseType(driver.connection.type) - } -} diff --git a/apple/TablePro/Views/RowInspector/InspectorFieldListView.swift b/apple/TablePro/Views/RowInspector/InspectorFieldListView.swift index fcee8688a1..8328ab0a09 100644 --- a/apple/TablePro/Views/RowInspector/InspectorFieldListView.swift +++ b/apple/TablePro/Views/RowInspector/InspectorFieldListView.swift @@ -17,7 +17,6 @@ internal struct InspectorFieldListView: View { @ObservedObject internal var editState: MultiRowEditState internal let isEditable: Bool internal let databaseType: DatabaseType - internal let userDefinedTypeScope: DatabaseScope? internal var offersDatabaseValues = true private var offersFieldRemoval: Bool { @@ -93,14 +92,12 @@ internal struct InspectorFieldListView: View { case .phpSerialized, .binaryValue: return false case .json, .image, .geometry, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, - .typePicker, .valuePicker, .schemaText, .multiLine, .singleLine: + .multiLine, .singleLine: return true } } - /// A structure row's edits are recorded by its own grid, so `hasEdits` stays false while - /// `hasCommittedEdit` is true. Gating on pending edits alone left the filter permanently - /// unusable on exactly the rows it lists as modified. + // Rows that disagree carry an edit made elsewhere as `hasCommittedEdit`, with no pending value. private var hasAnyModification: Bool { editState.fields.contains { $0.hasEdit || $0.hasCommittedEdit } } @@ -156,19 +153,15 @@ internal struct InspectorFieldListView: View { let commands = editState.setValueCommands(for: field, isWritable: editable && offersDatabaseValues) InspectorFieldRow( context: context(for: field, kind: kind, isEditable: editable), - layout: InspectorFieldLayout.resolve(for: kind, isSchemaField: field.isSchemaField), kind: kind, isModified: field.hasEdit || field.hasCommittedEdit, isPrimaryKey: field.isPrimaryKey, isForeignKey: field.isForeignKey, - databaseType: databaseType, isExpanded: expandedFieldID == field.id, commands: commands, isPendingExpression: field.isPendingExpression, onSetValue: { editState.setValue($0, at: field.columnIndex) }, - onRemoveField: offersFieldRemoval && !field.isSchemaField - ? { editState.removeField(at: field.columnIndex) } - : nil, + onRemoveField: offersFieldRemoval ? { editState.removeField(at: field.columnIndex) } : nil, onToggleExpand: FieldEditorContent.canExpand(kind: kind, state: FieldValueState.resolve(field)) ? { expandedFieldID = expandedFieldID == field.id ? nil : field.id } : nil, @@ -208,9 +201,7 @@ internal struct InspectorFieldListView: View { ? { editState.setFieldToBytes(at: field.columnIndex, data: $0) } : nil, editor: kind, - allowsNullAndDefault: offersDatabaseValues && !field.isSchemaField, - showsTypeBadge: !field.isSchemaField, - userDefinedTypeScope: field.isSchemaField ? userDefinedTypeScope : nil + allowsNullAndDefault: offersDatabaseValues ) } @@ -251,7 +242,6 @@ internal struct InspectorFieldListView: View { offersDatabaseValues, let focusedField, let field = editState.fields.first(where: { $0.id == focusedField }), - !field.isSchemaField, Self.isFieldEditable(field, kind: FieldEditorResolver.resolve(field: field), rowIsEditable: isEditable), let command = Self.stateShortcutCommand( for: key, offered: editState.setValueCommands(for: field, isWritable: true) diff --git a/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift b/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift index c9c57b6e31..20ca42288f 100644 --- a/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift +++ b/apple/TablePro/Views/RowInspector/InspectorFieldRow.swift @@ -10,12 +10,10 @@ import SwiftUI internal struct InspectorFieldRow: View { @ObservedObject private var themeEngine = ThemeEngine.shared internal let context: FieldEditorContext - internal let layout: InspectorFieldLayout internal let kind: FieldEditorKind internal let isModified: Bool internal let isPrimaryKey: Bool internal let isForeignKey: Bool - internal let databaseType: DatabaseType internal let isExpanded: Bool internal let commands: [SetValueCommand] internal var isPendingExpression = false @@ -29,51 +27,24 @@ internal struct InspectorFieldRow: View { internal let fieldID: UUID var body: some View { - Group { - switch layout { - case .inline: inlineRow - case .stacked: stackedRow - } - } - .contentShape(Rectangle()) - .contextMenu { menuContent } - .accessibilityElement(children: .contain) - .accessibilityLabel(accessibilityLabel) + row + .contentShape(Rectangle()) + .contextMenu { menuContent } + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibilityLabel) } - // MARK: - Layouts - - /// A schema row: the label in a fixed trailing-aligned lane, the editor filling what is left. - /// The lane is a constant because the label vocabulary is closed and authored; its widest member - /// renders at 66.4pt, so 96 carries every one of them with room for a longer translation. - private var inlineRow: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - HStack(spacing: 3) { - statusGlyphs - fieldLabel - } - .frame(width: Self.schemaLabelLaneWidth, alignment: .trailing) - editor - .frame(maxWidth: .infinity, alignment: .leading) - modifiedGlyph - valueMenu - } - .padding(.vertical, InspectorMetrics.betweenFields / 2) - } + // MARK: - Layout - /// A data row: name and type on their own line, the value at full width beneath. Every value in - /// the pane then starts at the same x and gets the whole content width, against the 24 to 31pt a - /// long column name used to leave it. - private var stackedRow: some View { + // Always stacked: a column name has no length bound, so a label lane beside the value truncated both. + private var row: some View { VStack(alignment: .leading, spacing: InspectorMetrics.labelToValue) { HStack(spacing: 4) { statusGlyphs fieldLabel Spacer(minLength: 4) modifiedGlyph - if context.showsTypeBadge { - TypeBadge(context.columnType.badgeLabel) - } + TypeBadge(context.columnType.badgeLabel) if let onToggleExpand { Button( isExpanded ? String(localized: "Collapse") : String(localized: "Expand"), @@ -146,7 +117,7 @@ internal struct InspectorFieldRow: View { var parts = [context.columnName] if isPrimaryKey { parts.append(String(localized: "Primary key")) } if isForeignKey { parts.append(String(localized: "Foreign key")) } - if context.showsTypeBadge { parts.append(context.columnType.badgeLabel) } + parts.append(context.columnType.badgeLabel) if isModified { parts.append(String(localized: "Modified")) } return parts.joined(separator: ", ") } @@ -198,7 +169,6 @@ internal struct InspectorFieldRow: View { FieldEditorContent( context: context, kind: kind, - databaseType: databaseType, onSetNull: context.canMutate && commands.contains(.null) ? { onSetValue(.null) } : nil, onSetDefault: context.canMutate && commands.contains(.defaultValue) ? { onSetValue(.defaultValue) } : nil, onPopOut: onPopOut, @@ -218,20 +188,8 @@ internal struct InspectorFieldRow: View { return nil case .geometry: return nil - case .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker, - .schemaText, .multiLine, .singleLine: + case .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .vector, .multiLine, .singleLine: return ThemeEngine.shared.valueFontSwiftUI } } - - - /// The lane a schema row's label sits in. The vocabulary is closed and authored, and its widest - /// member, "Ref Columns", renders at 66.4pt in `.subheadline`, so this carries every label with - /// 45% left over for a longer translation. A label that still overruns truncates in the middle - /// and keeps its tooltip rather than taking the width from its own value. - /// - /// It is a constant, not a measurement. A lane derived from the rows on screen would change - /// width as the user scrolls, because a `PreferenceKey` max-reduce inside a `List` only ever - /// sees the realized rows. - private static let schemaLabelLaneWidth: CGFloat = 96 } diff --git a/apple/TablePro/Views/RowInspector/RowInspectorView.swift b/apple/TablePro/Views/RowInspector/RowInspectorView.swift index a9a534f818..03a8555d7f 100644 --- a/apple/TablePro/Views/RowInspector/RowInspectorView.swift +++ b/apple/TablePro/Views/RowInspector/RowInspectorView.swift @@ -5,12 +5,9 @@ import SwiftUI -/// The window's inspector: the currently selected row, in one of two renderings. -/// -/// It replaces a view that carried four modes plus two full-panel takeovers, each takeover drawing -/// its own back button over the rest of the row. There is no navigation here: a structured value -/// grows in place and the pop-out windows take anything larger, so the fields around it never go -/// away. +// The window's inspector: the selected row in one of two renderings, or a structure definition's form while a +// Structure view or a Create Table tab is on screen. No navigation: a structured value grows in place and the +// pop-out windows take anything larger. internal struct RowInspectorView: View { @ObservedObject private var state: RowInspectorState private let paneState: TrailingPaneState @@ -42,8 +39,12 @@ internal struct RowInspectorView: View { ) { section in menuSection(section) } - InspectorSubjectView(subject: context.subject) - content + if let structure = context.structure { + StructureInspectorView(source: structure, tableMetadata: context.tableMetadata) + } else { + InspectorSubjectView(subject: context.subject) + content + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -60,9 +61,7 @@ internal struct RowInspectorView: View { } } - /// Both renderings are views of the same selection, which is the case Apple's inspector guidance - /// covers. The header offers this only while `offeredRendering` has a value, and there the stored - /// mode is the one drawn, so the item it checks is the one on screen. + // Offered only while `offeredRendering` has a value, so the item it checks is the rendering on screen. private var renderingPicker: some View { Picker(String(localized: "Inspector View"), selection: $state.viewMode) { ForEach(InspectorViewMode.allCases, id: \.self) { mode in @@ -73,22 +72,14 @@ internal struct RowInspectorView: View { .labelsHidden() } - /// The rendering on screen, for a selection that can be drawn both ways. A schema grid's - /// selection is a column definition with no types and no foreign keys to follow, and a pane with - /// no row draws table info or nothing, so neither offers a choice: the stored mode would be - /// checked there over a pane drawing something else. + // Only a data row can be drawn both ways; a structure definition or a pane with no row offers no choice. private var offeredRendering: InspectorViewMode? { - guard context.hasRow, context.jsonRow != nil else { return nil } + guard context.structure == nil, context.hasRow, context.jsonRow != nil else { return nil } return showsFields ? .fields : .json } - /// The field list stays mounted and is hidden rather than rebuilt. - /// - /// It owns its search term, its edited-only setting, which field is expanded, which field has - /// focus and the `List`'s scroll position, and none of that survives leaving the hierarchy. - /// Rebuilding it also rebuilds a row per column, which is what scales on a wide table. The JSON - /// rendering is cheap to rebuild and keeps its own reader state in `jsonViewModel`, so it stays - /// conditional. + // The field list stays mounted and hidden: its search, expansion, focus and scroll position would not survive + // a rebuild, and a rebuild makes a row per column. The JSON rendering keeps its state in `jsonViewModel`. @ViewBuilder private var content: some View { if context.hasRow { @@ -96,10 +87,8 @@ internal struct RowInspectorView: View { fieldsMode .opacity(showsFields ? 1 : 0) .allowsHitTesting(showsFields) - /// Neither opacity nor hit-testing stops the keyboard. Without this a field - /// still holding first responder when the mode changed goes on accepting text - /// and the list's Ctrl-Option-N and Ctrl-Option-D behind the JSON view, and - /// each of those stages a real cell change that Save later commits unseen. + // Neither opacity nor hit-testing stops the keyboard: a focused field behind the JSON view + // would go on staging cell changes that Save commits unseen. .disabled(!showsFields) .accessibilityHidden(!showsFields) @@ -114,8 +103,6 @@ internal struct RowInspectorView: View { } } - /// The JSON rendering only exists for a data-grid row, because a schema grid's selection is a - /// column definition with no types and no foreign keys to follow. private var showsFields: Bool { state.viewMode == .fields || context.jsonRow == nil } @@ -125,7 +112,6 @@ internal struct RowInspectorView: View { editState: state.editState, isEditable: context.isEditable && !context.isRowDeleted, databaseType: connection.type, - userDefinedTypeScope: context.userDefinedTypeScope, onPopOut: popOut ) } @@ -140,9 +126,7 @@ internal struct RowInspectorView: View { ) } - /// The inspector's own glyph rather than `sidebar.right`, which is the pane and which the pane's - /// not-connected state drew too: a row not being selected and a connection being down read the - /// same. + // The inspector's own glyph, not `sidebar.right`, which the not-connected state draws. private var emptyState: some View { UnavailableStateView( String(localized: "No Row Selected"), @@ -152,12 +136,9 @@ internal struct RowInspectorView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - /// The pop-out windows are the escape for a value too large for the pane, and the only escape: - /// no competitor ships an in-panel takeover and neither does this any more. private func popOut(field: FieldEditState, text: String, kind: FieldEditorKind) { let isEditable = context.isEditable && !context.isRowDeleted && !field.isServerOwned - /// Captured, not looked up per keystroke: the field's id is reissued on every selection - /// change, and a window left open over a new selection dropped what was typed into it. + // Captured, not looked up per keystroke: the field's id is reissued on every selection change. let columnIndex = field.columnIndex let rowIDs = state.editState.rowIDs let commit: ((String) -> Void)? = isEditable @@ -189,8 +170,7 @@ internal struct RowInspectorView: View { } } -/// The window a field's text opens in. A geometry field's map opens its own window from the field; -/// what reaches here is its Text segment, which goes where that editor's kind would. +// A geometry field's map opens its own window; its Text segment goes where that editor's kind would. internal enum InspectorPopOutWindow: Equatable { case json case php @@ -207,19 +187,14 @@ internal enum InspectorPopOutWindow: Equatable { return .binary(format) case .geometry(let descriptor): return resolve(for: descriptor.textEditorKind) - case .multiLine, .singleLine, .schemaText, .blobHex, .image, .boolean, - .enumPicker, .setPicker, .arrayElements, .vector, .typePicker, .valuePicker: + case .multiLine, .singleLine, .blobHex, .image, .boolean, .enumPicker, .setPicker, .arrayElements, .vector: return .text } } } -/// The JSON rendering's own commands, in the pane header's menu while that rendering is on screen. -/// -/// They were an ellipsis of their own at the end of the JSON filter field, which put two ellipsis -/// menus one above the other in the same column once the pane's header gained one. Its own view so -/// it observes the reader's model: the inspector does not, and a menu built from it would go on -/// showing Always Expand Foreign Keys in the state it had when the pane last redrew. +// The JSON rendering's commands in the pane header's menu. Its own view so it observes the reader's model, which +// the inspector does not. private struct JSONReadingCommands: View { @ObservedObject var viewModel: JSONRowInspectorViewModel diff --git a/apple/TablePro/Views/RowInspector/TableInfoView.swift b/apple/TablePro/Views/RowInspector/TableInfoView.swift index 5baf7bf061..1927087b00 100644 --- a/apple/TablePro/Views/RowInspector/TableInfoView.swift +++ b/apple/TablePro/Views/RowInspector/TableInfoView.swift @@ -5,79 +5,83 @@ import SwiftUI -/// The table's own statistics, shown when the inspector has a table but no row selected. -/// -/// Not a `Form`. `.formStyle(.grouped)` insets its content 30pt per side and there is no way to ask -/// it for less: measured, that 30 holds at 250, 270, 300, 340, 420 and 600pt, so it is not a margin -/// that gives way on a narrow pane. At the inspector's 270pt minimum it left a 116pt value column, -/// which truncated the collation on every engine whose collation has a name (`utf8mb4_0900_ai_ci` -/// needs 126pt, `SQL_Latin1_General_CP1_CI_AS` needs 196), and the previous author reached for a -/// `.help` tooltip on that row rather than the width. It also disagreed with the field list beside -/// it: selecting a row moved every value 20pt sideways. -/// -/// So the sections are drawn here, on `InspectorMetrics.horizontalInset` like everything else in -/// the pane, and a value that still does not fit wraps rather than eliding. Wrapping is what -/// Finder's Get Info does with a long value at this width, measured. +// The table's own statistics, when the inspector has a table but no row selected. Not a grouped `Form`: its 30pt +// insets hold at every pane width and left a 116pt value column at 270pt, which truncated collation names. internal struct TableInfoView: View { - @ObservedObject private var settingsManager = AppSettingsManager.shared internal let metadata: TableMetadata var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 14) { - if settingsManager.general.showObjectComments, - let comment = metadata.comment, !comment.isEmpty { - section(String(localized: "Comment")) { - Text(comment) - .font(.subheadline) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - } + TableInfoSections(metadata: metadata) + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// The statistics alone, for a pane that scrolls them with its own content. +internal struct TableInfoSections: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared + internal let metadata: TableMetadata + internal var includesComment = true + + internal init(metadata: TableMetadata, includesComment: Bool = true) { + self.metadata = metadata + self.includesComment = includesComment + } - section(String(localized: "Size")) { - row(String(localized: "Data"), TableMetadata.formatSize(metadata.dataSize)) - row(String(localized: "Indexes"), TableMetadata.formatSize(metadata.indexSize)) - row(String(localized: "Total"), TableMetadata.formatSize(metadata.totalSize)) + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if includesComment, settingsManager.general.showObjectComments, + let comment = metadata.comment, !comment.isEmpty { + section(String(localized: "Comment")) { + Text(comment) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) } + } + + section(String(localized: "Size")) { + row(String(localized: "Data"), TableMetadata.formatSize(metadata.dataSize)) + row(String(localized: "Indexes"), TableMetadata.formatSize(metadata.indexSize)) + row(String(localized: "Total"), TableMetadata.formatSize(metadata.totalSize)) + } - if metadata.rowCount != nil || metadata.avgRowLength != nil { - section(String(localized: "Statistics")) { - if let rows = metadata.rowCount { - row(String(localized: "Rows"), rows.formatted(.number)) - } - if let averageLength = metadata.avgRowLength { - row(String(localized: "Average Row"), TableMetadata.formatSize(averageLength)) - } + if metadata.rowCount != nil || metadata.avgRowLength != nil { + section(String(localized: "Statistics")) { + if let rows = metadata.rowCount { + row(String(localized: "Rows"), rows.formatted(.number)) + } + if let averageLength = metadata.avgRowLength { + row(String(localized: "Average Row"), TableMetadata.formatSize(averageLength)) } } + } - if metadata.engine != nil || metadata.collation != nil { - section(String(localized: "Metadata")) { - if let engine = metadata.engine { - row(String(localized: "Engine"), engine) - } - if let collation = metadata.collation { - row(String(localized: "Collation"), collation) - } + if metadata.engine != nil || metadata.collation != nil { + section(String(localized: "Metadata")) { + if let engine = metadata.engine { + row(String(localized: "Engine"), engine) + } + if let collation = metadata.collation { + row(String(localized: "Collation"), collation) } } + } - if metadata.createTime != nil || metadata.updateTime != nil { - section(String(localized: "Timestamps")) { - if let created = metadata.createTime { - row(String(localized: "Created"), Self.date(created)) - } - if let updated = metadata.updateTime { - row(String(localized: "Updated"), Self.date(updated)) - } + if metadata.createTime != nil || metadata.updateTime != nil { + section(String(localized: "Timestamps")) { + if let created = metadata.createTime { + row(String(localized: "Created"), Self.date(created)) + } + if let updated = metadata.updateTime { + row(String(localized: "Updated"), Self.date(updated)) } } } - .padding(.horizontal, InspectorMetrics.horizontalInset) - .padding(.vertical, 12) - .frame(maxWidth: .infinity, alignment: .leading) } .accessibilityElement(children: .contain) .accessibilityIdentifier("table-info") @@ -93,9 +97,7 @@ internal struct TableInfoView: View { } } - /// The label takes what it needs and the value takes the rest, wrapping when it has to. A fixed - /// label lane would be wrong here for the same reason it is wrong on a data field: these labels - /// are localized, so their widest member is not knowable from the English. + // The label takes what it needs and the value the rest, wrapping: labels are localized, so no fixed lane fits. private func row(_ label: String, _ value: String) -> some View { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(label) diff --git a/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index 7e31ec969b..2976e16b9f 100644 --- a/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/apple/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -12,7 +12,6 @@ internal enum FieldEditorResolver { /// Answers from the field's own resolution when it has one. Only a field built before /// `MultiRowEditState` filled it in pays the detectors here. static func resolve(field: FieldEditState) -> FieldEditorKind { - if let editor = field.editor { return editor } if let resolved = field.resolvedEditor { return resolved } return resolve( for: field.columnTypeEnum, diff --git a/apple/TablePro/Views/Structure/ClickHousePartsView.swift b/apple/TablePro/Views/Structure/ClickHousePartsView.swift index 0e9e2e5f2e..6f37691102 100644 --- a/apple/TablePro/Views/Structure/ClickHousePartsView.swift +++ b/apple/TablePro/Views/Structure/ClickHousePartsView.swift @@ -21,7 +21,7 @@ struct ClickHousePartsView: View { @State private var isLoading = true @State private var errorMessage: String? @State private var loadedToken: Int? - @StateObject private var grid = StructureListGridModel(accessibilityLabel: StructureTab.parts.displayName) + @StateObject private var grid = StructureListGridModel(accessibilityLabel: StructurePane.parts.displayName) @StateObject private var selection = StructureListSelection() var body: some View { diff --git a/apple/TablePro/Views/Structure/CreateTableActionHandler.swift b/apple/TablePro/Views/Structure/CreateTableActionHandler.swift deleted file mode 100644 index 9cc91dd862..0000000000 --- a/apple/TablePro/Views/Structure/CreateTableActionHandler.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// CreateTableActionHandler.swift -// TablePro -// - -import Foundation - -@MainActor -final class CreateTableActionHandler { - var createTable: (() -> Void)? - var undo: (() -> Void)? - var redo: (() -> Void)? -} diff --git a/apple/TablePro/Views/Structure/CreateTableDraft.swift b/apple/TablePro/Views/Structure/CreateTableDraft.swift index 2877426dab..bffd4f2653 100644 --- a/apple/TablePro/Views/Structure/CreateTableDraft.swift +++ b/apple/TablePro/Views/Structure/CreateTableDraft.swift @@ -17,13 +17,25 @@ internal struct CreateTableCompositionKey: Hashable { let columns: [EditableColumnDefinition] let indexes: [EditableIndexDefinition] let foreignKeys: [EditableForeignKeyDefinition] + let checkConstraints: [EditableCheckConstraintDefinition] } +internal enum CreateTablePane: Hashable, CaseIterable { + case definition + case ddl +} + +// One Create Table tab: the definition it drafts over an empty baseline, its table options, and the commands the +// toolbar and menus send it. The coordinator holds it by tab id, so the work outlives the view. @MainActor internal final class CreateTableDraft: ObservableObject { private static let logger = Logger(subsystem: "com.TablePro", category: "CreateTableDraft") + internal let connection: DatabaseConnection + // The tab's own database and schema: the table is created there, and the reference menus read there. + internal let scope: DatabaseScope? internal let changeManager = StructureChangeManager() + internal let editor: StructureDefinitionEditor @Published internal var tableName = "" { didSet { @@ -33,37 +45,85 @@ internal final class CreateTableDraft: ObservableObject { } @Published internal var tableOptions = CreateTableOptions() @Published internal var form: CreateTableFormState? + @Published internal var pane: CreateTablePane = .definition + @Published internal private(set) var hasResolvedForm = false + @Published internal private(set) var isCreating = false - // Server-wide: the character sets, collations and engines the pickers and the column cells offer. + // Server-wide: the character sets, collations and engines the inspector and the column cells offer. @Published internal private(set) var optionsCatalog: MetadataLoadState = .idle - // Per target database, so a draft compares a pick with the defaults of the database it creates the table in. + // Per target database, so a pick is compared with the defaults of the database the table goes in. @Published internal private(set) var optionDefaults = CreateTableOptionDefaults() - private var optionDefaultsScope: DatabaseScope? - private var optionsScope: DatabaseScope? - private var optionsCatalogRead: Task? - @Published internal private(set) var hasResolvedForm = false - - internal func resolveForm(from spec: @autoclosure () -> PluginCreateTableFormSpec?) { - guard !hasResolvedForm else { return } - hasResolvedForm = true - form = spec().map(CreateTableFormState.init(spec:)) - } @Published internal private(set) var composed: CreateTableStatements? @Published internal private(set) var compositionFailure: String? - // Made on the first mount, which knows the connection; held here so the grid outlives the view. - internal private(set) var gridAdapter: CreateTableGridAdapter? + internal var executionGate: any ExecutionGate = ExecutionGateProvider.shared + private var focusesNameOnAppear = true + private var optionDefaultsScope: DatabaseScope? + private var optionsCatalogRead: Task? private var composedKey: CreateTableCompositionKey? private var compositionGeneration = 0 - private var changeManagerForwarding: AnyCancellable? + private var cancellables: Set = [] - internal init() { - changeManagerForwarding = changeManager.objectWillChange + internal init(connection: DatabaseConnection, scope: DatabaseScope?) { + self.connection = connection + self.scope = scope + editor = StructureDefinitionEditor( + manager: changeManager, + context: .newTable( + connection: connection, + databaseName: scope?.database ?? connection.database, + schemaName: scope?.schema + ) + ) + editor.characterSetContextProvider = { [weak self] in + ColumnCharacterSetMenus.Context( + catalog: self?.optionsCatalog ?? .idle, + tableCharacterSet: self?.tableOptions.charset + ) + } + editor.characterSetCatalogReader = { [weak self] in + self?.readOptionsCatalogIfNeeded() + } + changeManager.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + $optionsCatalog + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.editor.refresh() } + .store(in: &cancellables) } + // MARK: - Setup + + // Runs on every appearance; each step skips what is done. + internal func prepare(coordinator: MainContentCoordinator?) { + editor.coordinator = coordinator + editor.serverSupport = StructureServerSupport.forConnection(connection.id) + resolveForm(from: DatabaseManager.shared.driver(for: connection.id)?.createTableFormSpec(schema: scope?.schema)) + } + + internal func resolveForm(from spec: @autoclosure () -> PluginCreateTableFormSpec?) { + guard !hasResolvedForm else { return } + hasResolvedForm = true + form = spec().map(CreateTableFormState.init(spec:)) + if form == nil, changeManager.workingColumns.isEmpty { + changeManager.addNewColumn() + editor.refresh() + } + } + + // The Name field takes focus when the tab first shows, not on every return to it. + internal func claimNameFocus() -> Bool { + guard focusesNameOnAppear else { return false } + focusesNameOnAppear = false + return true + } + + // MARK: - State + internal var holdsWork: Bool { !tableName.isEmpty || ObjectCommentDraft.normalized(tableOptions.comment) != nil @@ -73,16 +133,91 @@ internal final class CreateTableDraft: ObservableObject { || changeManager.workingForeignKeys.contains { !$0.name.isEmpty || !$0.columns.isEmpty || !$0.referencedTable.isEmpty } + || changeManager.workingCheckConstraints.contains { !$0.name.isEmpty || !$0.expression.isEmpty } + } + + internal var hasChanges: Bool { holdsWork } + + // The composed statement adds what the driver refuses; until it catches up with the draft, the plan's own. + internal var issues: [SchemaDraftIssue] { + currentComposedIssues ?? plan().issues } - internal func gridAdapter(for connection: DatabaseConnection) -> CreateTableGridAdapter { - if let gridAdapter { return gridAdapter } - let adapter = CreateTableGridAdapter(structureChangeManager: changeManager, connection: connection) - adapter.draft = self - gridAdapter = adapter - return adapter + internal var isReadyToCreate: Bool { + guard hasResolvedForm, !isCreating else { return false } + if let form { + return form.issues(tableName: tableName).isEmpty + } + let plan = plan() + return plan.definition != nil && (currentComposedIssues ?? plan.issues).isEmpty + } + + private var currentComposedIssues: [SchemaDraftIssue]? { + guard let composed, let composedKey, composedKey == compositionKey else { return nil } + return composed.issues + } + + // The definition the issue names, when it names one row. + internal func entityID(for issue: SchemaDraftIssue) -> UUID? { + guard let row = issue.row else { return nil } + let ids = StructureGridEdits.entityIDs(in: issue.section, manager: changeManager) + return ids.indices.contains(row) ? ids[row] : nil + } + + internal func reveal(_ issue: SchemaDraftIssue) { + guard let id = entityID(for: issue) else { return } + pane = .definition + editor.reveal(section: issue.section, entity: id) + } + + // MARK: - Commands + + // The section commands act on: the one holding the selection, else Columns. + internal var commandSection: StructureSection { editor.commandSection } + + private var editsDefinition: Bool { hasResolvedForm && form == nil } + + internal var canUndo: Bool { editsDefinition && changeManager.canUndo } + internal var canRedo: Bool { editsDefinition && changeManager.canRedo } + internal var undoMenuItemTitle: String { changeManager.undoMenuItemTitle } + internal var redoMenuItemTitle: String { changeManager.redoMenuItemTitle } + + internal func undo() { + guard canUndo else { return } + commitOpenEdits() + changeManager.undo() + editor.refresh() + } + + internal func redo() { + guard canRedo else { return } + commitOpenEdits() + changeManager.redo() + editor.refresh() + } + + internal var canAddRow: Bool { editsDefinition && pane == .definition && editor.canAdd(commandSection) } + + internal func addRow() { + guard canAddRow else { return } + editor.add(commandSection) } + internal var canRemoveRows: Bool { + editsDefinition && pane == .definition && editor.canRemoveSelection(in: commandSection) + } + + internal func removeSelectedRows() { + guard canRemoveRows else { return } + editor.removeSelection(in: commandSection) + } + + internal func commitOpenEdits() { + editor.commitPendingEdits() + } + + // MARK: - Table options + // The driver's catalog decides, never the engine's type: a driver without one has no table options. internal var catalog: TableOptionsCatalog? { guard case .loaded(let catalog) = optionsCatalog else { return nil } @@ -91,6 +226,13 @@ internal final class CreateTableDraft: ObservableObject { internal var offersEngineOptions: Bool { catalog != nil } + // A driver's own form names everything its table takes. + internal var offersTableComment: Bool { + guard editsDefinition else { return false } + let support = DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: connection.id)) + return support.commentableTypes.contains(.table) + } + internal func selectCharacterSet(_ charset: String?) { guard charset != tableOptions.charset else { return } tableOptions = tableOptions.selectingCharacterSet( @@ -100,7 +242,8 @@ internal final class CreateTableDraft: ObservableObject { } // Idempotent: a remount skips what is loaded, and a read a switch cancelled runs again on return. - internal func loadTableOptions(scope: DatabaseScope, provider: any ScopedMetadataProviding = DatabaseManager.shared) async { + internal func loadTableOptions(provider: any ScopedMetadataProviding = DatabaseManager.shared) async { + guard let scope else { return } let loader = TableStructureLoader(scope: scope, tableName: "", provider: provider) await loadOptionsCatalog(using: loader) guard catalog != nil, optionDefaultsScope != scope else { return } @@ -121,13 +264,16 @@ internal final class CreateTableDraft: ObservableObject { } // Opening a column's Charset or Collation menu is the retry. - internal func retryOptionsCatalog() { - guard case .failed = optionsCatalog, let optionsScope else { return } - Task { await loadTableOptions(scope: optionsScope) } + private func readOptionsCatalogIfNeeded() { + switch optionsCatalog { + case .idle, .failed: + Task { await loadTableOptions() } + case .loading, .loaded: + break + } } private func loadOptionsCatalog(using loader: TableStructureLoader) async { - optionsScope = loader.scope if case .loaded = optionsCatalog { return } // The draft owns the read, so a view task cancelled by a tab switch leaves it running for the view that returns. if let optionsCatalogRead { @@ -156,22 +302,26 @@ internal final class CreateTableDraft: ObservableObject { await read.value } - internal func plan(for databaseType: DatabaseType) -> CreateTablePlan { + // MARK: - Composition + + internal func plan() -> CreateTablePlan { CreateTableDraftBuilder.plan( tableName: tableName, options: tableOptions, columns: changeManager.workingColumns, indexes: changeManager.workingIndexes, foreignKeys: changeManager.workingForeignKeys, - dialect: ForeignKeyDialect.forType(databaseType), + checkConstraints: changeManager.workingCheckConstraints, + dialect: ForeignKeyDialect.forType(connection.type), includesEngineOptions: offersEngineOptions, defaults: optionDefaults, - suppliesItsOwnKey: PluginManager.shared.defaultPrimaryKeyColumn(for: databaseType) != nil + suppliesItsOwnKey: PluginManager.shared.defaultPrimaryKeyColumn(for: connection.type) != nil ) } - internal func compositionKey(scope: DatabaseScope) -> CreateTableCompositionKey { - CreateTableCompositionKey( + internal var compositionKey: CreateTableCompositionKey? { + guard let scope else { return nil } + return CreateTableCompositionKey( scope: scope, tableName: tableName, options: tableOptions, @@ -179,21 +329,19 @@ internal final class CreateTableDraft: ObservableObject { optionDefaults: optionDefaults, columns: changeManager.workingColumns, indexes: changeManager.workingIndexes, - foreignKeys: changeManager.workingForeignKeys + foreignKeys: changeManager.workingForeignKeys, + checkConstraints: changeManager.workingCheckConstraints ) } - internal func recompose(databaseType: DatabaseType, scope: DatabaseScope) async { - let key = compositionKey(scope: scope) - guard key != composedKey else { return } + // Keeps the last good statement on screen until the next one lands, and through a failure. + internal func recompose() async { + guard let scope, let key = compositionKey, key != composedKey else { return } compositionGeneration += 1 let generation = compositionGeneration do { - let statements = try await DatabaseManager.shared.createTableStatements( - plan: plan(for: databaseType), - scope: scope - ) + let statements = try await DatabaseManager.shared.createTableStatements(plan: plan(), scope: scope) guard generation == compositionGeneration else { return } composed = statements composedKey = key @@ -205,12 +353,226 @@ internal final class CreateTableDraft: ObservableObject { compositionFailure = error.localizedDescription } } + + internal func formStatements(for request: PluginCreateTableRequest) throws -> [String] { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + throw PluginCreateTableFormError(message: String(localized: "Not connected to database")) + } + return try driver.createTableStatements(for: request, schema: scope?.schema) + } + + internal var formPreview: CreateTableFormState.Preview? { + form?.preview(tableName: tableName) { request in try formStatements(for: request) } + } + + // MARK: - Preview SQL + + // Refuses what Create Table refuses, so the statements shown are the ones it would run. + internal func previewSQL(coordinator: MainContentCoordinator) { + guard hasResolvedForm, coordinator.activeSheet == nil else { return } + commitOpenEdits() + if let formState = form { + let formIssues = formState.issues(tableName: tableName) + guard formIssues.isEmpty else { + reportIncomplete(formIssues.map(\.qualifiedMessage), on: coordinator) + return + } + do { + let statements = try formStatements(for: formState.request(tableName: tableName)) + presentPreview(statements, on: coordinator) + } catch { + reportPreviewFailure(error, on: coordinator) + } + return + } + + let plan = plan() + guard plan.issues.isEmpty else { + reportIncomplete(plan.issues.map(\.qualifiedMessage), on: coordinator) + return + } + guard let scope else { return } + Task { + do { + let composed = try await DatabaseManager.shared.createTableStatements(plan: plan, scope: scope) + guard composed.issues.isEmpty, !composed.statements.isEmpty else { + reportIncomplete(composed.issues.map(\.qualifiedMessage), on: coordinator) + return + } + presentPreview(composed.statements, on: coordinator) + } catch { + reportPreviewFailure(error, on: coordinator) + } + } + } + + private func presentPreview(_ statements: [String], on coordinator: MainContentCoordinator) { + guard coordinator.activeSheet == nil, coordinator.activeCreateTableDraft === self else { return } + coordinator.toolbarState.previewStatements = statements + coordinator.activeSheet = .sqlPreview + } + + private func reportIncomplete(_ messages: [String], on coordinator: MainContentCoordinator) { + AlertHelper.showErrorSheet( + title: String(localized: "Some Changes Are Incomplete"), + message: messages.joined(separator: "\n"), + window: coordinator.contentWindow + ) + } + + private func reportPreviewFailure(_ error: any Error, on coordinator: MainContentCoordinator) { + Self.logger.error("Create Table preview failed: \(error.publicLogShape, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Preview SQL"), + message: error.localizedDescription, + window: coordinator.contentWindow + ) + } + + // MARK: - Create + + // What Cmd+S and the toolbar's Create Table run. + internal func create(coordinator: MainContentCoordinator) { + guard hasResolvedForm, !isCreating else { return } + commitOpenEdits() + if form != nil { + createFromForm(coordinator: coordinator) + } else { + createFromDefinition(coordinator: coordinator) + } + } + + private func createFromDefinition(coordinator: MainContentCoordinator) { + let plan = plan() + guard plan.issues.isEmpty else { + reportFailure(plan.issues.map(\.qualifiedMessage).joined(separator: "\n"), on: coordinator) + return + } + guard let scope else { + reportFailure(String(localized: "Not connected to database"), on: coordinator) + return + } + + isCreating = true + Task { + defer { isCreating = false } + do { + let composed = try await DatabaseManager.shared.createTableStatements(plan: plan, scope: scope) + guard composed.issues.isEmpty, !composed.statements.isEmpty else { + reportFailure(composed.issues.map(\.qualifiedMessage).joined(separator: "\n"), on: coordinator) + return + } + try await run( + composed.statements, + creating: composed.tableName ?? tableName, + in: scope, + coordinator: coordinator + ) + } catch { + Self.logger.error("Create table failed: \(error.publicLogShape, privacy: .public)") + reportFailure(error.localizedDescription, on: coordinator) + } + } + } + + private func createFromForm(coordinator: MainContentCoordinator) { + guard let formState = form else { return } + let formIssues = formState.issues(tableName: tableName) + guard formIssues.isEmpty else { + reportFailure(formIssues.map(\.qualifiedMessage).joined(separator: "\n"), on: coordinator) + return + } + guard let scope else { + reportFailure(String(localized: "Not connected to database"), on: coordinator) + return + } + + let request = formState.request(tableName: tableName) + let schema = scope.schema + isCreating = true + form?.clearSubmissionError() + + Task { + defer { isCreating = false } + do { + let statements = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try driver.createTableStatements(for: request, schema: schema) + } + guard !statements.isEmpty else { + form?.recordSubmissionError(PluginCreateTableFormError( + message: String(localized: "The form produced no statements to run.") + )) + return + } + try await run(statements, creating: request.tableName, in: scope, coordinator: coordinator) + } catch let formError as PluginCreateTableFormError { + form?.recordSubmissionError(formError) + } catch { + Self.logger.error("Create table failed: \(error.publicLogShape, privacy: .public)") + reportFailure(error.localizedDescription, on: coordinator) + } + } + } + + // The draft keeps its work after a create, so the tab stays open; the new table opens in its own tab. + private func run( + _ statements: [String], + creating name: String, + in scope: DatabaseScope, + coordinator: MainContentCoordinator + ) async throws { + do { + try await DatabaseManager.shared.executeCreateTable( + statements: statements, + databaseType: connection.type, + scope: scope, + gate: executionGate + ) + } catch let incomplete as CreateTableIncompleteError { + openCreatedTable(name, in: scope, showStructure: true, coordinator: coordinator) + AlertHelper.showErrorSheet( + title: String(localized: "Table Created with Errors"), + message: incomplete.message, + recoverySuggestion: String( + localized: "The table exists, but a statement after CREATE TABLE failed. Finish the change in its Structure tab." + ), + window: coordinator.contentWindow + ) + return + } + openCreatedTable(name, in: scope, showStructure: false, coordinator: coordinator) + } + + private func openCreatedTable( + _ name: String, + in scope: DatabaseScope, + showStructure: Bool, + coordinator: MainContentCoordinator + ) { + coordinator.openTableTab( + name, + schema: scope.schema, + database: scope.database.nilIfEmpty, + showStructure: showStructure + ) + AppCommands.shared.objectChanged.send( + DatabaseObjectChange(connectionId: connection.id, scope: scope, name: name, kind: .rows) + ) + } + + private func reportFailure(_ message: String, on coordinator: MainContentCoordinator) { + AlertHelper.showErrorSheet( + title: String(localized: "Create Table Failed"), + message: message, + window: coordinator.contentWindow + ) + } } extension CreateTableDraft: ActiveGridModelProviding { - // The definition grid, unless the driver supplied a form of its own. + // The section holding the selection while the Definition page is on screen; a driver's form has no grid. var activeGridModel: DataGridModel? { - guard hasResolvedForm, form == nil else { return nil } - return gridAdapter?.model + guard editsDefinition, pane == .definition else { return nil } + return editor.focusedModel ?? editor.adapter(for: .columns).model } } diff --git a/apple/TablePro/Views/Structure/CreateTableFormEditor.swift b/apple/TablePro/Views/Structure/CreateTableFormEditor.swift deleted file mode 100644 index 0c44badea8..0000000000 --- a/apple/TablePro/Views/Structure/CreateTableFormEditor.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// CreateTableFormEditor.swift -// TablePro -// - -import SwiftUI -import TableProPluginKit - -private enum CreateTableFormPane: CaseIterable { - case form - case preview - - var displayName: String { - switch self { - case .form: String(localized: "Form") - case .preview: String(localized: "Preview") - } - } -} - -internal struct CreateTableFormEditor: View { - @ObservedObject internal var draft: CreateTableDraft - internal let databaseType: DatabaseType - internal let isCreating: Bool - internal let generateStatements: (PluginCreateTableRequest) throws -> [String] - internal let onCreate: () -> Void - - @State private var pane: CreateTableFormPane = .form - - internal var body: some View { - if let form = draft.form { - let issues = form.issues(tableName: draft.tableName) - let messages = [form.submissionError?.message].compactMap { $0 } + issues.map(\.qualifiedMessage) - VStack(spacing: 0) { - PaneTabBar( - title: String(localized: "View"), - selection: $pane, - identifier: "create-table-form-tabs" - ) { - ForEach(CreateTableFormPane.allCases, id: \.self) { tab in - Text(tab.displayName).tag(tab) - } - } - - Divider() - - Group { - switch pane { - case .form: - CreateTableFormView(draft: draft) - case .preview: - CreateTableFormPreview( - preview: form.preview(tableName: draft.tableName, generate: generateStatements), - databaseType: databaseType - ) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - - HStack(spacing: 8) { - if let first = messages.first { - Label(first, systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .help(messages.joined(separator: "\n")) - .accessibilityIdentifier("create-table-validation") - } - - Spacer(minLength: 12) - - Button( - isCreating ? String(localized: "Creating…") : String(localized: "Create Table"), - action: onCreate - ) - .buttonStyle(.borderedProminent) - .tint(.accentColor) - .disabled(!issues.isEmpty || isCreating) - .keyboardShortcut(.return, modifiers: .command) - .accessibilityIdentifier("create-table-commit") - } - .padding(.horizontal) - .padding(.vertical, 8) - } - } - } -} diff --git a/apple/TablePro/Views/Structure/CreateTableGridAdapter.swift b/apple/TablePro/Views/Structure/CreateTableGridAdapter.swift deleted file mode 100644 index 599cc720d7..0000000000 --- a/apple/TablePro/Views/Structure/CreateTableGridAdapter.swift +++ /dev/null @@ -1,327 +0,0 @@ -// -// CreateTableGridAdapter.swift -// TablePro -// - -import AppKit -import TableProPluginKit - -// The Create Table grid: every row is a definition the draft holds, edited by the definition a row -// names. Held by the draft, so the grid's state survives the tab being switched away and back. -@MainActor -final class CreateTableGridAdapter { - let model: DataGridModel - let store: StructureGridStateStore - let structureChangeManager: StructureChangeManager - let connection: DatabaseConnection - let referenceMenus: ForeignKeyReferenceMenus - - weak var coordinator: MainContentCoordinator? - // Holds the catalog the Charset and Collation cells pick from, and the table's own character set. - weak var draft: CreateTableDraft? - var serverSupport = StructureServerSupport.unrestricted - private(set) var structureTab: StructureTab = .columns - - var schemaName: String? { - get { referenceMenus.schemaName } - set { referenceMenus.schemaName = newValue } - } - - private(set) var provider: StructureRowProvider - private var sortDescriptor: StructureSortDescriptor? - private var snapshot = StructureGridSnapshot() - private let changeTracking: StructureGridChangeState - private let changeState: AnyChangeManager - - init(structureChangeManager: StructureChangeManager, connection: DatabaseConnection) { - self.structureChangeManager = structureChangeManager - self.connection = connection - let store = StructureGridStateStore() - self.store = store - self.model = DataGridModel(store: store) - self.referenceMenus = ForeignKeyReferenceMenus(connectionId: connection.id, databaseType: connection.type) - self.changeTracking = StructureGridChangeState(manager: structureChangeManager) - self.changeState = AnyChangeManager(changeTracking) - self.provider = StructureRowProvider( - changeManager: structureChangeManager, - tab: .columns, - databaseType: connection.type, - additionalFields: [.primaryKey], - serverSupport: .unrestricted - ) - referenceMenus.onListsChanged = { [weak self] in - self?.coordinator?.inspectorRowSourceRevision += 1 - } - model.dataSource = self - model.actions = self - } - - func select(_ tab: StructureTab) { - guard tab != structureTab else { return } - model.commitEditing() - structureTab = tab - sortDescriptor = nil - store.switchSubTab(to: tab) - store.sortState = SortState() - refresh() - } - - var characterSetContext: ColumnCharacterSetMenus.Context { - ColumnCharacterSetMenus.Context( - catalog: draft?.optionsCatalog ?? .idle, - tableCharacterSet: draft?.tableOptions.charset - ) - } - - func characterSetCatalogDidChange() { - refresh() - coordinator?.inspectorRowSourceRevision += 1 - } - - func refresh() { - changeTracking.tab = structureTab - changeTracking.rowCount = StructureGridEdits.rowCount(on: structureTab, manager: structureChangeManager) - let provider = StructureRowProvider( - changeManager: structureChangeManager, - tab: structureTab, - databaseType: connection.type, - additionalFields: [.primaryKey], - serverSupport: serverSupport, - sortDescriptor: sortDescriptor, - picksCharacterSets: ColumnCharacterSetMenus.offersMenus(characterSetContext.catalog) - ) - self.provider = provider - let rows = provider.asTableRows() - let states = rows.rows.map { row in - StructureGridEdits.visualState( - sourceIndex: StructureRowProvider.sourceIndex(of: row.id) ?? -1, - tab: structureTab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - tintsModifiedFields: false - ) - } - model.configuration = makeConfiguration(provider: provider) - let previous = snapshot - snapshot = StructureGridSnapshot(rows: rows, states: states) - model.applyHeldSnapshot(from: previous, to: snapshot) - if previous.rows.rows != rows.rows || previous.states != states || previous.rows.columns != rows.columns { - coordinator?.inspectorRowSourceRevision += 1 - } - } - - private func makeConfiguration(provider: StructureRowProvider) -> DataGridConfiguration { - var configuration = DataGridConfiguration() - configuration.dropdownColumns = provider.dropdownColumns - configuration.typePickerColumns = provider.typePickerColumns - configuration.customDropdownOptions = provider.customDropdownOptions - configuration.isEditable = true - configuration.traits = DataGridEngineTraits( - databaseType: connection.type, - connectionScope: DatabaseScope( - connectionId: connection.id, - database: DatabaseManager.shared.browseDatabaseName(for: connection), - schema: schemaName - ), - looksUpUserDefinedTypes: true - ) - configuration.commands = [.addRow, .deleteRows, .valueFilter, .freezeColumns] - return configuration - } - - // MARK: - Commands - - func menuOptions(sourceIndex: Int, field: Int) -> [GridMenuOption]? { - switch structureTab { - case .indexes: - guard structureChangeManager.workingIndexes.indices.contains(sourceIndex) else { return nil } - return StructureRowProvider.indexMenuOptions( - columnIndex: field, - index: structureChangeManager.workingIndexes[sourceIndex], - serverSupport: serverSupport - ) - case .foreignKeys: - guard structureChangeManager.workingForeignKeys.indices.contains(sourceIndex) else { return nil } - return referenceMenus.options( - columnIndex: field, - foreignKey: structureChangeManager.workingForeignKeys[sourceIndex], - tableColumns: structureChangeManager.workingColumns.map(\.name) - ) - case .columns: - guard structureChangeManager.workingColumns.indices.contains(sourceIndex), - provider.orderedColumnFields.indices.contains(field) else { return nil } - return ColumnCharacterSetMenus.options( - for: provider.orderedColumnFields[field], - column: structureChangeManager.workingColumns[sourceIndex], - context: characterSetContext - ) - case .checkConstraints, .options, .ddl, .parts, .triggers: - return nil - } - } - - func characterSetLockedFields(sourceIndex: Int) -> Set { - guard structureTab == .columns, structureChangeManager.workingColumns.indices.contains(sourceIndex) else { return [] } - return ColumnCharacterSetMenus.lockedFieldIndices( - orderedFields: provider.orderedColumnFields, - column: structureChangeManager.workingColumns[sourceIndex], - catalog: characterSetContext.catalog - ) - } - - @discardableResult - func applyEdit(rowID: RowID, field: Int, text: String) -> Bool { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: rowID) else { return false } - let applied = write(text, field: field, sourceIndex: sourceIndex) - refresh() - return applied - } - - private func write(_ text: String, field: Int, sourceIndex: Int) -> Bool { - let characterSets = characterSetContext - guard field >= 0, - !StructureGridEdits.refuses( - text, - field: field, - sourceIndex: sourceIndex, - tab: structureTab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - characterSets: characterSets - ) else { return false } - let changedReference = StructureGridEdits.apply( - text, - field: field, - sourceIndex: sourceIndex, - tab: structureTab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - connection: connection, - characterSets: characterSets - ) - if let changedReference { - referenceMenus.prefetchReferencedColumns( - of: changedReference.referencedTable, schema: changedReference.referencedSchema - ) - } - return true - } - - func addRow() { - StructureGridEdits.addRow(on: structureTab, manager: structureChangeManager) - refresh() - } - - func deleteRows(_ rowIDs: [RowID]) { - let indices = rowIDs.compactMap(StructureRowProvider.sourceIndex(of:)) - guard !indices.isEmpty else { return } - let displayRows = IndexSet(rowIDs.compactMap { model.displayIndex(of: $0) }) - StructureGridEdits.delete(sourceIndices: indices, tab: structureTab, manager: structureChangeManager) - refresh() - let remaining = model.rowCount - guard remaining > 0, let minRow = displayRows.first, let maxRow = displayRows.last else { - model.clearSelection() - return - } - let target = maxRow < remaining ? maxRow : (minRow > 0 ? minRow - 1 : 0) - if let rowID = model.rowID(at: target) { - model.selectRows([rowID]) - } else { - model.clearSelection() - } - } - - func undo() { - model.commitEditing() - structureChangeManager.undo() - refresh() - } - - func redo() { - model.commitEditing() - structureChangeManager.redo() - refresh() - } -} - -extension CreateTableGridAdapter: DataGridDataSource { - var gridRows: TableRows { snapshot.rows } - var gridChangeState: AnyChangeManager { changeState } - - func gridVisualState(for rowID: RowID) -> RowVisualState? { - snapshot.state(for: rowID) - } - - func gridMenuOptions(for rowID: RowID, column: DataGridColumn) -> [GridMenuOption]? { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: rowID) else { return nil } - let options = menuOptions(sourceIndex: sourceIndex, field: column.dataIndex) - if structureTab == .columns, provider.orderedColumnFields.indices.contains(column.dataIndex), - ColumnCharacterSetMenus.isCharacterSetField(provider.orderedColumnFields[column.dataIndex]) { - draft?.retryOptionsCatalog() - } - return options - } -} - -extension CreateTableGridAdapter: DataGridActions { - func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool { - let writes = edits.compactMap { edit -> (Int, Int, String)? in - StructureRowProvider.sourceIndex(of: edit.rowID).map { ($0, edit.dataIndex, edit.newValue.asText ?? "") } - } - guard !writes.isEmpty else { return false } - var applied = false - let writeAll = { - for (sourceIndex, field, text) in writes { - let wrote = self.write(text, field: field, sourceIndex: sourceIndex) - applied = applied || wrote - } - } - if writes.count > 1 { - structureChangeManager.performAsOneUndoStep(writeAll) - } else { - writeAll() - } - refresh() - return applied - } - - func gridDeleteRows(_ rowIDs: [RowID]) { - deleteRows(rowIDs) - } - - func gridAddRow() { - addRow() - } - - func gridSortDidChange(_ sort: SortState) { - if let primary = sort.columns.first { - sortDescriptor = StructureSortDescriptor(column: primary.columnIndex, ascending: primary.direction == .ascending) - store.sortState = SortState(columns: [primary], source: .user) - } else { - sortDescriptor = nil - store.sortState = SortState(columns: [], source: .user) - } - refresh() - } -} - -extension CreateTableGridAdapter: InspectorRowSource { - func inspectorRow(atDisplayRow displayRow: Int) -> InspectorRow? { - guard let rowID = model.rowID(at: displayRow), - let sourceIndex = StructureRowProvider.sourceIndex(of: rowID), - let providerRow = provider.displayRow(ofSourceIndex: sourceIndex) else { return nil } - return StructureInspectorRowBuilder.row( - atDisplayRow: providerRow, - tab: structureTab, - provider: provider, - canEditSchema: true, - lockedFieldIndices: characterSetLockedFields(sourceIndex: sourceIndex), - rowOptions: { menuOptions(sourceIndex: sourceIndex, field: $0) } - ) - } - - func commitInspectorField(displayRow: Int, fieldIndex: Int, value: String?) { - guard let rowID = model.rowID(at: displayRow) else { return } - applyEdit(rowID: rowID, field: fieldIndex, text: value ?? "") - } -} diff --git a/apple/TablePro/Views/Structure/CreateTableView.swift b/apple/TablePro/Views/Structure/CreateTableView.swift index ffdb61b7ac..1d626da883 100644 --- a/apple/TablePro/Views/Structure/CreateTableView.swift +++ b/apple/TablePro/Views/Structure/CreateTableView.swift @@ -3,548 +3,208 @@ // TablePro // -import AppKit -import Combine -import os import SwiftUI import TableProPluginKit -private enum CreateTableTab: CaseIterable { - case columns - case indexes - case foreignKeys - case sqlPreview - - var displayName: String { - switch self { - case .columns: String(localized: "Columns") - case .indexes: String(localized: "Indexes") - case .foreignKeys: String(localized: "Foreign Keys") - case .sqlPreview: String(localized: "SQL Preview") - } - } -} - +// A Create Table tab: the Structure tab's Definition page over an empty table, with the name on top, and the +// CREATE statement it composes. Create Table runs from the toolbar and Cmd+S; the table options are in the inspector. struct CreateTableView: View { - private static let logger = Logger(subsystem: "com.TablePro", category: "CreateTableView") - - let connection: DatabaseConnection - - let scope: DatabaseScope? - var coordinator: MainContentCoordinator? - - @Environment(\.appServices) private var services - @ObservedObject var draft: CreateTableDraft - @ObservedObject private var gridModel: DataGridModel - private let gridAdapter: CreateTableGridAdapter - - private var structureChangeManager: StructureChangeManager { draft.changeManager } + let coordinator: MainContentCoordinator? - @State private var selectedTab: CreateTableTab = .columns - @State private var isCreating = false - @State private var errorMessage: String? - @State private var showError = false - @State private var actionHandler = CreateTableActionHandler() + @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var ddlFontSize: Double = 13 - init( - connection: DatabaseConnection, - scope: DatabaseScope?, - coordinator: MainContentCoordinator?, - draft: CreateTableDraft - ) { - self.connection = connection - self.scope = scope - self.coordinator = coordinator - self.draft = draft - let adapter = draft.gridAdapter(for: connection) - self.gridAdapter = adapter - self.gridModel = adapter.model - } + private static let compositionPause: Duration = .milliseconds(150) var body: some View { VStack(spacing: 0) { - configBar - Divider() - editorContent + if draft.hasResolvedForm { + paneBar + Divider() + paneContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Color.clear + } } .navigationTitle(String(localized: "Create Table")) .onAppear { - draft.resolveForm( - from: DatabaseManager.shared.driver(for: connection.id)?.createTableFormSpec(schema: scope?.schema) - ) - actionHandler.createTable = { createTable() } - coordinator?.createTableActions = actionHandler - if draft.form == nil { - attachStructureGrid() - } - coordinator?.toolbarState.hasCreateTablePending = isReadyToCreate + draft.prepare(coordinator: coordinator) + publishReadiness() } .onDisappear { - // Guarded by identity: the outgoing view's onDisappear can land after the incoming one's onAppear. - if coordinator?.createTableActions === actionHandler { - coordinator?.createTableActions = nil - coordinator?.toolbarState.hasCreateTablePending = false - } - if coordinator?.inspectorRowSource === gridAdapter { - coordinator?.inspectorRowSource = nil - } - } - .onChange(of: selectedTab) { _ in gridAdapter.select(structureTab) } - .onChange(of: structureChangeManager.reloadVersion) { _ in gridAdapter.refresh() } - .onChange(of: structureChangeManager.hasChanges) { _ in gridAdapter.refresh() } - .onChange(of: isReadyToCreate) { _ in updateCreateTablePendingState() } - .onChange(of: draft.optionsCatalog) { _ in gridAdapter.characterSetCatalogDidChange() } - .task(id: scope) { await loadTableOptions() } - .task(id: compositionKey) { await recomposeAfterPause() } - .alert(String(localized: "Create Table Failed"), isPresented: $showError) { - Button("OK") {} - } message: { - Text(verbatim: RevealedText(errorMessage ?? "").plainText) - } - } - - @ViewBuilder - private var editorContent: some View { - if draft.form != nil { - CreateTableFormEditor( - draft: draft, - databaseType: connection.type, - isCreating: isCreating, - generateStatements: { request in try formStatements(for: request) }, - onCreate: { createTable() } - ) - } else if draft.hasResolvedForm { - tabBar - Divider() - tabContent - .frame(maxWidth: .infinity, maxHeight: .infinity) - Divider() - commitBar - } else { - Color.clear - } - } - - private func attachStructureGrid() { - coordinator?.inspectorRowSource = gridAdapter - gridAdapter.coordinator = coordinator - gridAdapter.serverSupport = StructureServerSupport.forConnection(connection.id) - gridAdapter.schemaName = coordinator?.toolbarState.currentSchema - if structureChangeManager.workingColumns.isEmpty { - structureChangeManager.addNewColumn() - } - gridAdapter.select(structureTab) - gridAdapter.refresh() - actionHandler.undo = { gridAdapter.undo() } - actionHandler.redo = { gridAdapter.redo() } - } - - // MARK: - Config Bar - - private var configBar: some View { - HStack(spacing: 12) { - Text("Table Name:") - .font(.body.weight(.medium)) - - TextField("Enter table name", text: $draft.tableName) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled(true) - .frame(maxWidth: 300) - .accessibilityLabel(String(localized: "Table Name")) - .accessibilityIdentifier("create-table-name") - - if offersTableComment { - Text("Comment:") - .font(.body.weight(.medium)) - - TextField("Optional", text: $draft.tableOptions.comment) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 300) - .accessibilityLabel(String(localized: "Comment")) - .accessibilityIdentifier("create-table-comment") - } - - if let catalog = draft.catalog, draft.hasResolvedForm, draft.form == nil { - Divider() - .frame(height: 20) - - engineOptions(catalog) - } - - Spacer() + // Guarded: the outgoing view's onDisappear can land after the incoming one's onAppear. + if let active = coordinator?.activeCreateTableDraft, active !== draft { return } + coordinator?.toolbarState.hasCreateTablePending = false } - .padding() - .paneBarBackground() + .onChange(of: draft.isReadyToCreate) { _ in publishReadiness() } + .task(id: draft.scope) { await draft.loadTableOptions() } + .task(id: draft.compositionKey) { await recomposeAfterPause() } } - // Lists from the server's catalog, started from the target database's defaults. Nil writes no clause. - @ViewBuilder - private func engineOptions(_ catalog: TableOptionsCatalog) -> some View { - let options = draft.tableOptions - if !catalog.engines.isEmpty || options.engine != nil { - Picker("Engine:", selection: $draft.tableOptions.engine) { - if options.engine == nil { - Text("Default").tag(String?.none) - } - ForEach(TableOptionsForm.engines(in: catalog, keeping: options.engine)) { choice in - Text(choice.title).tag(Optional(choice.name)) - } - } - .fixedSize() - .accessibilityIdentifier("create-table-engine") - } - - Picker("Charset:", selection: characterSetBinding) { - if options.charset == nil { - Text("Default").tag(String?.none) - } - ForEach(TableOptionsForm.characterSets(in: catalog, keeping: options.charset), id: \.self) { name in - Text(name).tag(Optional(name)) - } - } - .fixedSize() - .accessibilityIdentifier("create-table-charset") - - Picker("Collation:", selection: $draft.tableOptions.collation) { - if options.collation == nil { - Text("Default").tag(String?.none) - } - ForEach( - TableOptionsForm.collations(in: catalog, characterSet: options.charset, keeping: options.collation) - ) { choice in - Text(choice.title).tag(Optional(choice.name)) - } - } - .fixedSize() - .disabled(options.charset == nil) - .accessibilityIdentifier("create-table-collation") + private func publishReadiness() { + coordinator?.toolbarState.hasCreateTablePending = draft.isReadyToCreate } - // One whole-value write: the collation follows the charset, so a second write would resolve against a stale draft. - private var characterSetBinding: Binding { - Binding( - get: { draft.tableOptions.charset }, - set: { draft.selectCharacterSet($0) } - ) - } - - private func loadTableOptions() async { - guard let scope else { return } - await draft.loadTableOptions(scope: scope) - } - - private var offersTableComment: Bool { - guard draft.hasResolvedForm, draft.form == nil else { return false } - let support = DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: connection.id)) - return support.commentableTypes.contains(.table) + private func recomposeAfterPause() async { + try? await Task.sleep(for: Self.compositionPause) + guard !Task.isCancelled else { return } + await draft.recompose() } - // MARK: - Tabs + // MARK: - Panes - private var availableTabs: [CreateTableTab] { - var tabs = CreateTableTab.allCases - if !connection.type.supportsForeignKeys { - tabs = tabs.filter { $0 != .foreignKeys } - } - return tabs - } - - private var tabBar: some View { + private var paneBar: some View { PaneTabBar( title: String(localized: "Structure"), - selection: $selectedTab.committingEdit(of: gridModel), + selection: paneSelection, identifier: "create-table-tabs" ) { - ForEach(availableTabs, id: \.self) { tab in - Text(tab.displayName).tag(tab) + ForEach(CreateTablePane.allCases, id: \.self) { pane in + Text(title(of: pane)).tag(pane) } } } - // One grid serves the three grid tabs: the adapter repoints it as the selection moves. - @ViewBuilder - private var tabContent: some View { - switch selectedTab { - case .columns, .indexes, .foreignKeys: - structureGridPane - case .sqlPreview: - sqlPreviewView - } + // An open cell edit lands before the pane changes, so the DDL includes it. + private var paneSelection: Binding { + Binding( + get: { draft.pane }, + set: { pane in + draft.commitOpenEdits() + draft.pane = pane + } + ) } - // MARK: - Commit Bar - - private var commitBar: some View { - let issues = draft.composed?.issues ?? draft.plan(for: connection.type).issues - - return HStack(spacing: 8) { - issueMessage(issues) - - Spacer(minLength: 12) - - Button(isCreating ? String(localized: "Creating…") : String(localized: "Create Table")) { - createTable() + private func title(of pane: CreateTablePane) -> String { + switch pane { + case .definition: + if draft.form == nil { + return String(localized: "Definition") } - .buttonStyle(.borderedProminent) - .tint(.accentColor) - .disabled(!issues.isEmpty || isCreating) - .keyboardShortcut(.return, modifiers: .command) - .accessibilityIdentifier("create-table-commit") + return String(localized: "Form") + case .ddl: + return "DDL" } - .padding(.horizontal) - .padding(.vertical, 8) } @ViewBuilder - private func issueMessage(_ issues: [SchemaDraftIssue]) -> some View { - if let first = issues.first { - Label(messageText(first), systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .help(issues.map(\.qualifiedMessage).joined(separator: "\n")) - .accessibilityIdentifier("create-table-validation") - } - } - - private func messageText(_ issue: SchemaDraftIssue) -> String { - issue.tab == structureTab ? issue.message : issue.qualifiedMessage - } - - // MARK: - Structure Grid - - private var structureTab: StructureTab { - switch selectedTab { - case .columns: return .columns - case .indexes: return .indexes - case .foreignKeys: return .foreignKeys - case .sqlPreview: return .columns - } - } - - private var structureGridPane: some View { - VStack(spacing: 0) { - DataGridView(model: gridModel) - Divider() - rowButtons(identifier: rowButtonsIdentifier) - } - } - - private var rowButtonsIdentifier: String { - switch selectedTab { - case .columns, .sqlPreview: return "create-table-columns" - case .indexes: return "create-table-indexes" - case .foreignKeys: return "create-table-foreign-keys" - } - } - - // Add and Delete act on the shown tab's rows, since the adapter follows the selected tab. - private func rowButtons(identifier: String) -> some View { - HStack(spacing: 8) { - Button(action: { gridAdapter.addRow() }) { - Image(systemName: "plus") - .frame(width: 24, height: 24) - } - .help(String(localized: "Add Row")) - .accessibilityLabel(String(localized: "Add Row")) - .accessibilityIdentifier("\(identifier)-add") - - Button(action: { gridAdapter.deleteRows(gridModel.selectedRowIDs) }) { - Image(systemName: "minus") - .frame(width: 24, height: 24) + private var paneContent: some View { + switch draft.pane { + case .definition: + if draft.form != nil { + VStack(spacing: 0) { + CreateTableNameHeader(draft: draft) + Divider() + CreateTableFormView(draft: draft) + } + } else { + StructureDefinitionPage( + editor: draft.editor, + loadState: StructureLoadState(), + header: AnyView(CreateTableNameHeader(draft: draft)) + ) } - .help(String(localized: "Delete Selected")) - .accessibilityLabel(String(localized: "Delete Selected")) - .disabled(gridModel.selection.isEmpty) - .accessibilityIdentifier("\(identifier)-remove") - - Spacer() + case .ddl: + ddlPane } - .padding(8) } - // MARK: - SQL Preview - @ViewBuilder - private var sqlPreviewView: some View { - if let composed = draft.composed { + private var ddlPane: some View { + if let preview = draft.formPreview { + CreateTableFormPreview(preview: preview, databaseType: draft.connection.type) + } else if let composed = draft.composed { if composed.statements.isEmpty { CreateTableSQLPreviewStandIn(content: .message(composed.issues.first?.qualifiedMessage)) } else { - DDLTextView(ddl: composed.preview, fontSize: .constant(13), databaseType: connection.type) + DDLTextView(ddl: composed.preview, fontSize: $ddlFontSize, databaseType: draft.connection.type) } } else if let failure = draft.compositionFailure { CreateTableSQLPreviewStandIn(content: .message(failure)) } else { - uncomposedPreview(plan: draft.plan(for: connection.type)) - } - } - - private func uncomposedPreview(plan: CreateTablePlan) -> some View { - CreateTableSQLPreviewStandIn( - content: plan.definition == nil ? .message(plan.issues.first?.qualifiedMessage) : .composing - ) - } - - // MARK: - SQL Generation - - private static let compositionPause: Duration = .milliseconds(150) - - private var compositionKey: CreateTableCompositionKey? { - scope.map { draft.compositionKey(scope: $0) } - } - - private func recomposeAfterPause() async { - guard let scope else { return } - try? await Task.sleep(for: Self.compositionPause) - guard !Task.isCancelled else { return } - await draft.recompose(databaseType: connection.type, scope: scope) - } - - // MARK: - Create Table - - private var isReadyToCreate: Bool { - guard !isCreating else { return false } - if let form = draft.form { - return form.issues(tableName: draft.tableName).isEmpty - } - guard let composed = draft.composed else { return false } - return composed.issues.isEmpty && !composed.statements.isEmpty - } - - private func formStatements(for request: PluginCreateTableRequest) throws -> [String] { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - throw PluginCreateTableFormError(message: String(localized: "Not connected to database")) + let plan = draft.plan() + CreateTableSQLPreviewStandIn( + content: plan.definition == nil ? .message(plan.issues.first?.qualifiedMessage) : .composing + ) } - return try driver.createTableStatements(for: request, schema: scope?.schema) } +} - private func updateCreateTablePendingState() { - coordinator?.toolbarState.hasCreateTablePending = isReadyToCreate - } +// The table's name and what still stands between the draft and Create Table. An issue that names a row reveals it. +struct CreateTableNameHeader: View { + @ObservedObject var draft: CreateTableDraft + @FocusState private var isNameFocused: Bool - private func createTable() { - guard draft.form != nil else { - createTableFromStructureGrid() - return - } - createTableFromForm() + private struct Row: Identifiable { + let id: String + let message: String + let reveal: (() -> Void)? } - private func createTableFromStructureGrid() { - guard !isCreating else { return } - let plan = draft.plan(for: connection.type) - guard plan.issues.isEmpty else { - errorMessage = plan.issues.map(\.qualifiedMessage).joined(separator: "\n") - showError = true - return - } - guard let scope else { - errorMessage = String(localized: "Not connected to database") - showError = true - return - } - - isCreating = true - errorMessage = nil - updateCreateTablePendingState() - - Task { - defer { isCreating = false } - do { - let composed = try await DatabaseManager.shared.createTableStatements(plan: plan, scope: scope) - guard composed.issues.isEmpty, !composed.statements.isEmpty else { - errorMessage = composed.issues.map(\.qualifiedMessage).joined(separator: "\n") - showError = true - return + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Table Name:") + TextField(String(localized: "Table Name"), text: $draft.tableName, prompt: Text("Required")) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled(true) + .frame(maxWidth: 300) + .focused($isNameFocused) + .accessibilityIdentifier("create-table-name") + } + let issueRows = rows + if !issueRows.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(issueRows) { row in + issueRow(row) + } } - let createdName = composed.tableName ?? draft.tableName - try await runCreateTable(statements: composed.statements, createdName: createdName, in: scope) - } catch { - Self.logger.error("Create table failed: \(error.publicLogShape, privacy: .public)") - errorMessage = error.localizedDescription - showError = true + .accessibilityElement(children: .contain) + .accessibilityIdentifier("create-table-validation") } } - } - - private func createTableFromForm() { - guard !isCreating, let form = draft.form else { return } - let issues = form.issues(tableName: draft.tableName) - guard issues.isEmpty else { - errorMessage = issues.map(\.qualifiedMessage).joined(separator: "\n") - showError = true - return - } - guard let scope else { - errorMessage = String(localized: "Not connected to database") - showError = true - return + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { + if draft.claimNameFocus() { + isNameFocused = true + } } + } - let request = form.request(tableName: draft.tableName) - let schema = scope.schema - isCreating = true - errorMessage = nil - draft.form?.clearSubmissionError() - updateCreateTablePendingState() - - Task { - defer { isCreating = false } - do { - let statements = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - try driver.createTableStatements(for: request, schema: schema) - } - guard !statements.isEmpty else { - draft.form?.recordSubmissionError(PluginCreateTableFormError( - message: String(localized: "The form produced no statements to run.") - )) - return - } - try await runCreateTable(statements: statements, createdName: request.tableName, in: scope) - } catch let formError as PluginCreateTableFormError { - draft.form?.recordSubmissionError(formError) - } catch { - Self.logger.error("Create table failed: \(error.publicLogShape, privacy: .public)") - errorMessage = error.localizedDescription - showError = true + private func issueRow(_ row: Row) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + if let reveal = row.reveal { + Button(row.message, action: reveal) + .buttonStyle(.link) + } else { + Text(row.message) + .foregroundStyle(.secondary) } } + .font(.callout) + .fixedSize(horizontal: false, vertical: true) } - private func runCreateTable(statements: [String], createdName: String, in scope: DatabaseScope) async throws { - do { - try await DatabaseManager.shared.executeCreateTable( - statements: statements, - databaseType: connection.type, - scope: scope - ) - } catch let incomplete as CreateTableIncompleteError { - openCreatedTable(createdName, in: scope, showStructure: true) - AlertHelper.showErrorSheet( - title: String(localized: "Table Created with Errors"), - message: incomplete.message, - recoverySuggestion: String( - localized: "The table exists, but a statement after CREATE TABLE failed. Finish the change in its Structure tab." - ), - window: coordinator?.contentWindow + private var rows: [Row] { + if let form = draft.form { + let messages = [form.submissionError?.message].compactMap { $0 } + + form.issues(tableName: draft.tableName).map(\.qualifiedMessage) + return messages.enumerated().map { Row(id: "\($0.offset)", message: $0.element, reveal: nil) } + } + return draft.issues.enumerated().map { offset, issue in + Row( + id: "\(offset)-\(issue.id)", + message: issue.row == nil ? issue.message : issue.qualifiedMessage, + reveal: draft.entityID(for: issue) == nil ? nil : { draft.reveal(issue) } ) - return } - openCreatedTable(createdName, in: scope, showStructure: false) - } - - private func openCreatedTable(_ name: String, in scope: DatabaseScope, showStructure: Bool) { - let created = DatabaseObjectChange(connectionId: connection.id, scope: scope, name: name, kind: .rows) - coordinator?.openTableTab( - name, - schema: scope.schema, - database: scope.database.nilIfEmpty, - showStructure: showStructure - ) - AppCommands.shared.objectChanged.send(created) } } diff --git a/apple/TablePro/Views/Structure/Inspector/CreateTablePropertiesView.swift b/apple/TablePro/Views/Structure/Inspector/CreateTablePropertiesView.swift new file mode 100644 index 0000000000..09d4303321 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/CreateTablePropertiesView.swift @@ -0,0 +1,117 @@ +// +// CreateTablePropertiesView.swift +// TablePro +// + +import SwiftUI + +// The drafted table's comment and engine options, when nothing on the Definition page is selected. The lists come +// from the server's catalog and start from the target database's defaults; nil writes no clause. +struct CreateTablePropertiesView: View { + @ObservedObject var draft: CreateTableDraft + + private var catalog: TableOptionsCatalog? { + guard case .loaded(let catalog) = draft.optionsCatalog else { return nil } + return catalog + } + + private var offersComment: Bool { + let driver = DatabaseManager.shared.driver(for: draft.editor.context.connection.id) + return DatabaseObjectToolEligibility.Support.of(driver).commentableTypes.contains(.table) + } + + var body: some View { + let offersComment = draft.form == nil && self.offersComment + let catalog = draft.form == nil ? self.catalog : nil + if !offersComment, catalog == nil { + UnavailableStateView( + String(localized: "No Selection"), + systemImage: TrailingPaneSurface.inspector.symbolName, + description: Text(String(localized: "Select a definition to edit it")) + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + StructureInspectorSectionHeader(title: String(localized: "Table")) + VStack(alignment: .leading, spacing: 10) { + if offersComment { + StructureInspectorRow(label: String(localized: "Comment")) { + TextField(String(localized: "Optional"), text: $draft.tableOptions.comment, axis: .vertical) + .lineLimit(2...6) + .textFieldStyle(.squareBorder) + .accessibilityLabel(String(localized: "Comment")) + .accessibilityIdentifier("create-table-comment") + } + } + if let catalog { + engineRows(catalog) + } + } + } + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.subheadline) + .controlSize(.small) + .disabled(draft.editor.isReadOnly) + } + } + + @ViewBuilder + private func engineRows(_ catalog: TableOptionsCatalog) -> some View { + let options = draft.tableOptions + if !catalog.engines.isEmpty || options.engine != nil { + StructureInspectorRow(label: String(localized: "Engine")) { + Picker(String(localized: "Engine"), selection: $draft.tableOptions.engine) { + if options.engine == nil { + Text(String(localized: "Default")).tag(String?.none) + } + ForEach(TableOptionsForm.engines(in: catalog, keeping: options.engine)) { choice in + Text(choice.title).tag(Optional(choice.name)) + } + } + .labelsHidden() + .accessibilityIdentifier("create-table-engine") + } + } + + StructureInspectorRow(label: String(localized: "Character Set")) { + Picker(String(localized: "Character Set"), selection: characterSetBinding) { + if options.charset == nil { + Text(String(localized: "Default")).tag(String?.none) + } + ForEach(TableOptionsForm.characterSets(in: catalog, keeping: options.charset), id: \.self) { name in + Text(name).tag(Optional(name)) + } + } + .labelsHidden() + .accessibilityIdentifier("create-table-charset") + } + + StructureInspectorRow(label: String(localized: "Collation")) { + Picker(String(localized: "Collation"), selection: $draft.tableOptions.collation) { + if options.collation == nil { + Text(String(localized: "Default")).tag(String?.none) + } + ForEach( + TableOptionsForm.collations(in: catalog, characterSet: options.charset, keeping: options.collation) + ) { choice in + Text(choice.title).tag(Optional(choice.name)) + } + } + .labelsHidden() + .disabled(options.charset == nil) + .accessibilityIdentifier("create-table-collation") + } + } + + // One whole-value write: the collation follows the character set, so a second write would resolve against a + // stale draft. + private var characterSetBinding: Binding { + Binding( + get: { draft.tableOptions.charset }, + set: { draft.selectCharacterSet($0) } + ) + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift b/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift new file mode 100644 index 0000000000..c1f1bc5f28 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift @@ -0,0 +1,151 @@ +// +// StructureDefinitionFormView.swift +// TablePro +// + +import SwiftUI + +// The selected column, index, foreign key or check constraint as a form: each field's name above a control the pane's +// width, the layout the Data view's inspector uses, since a label column leaves a 270pt pane too little room. +struct StructureDefinitionFormView: View { + let editor: StructureDefinitionEditor + let form: StructureInspectorForm + let revision: Int + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if form.isDeleted { + Label( + String(localized: "Marked for deletion. Undo or Undo Delete brings it back."), + systemImage: "trash" + ) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("structure-inspector-deleted-note") + } + + VStack(alignment: .leading, spacing: 10) { + ForEach(form.fields) { field in + if case .checkbox = StructureInspectorControl.resolve(field) { + control(for: field) + } else { + StructureInspectorRow(label: field.label, isModified: field.isModified) { + control(for: field) + } + } + } + } + } + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.subheadline) + .controlSize(.small) + .onAppear { + // Reading the character sets is what a Charset or Collation menu opening does in the grid. + guard form.section == .columns, form.isEditable else { return } + editor.readCharacterSetCatalogIfNeeded() + } + } + + @ViewBuilder + private func control(for field: StructureInspectorForm.Field) -> some View { + let identifier = StructureInspectorPresentation.controlIdentifier(section: form.section, field: field.id) + let value = field.value ?? "" + switch StructureInspectorControl.resolve(field) { + case .text: + StructureInspectorTextField(label: field.label, value: value, revision: revision) { commit($0, to: field) } + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) + case .multiline: + StructureInspectorTextField( + label: field.label, value: value, revision: revision, isMultiline: true + ) { commit($0, to: field) } + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) + case .checkbox: + Toggle( + isOn: Binding( + get: { value == StructureRowProvider.booleanOptions[0] }, + set: { isOn in + commit(isOn ? StructureRowProvider.booleanOptions[0] : StructureRowProvider.booleanOptions[1], to: field) + } + ) + ) { + Text(field.label) + .fontWeight(field.isModified ? .semibold : .regular) + } + .toggleStyle(.checkbox) + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) + case .popUp(let choices): + popUp(choices, field: field) + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) + case .comboBox(let choices): + StructureInspectorSuggestionField( + label: field.label, + value: value, + revision: revision, + identifier: identifier, + groups: choices.groups + ) { commit($0, to: field) } + case .typePicker: + StructureInspectorTypeField( + value: value, + revision: revision, + label: field.label, + identifier: identifier, + databaseType: editor.context.databaseType, + scope: editor.context.scope + ) { commit($0, to: field) } + case .readOnly(let text): + Text(text) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel(field.label) + .accessibilityValue(text) + .accessibilityIdentifier(identifier) + } + } + + private func popUp(_ choices: StructureInspectorChoices, field: StructureInspectorForm.Field) -> some View { + Picker( + selection: Binding( + get: { choices.selectedID ?? Self.noSelection }, + set: { id in + guard let value = choices.value(of: id) else { return } + commit(value, to: field) + } + ) + ) { + ForEach(choices.groups) { group in + if let header = group.header { + Section { + ForEach(group.items) { Text($0.title).tag($0.id) } + } header: { + Text(header) + } + } else { + ForEach(group.items) { Text($0.title).tag($0.id) } + } + } + } label: { + Text(field.label) + } + .pickerStyle(.menu) + .labelsHidden() + .fixedSize(horizontal: false, vertical: true) + } + + // A tag no item carries, so a value the menu does not list shows nothing selected rather than another item. + private static let noSelection = -1 + + private func commit(_ value: String, to field: StructureInspectorForm.Field) { + guard value != (field.value ?? "") else { return } + editor.commitInspectorField(section: form.section, id: form.entityID, field: field.id, value: value) + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift new file mode 100644 index 0000000000..ec290eaf94 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift @@ -0,0 +1,234 @@ +// +// StructureInspectorControls.swift +// TablePro +// + +import SwiftUI + +// A definition field's text: one write on Return or when focus leaves, so a typed value is one undo step. Typing +// survives a refresh while the field has focus. +struct StructureInspectorTextField: View { + let label: String + let value: String + let revision: Int + var isMultiline = false + let onCommit: (String) -> Void + + @State private var draft = "" + // What the draft was last synced from, so only typing commits, never a value the model moved under the field. + @State private var base = "" + // The stored value, kept while focused, so leaving a field whose write was refused puts it back. + @State private var latest = "" + @State private var hasSeeded = false + @FocusState private var isFocused: Bool + @Environment(\.structureDefinitionEditor) private var editor + + private struct Sync: Equatable { + let value: String + let revision: Int + } + + var body: some View { + field + .textFieldStyle(.squareBorder) + .autocorrectionDisabled(true) + .focused($isFocused) + .onAppear(perform: seed) + // The revision moves on a refused write too, which puts the stored value back in the field. + .onChange(of: Sync(value: value, revision: revision)) { sync in + latest = sync.value + guard !isFocused else { return } + draft = sync.value + base = sync.value + } + .onChange(of: isFocused) { focused in + guard !focused, !commit() else { return } + draft = latest + base = latest + } + .onChange(of: draft) { text in + guard isFocused, text != base else { + editor?.clearFieldDraft() + return + } + editor?.noteFieldDraft(text, commit: onCommit) + } + .onSubmit { commit() } + .onDisappear { commit() } + } + + // The label column names the field, so an empty one shows no placeholder. + @ViewBuilder + private var field: some View { + if isMultiline { + TextField(text: $draft, prompt: Text(verbatim: ""), axis: .vertical) { Text(label) } + .lineLimit(2...6) + .labelsHidden() + } else { + TextField(text: $draft, prompt: Text(verbatim: "")) { Text(label) } + .labelsHidden() + } + } + + private func seed() { + guard !hasSeeded else { return } + hasSeeded = true + draft = value + base = value + latest = value + } + + @discardableResult + private func commit() -> Bool { + editor?.clearFieldDraft() + guard hasSeeded, draft != base else { return false } + base = draft + onCommit(draft) + return true + } +} + +// Free text with the engine's types and the database's own types to pick from, grouped as the grid's type picker +// groups them. +struct StructureInspectorTypeField: View { + let value: String + let revision: Int + let label: String + let identifier: String + let databaseType: DatabaseType + let scope: DatabaseScope? + let onCommit: (String) -> Void + + var body: some View { + UserDefinedTypeAwarePicker(scope: scope) { userDefinedTypes in + StructureInspectorSuggestionField( + label: label, + value: value, + revision: revision, + identifier: identifier, + groups: Self.groups(userDefinedTypes: userDefinedTypes, databaseType: databaseType), + onCommit: onCommit + ) + } + } + + static func groups(userDefinedTypes: [String], databaseType: DatabaseType) -> [StructureInspectorChoices.ItemGroup] { + var categories = PluginManager.shared.columnTypesByCategory(for: databaseType) + .sorted { $0.key < $1.key } + .map { (name: $0.key, types: $0.value) } + if !userDefinedTypes.isEmpty { + categories.insert((name: String(localized: "User-Defined"), types: userDefinedTypes), at: 0) + } + var nextID = 0 + var groups: [StructureInspectorChoices.ItemGroup] = [] + for (offset, category) in categories.enumerated() { + var items: [StructureInspectorChoices.Item] = [] + for type in category.types { + items.append(StructureInspectorChoices.Item(id: nextID, title: type, value: type)) + nextID += 1 + } + groups.append(StructureInspectorChoices.ItemGroup(id: offset, header: category.name, items: items)) + } + return groups + } +} + +// A text field with its common values one click away, the way a combo box offers them. A SwiftUI field and a +// pull-down menu, so it draws like every other field in the pane; an AppKit combo box keeps the pre-glass bezel. +struct StructureInspectorSuggestionField: View { + let label: String + let value: String + let revision: Int + let identifier: String + let groups: [StructureInspectorChoices.ItemGroup] + let onCommit: (String) -> Void + + var body: some View { + HStack(spacing: 4) { + StructureInspectorTextField(label: label, value: value, revision: revision, onCommit: onCommit) + .accessibilityLabel(label) + .accessibilityIdentifier(identifier) + Menu { + ForEach(groups) { group in + if let header = group.header { + Section(header) { items(group) } + } else { + items(group) + } + } + } label: { + Label(String(format: String(localized: "Choose %@"), label), systemImage: "chevron.down") + .labelStyle(.iconOnly) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(String(format: String(localized: "Choose %@"), label)) + .accessibilityIdentifier("\(identifier)-choices") + } + } + + private func items(_ group: StructureInspectorChoices.ItemGroup) -> some View { + ForEach(group.items) { item in + Button(item.title) { + guard item.value != value else { return } + onCommit(item.value) + } + } + } +} + +// One field: its name above, the control the pane's full width below, so every field shares the pane's leading edge +// as the Data view's inspector and the statistics do. Modified settings read bold, the way Xcode marks them. +struct StructureInspectorRow: View { + let label: String + var isModified = false + @ViewBuilder let control: () -> Control + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .fontWeight(isModified ? .semibold : .regular) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityHidden(true) + control() + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// The inspector's section title, the style the table statistics below it use. +struct StructureInspectorSectionHeader: View { + let title: String + + var body: some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.isHeader) + } +} + +struct StructureInspectorFootnote: View { + let text: String + + var body: some View { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct StructureDefinitionEditorKey: EnvironmentKey { + static let defaultValue: StructureDefinitionEditor? = nil +} + +extension EnvironmentValues { + var structureDefinitionEditor: StructureDefinitionEditor? { + get { self[StructureDefinitionEditorKey.self] } + set { self[StructureDefinitionEditorKey.self] = newValue } + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorForm.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorForm.swift new file mode 100644 index 0000000000..6ae7fb2cd5 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorForm.swift @@ -0,0 +1,116 @@ +// +// StructureInspectorForm.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// One definition's fields as the inspector edits them. Built from the same provider and menus the grid uses, so +// the two never disagree, and written back through the grid's own edit path, one undo step per field. +struct StructureInspectorForm: Equatable { + struct Field: Equatable, Identifiable { + enum Kind: Equatable { + case text + case multiline + case toggle + case menu([GridMenuOption]) + case combo([GridMenuOption]) + case typePicker + } + + let id: Int + let label: String + let value: String? + let kind: Kind + let isModified: Bool + let isEditable: Bool + } + + let section: StructureSection + let entityID: UUID + let name: String + let fields: [Field] + let isEditable: Bool + let isDeleted: Bool + let isNew: Bool + + @MainActor + static func build(adapter: StructureSectionAdapter, sourceIndex: Int, entityID: UUID) -> StructureInspectorForm? { + let provider = StructureRowProvider( + changeManager: adapter.manager, + tab: adapter.section, + databaseType: adapter.context.databaseType, + additionalFields: [.primaryKey], + serverSupport: adapter.serverSupport, + picksCharacterSets: ColumnCharacterSetMenus.offersMenus(adapter.characterSetContext.catalog) + ) + guard let values = provider.row(at: sourceIndex) else { return nil } + let names = provider.columns + let state = adapter.manager.deleteInsertState(for: sourceIndex, tab: adapter.section) + let modified = provider.modifiedFieldIndices(atDisplay: sourceIndex) + let locked = adapter.lockedFieldIndices.union(adapter.characterSetLockedFields(sourceIndex: sourceIndex)) + let isEditable = adapter.canEdit && !state.isDeleted + let fields = names.indices.map { index in + Field( + id: index, + label: names[index], + value: index < values.count ? values[index] : nil, + kind: kind( + at: index, + section: adapter.section, + orderedFields: provider.orderedColumnFields, + options: adapter.menuOptions(sourceIndex: sourceIndex, field: index) ?? provider.customDropdownOptions[index], + typePickerColumns: provider.typePickerColumns + ), + isModified: !state.isInserted && modified.contains(index), + isEditable: isEditable && !locked.contains(index) + ) + } + return StructureInspectorForm( + section: adapter.section, + entityID: entityID, + name: (values.first ?? nil) ?? "", + fields: fields, + isEditable: isEditable, + isDeleted: state.isDeleted, + isNew: state.isInserted + ) + } + + @MainActor + static func kind( + at index: Int, + section: StructureSection, + orderedFields: [StructureColumnField], + options: [GridMenuOption]?, + typePickerColumns: Set + ) -> Field.Kind { + if typePickerColumns.contains(index) { + return .typePicker + } + if let options, !options.isEmpty { + if options.compactMap(\.sql) == StructureRowProvider.booleanOptions { + return .toggle + } + if options.contains(where: { if case .custom = $0 { true } else { false } }) { + return .combo(options) + } + return .menu(options) + } + switch section { + case .columns: + guard orderedFields.indices.contains(index) else { return .text } + switch orderedFields[index] { + case .comment, .generationExpression: return .multiline + default: return .text + } + case .checkConstraints: + return index == 1 ? .multiline : .text + case .indexes: + return index == 4 ? .multiline : .text + case .foreignKeys: + return .text + } + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorPresentation.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorPresentation.swift new file mode 100644 index 0000000000..fe896e4a8e --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorPresentation.swift @@ -0,0 +1,121 @@ +// +// StructureInspectorPresentation.swift +// TablePro +// + +import Foundation + +enum StructureInspectorPresentation { + @MainActor + static func subject( + for target: StructureDefinitionEditor.InspectorTarget, + definitionName: String?, + tableName: String + ) -> InspectorSubject { + let table = tableName.isEmpty ? nil : tableName + switch target { + case .entity(let section, _): + return .definition(name: definitionName ?? "", section: section, table: table) + case .table, .several: + return .tableOnly(table: table ?? String(localized: "New Table")) + } + } + + static func severalTitle(_ section: StructureSection, count: Int) -> String { + switch section { + case .columns: String(format: String(localized: "%d Columns Selected"), count) + case .indexes: String(format: String(localized: "%d Indexes Selected"), count) + case .foreignKeys: String(format: String(localized: "%d Foreign Keys Selected"), count) + case .checkConstraints: String(format: String(localized: "%d Check Constraints Selected"), count) + } + } + + static func controlIdentifier(section: StructureSection, field: Int) -> String { + "structure-inspector-\(section.rawValue)-\(field)" + } +} + +// The control a definition field gets in the inspector. A field the form cannot edit is plain text. +enum StructureInspectorControl: Equatable { + case text + case multiline + case checkbox + case popUp(StructureInspectorChoices) + case comboBox(StructureInspectorChoices) + case typePicker + case readOnly(String) + + static func resolve(_ field: StructureInspectorForm.Field) -> StructureInspectorControl { + let value = field.value ?? "" + switch field.kind { + case .text: + return field.isEditable ? .text : .readOnly(value) + case .multiline: + return field.isEditable ? .multiline : .readOnly(value) + case .toggle: + return field.isEditable ? .checkbox : .readOnly(value) + case .typePicker: + return field.isEditable ? .typePicker : .readOnly(value) + case .menu(let options): + let choices = StructureInspectorChoices(options: options, current: value) + return field.isEditable ? .popUp(choices) : .readOnly(choices.selectedTitle ?? value) + case .combo(let options): + return field.isEditable ? .comboBox(StructureInspectorChoices(options: options, current: value)) : .readOnly(value) + } + } +} + +// A grid menu as a pop-up button's items. The value a field holds is always an item, listed or not, since a pop-up +// whose selection matches no tag shows nothing selected. +struct StructureInspectorChoices: Equatable { + struct Item: Equatable, Identifiable { + let id: Int + let title: String + let value: String + } + + struct ItemGroup: Equatable, Identifiable { + let id: Int + let header: String? + var items: [Item] + } + + let groups: [ItemGroup] + let selectedID: Int? + + var items: [Item] { groups.flatMap(\.items) } + + var selectedTitle: String? { + items.first { $0.id == selectedID }?.title + } + + func value(of id: Int) -> String? { + items.first { $0.id == id }?.value + } + + init(options: [GridMenuOption], current: String) { + var groups = [ItemGroup(id: options.count + 1, header: nil, items: [])] + for (index, option) in options.enumerated() { + switch option { + case .sectionHeader(let title): + groups.append(ItemGroup(id: index, header: title, items: [])) + case .value(let title, let sql): + groups[groups.count - 1].items.append(Item(id: index, title: title, value: sql)) + case .clear(let title): + groups[groups.count - 1].items.append(Item(id: index, title: title, value: "")) + case .custom: + continue + } + } + let listed = groups.flatMap(\.items) + var selected = listed.first { $0.value == current }?.id + ?? listed.first { $0.value.caseInsensitiveCompare(current) == .orderedSame }?.id + if selected == nil, !current.isEmpty { + let kept = Item(id: options.count, title: current, value: current) + groups[0].items.insert(kept, at: 0) + selected = kept.id + } + self.groups = groups.filter { $0.header != nil || !$0.items.isEmpty } + selectedID = selected + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift new file mode 100644 index 0000000000..39aa29f483 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift @@ -0,0 +1,90 @@ +// +// StructureInspectorView.swift +// TablePro +// + +import SwiftUI + +// The window inspector while a Structure view or a Create Table tab is on screen: the selected definition's form, +// or the table's own properties when nothing is selected. +struct StructureInspectorView: View { + let source: StructureInspectorSource + let tableMetadata: TableMetadata? + + var body: some View { + switch source { + case .table(let session): + StructureInspectorContent(editor: session.editor, tableName: session.tableName) { + StructureTablePropertiesView( + session: session, + changeManager: session.changeManager, + tableMetadata: tableMetadata + ) + } + case .createTable(let draft): + CreateTableInspectorContent(draft: draft) + } + } +} + +private struct CreateTableInspectorContent: View { + @ObservedObject var draft: CreateTableDraft + + var body: some View { + StructureInspectorContent(editor: draft.editor, tableName: draft.tableName) { + CreateTablePropertiesView(draft: draft) + } + } +} + +private struct StructureInspectorContent: View { + @ObservedObject var editor: StructureDefinitionEditor + let tableName: String + @ViewBuilder let tableProperties: () -> TableProperties + + var body: some View { + let target = editor.inspectorTarget + let form = selectedForm(target) + VStack(spacing: 0) { + InspectorSubjectView( + subject: StructureInspectorPresentation.subject( + for: target, + definitionName: form?.name, + tableName: tableName + ) + ) + content(target: target, form: form) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .environment(\.structureDefinitionEditor, editor) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("structure-inspector") + } + + private func selectedForm(_ target: StructureDefinitionEditor.InspectorTarget) -> StructureInspectorForm? { + guard case .entity(let section, let id) = target else { return nil } + return editor.inspectorForm(for: section, id: id) + } + + @ViewBuilder + private func content( + target: StructureDefinitionEditor.InspectorTarget, + form: StructureInspectorForm? + ) -> some View { + switch target { + case .table: + tableProperties() + case .entity: + if let form { + StructureDefinitionFormView(editor: editor, form: form, revision: editor.revision) + .id(form.entityID) + } + case .several(let section, let count): + UnavailableStateView( + StructureInspectorPresentation.severalTitle(section, count: count), + systemImage: TrailingPaneSurface.inspector.symbolName, + description: Text(String(localized: "Select one to edit it")) + ) + } + } +} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureTablePropertiesView.swift b/apple/TablePro/Views/Structure/Inspector/StructureTablePropertiesView.swift new file mode 100644 index 0000000000..1cfce1caf4 --- /dev/null +++ b/apple/TablePro/Views/Structure/Inspector/StructureTablePropertiesView.swift @@ -0,0 +1,439 @@ +// +// StructureTablePropertiesView.swift +// TablePro +// + +import SwiftUI + +// What the inspector shows for a table with no definition selected: its comment, its default character set and +// AUTO_INCREMENT counter, and the read-only statistics. +struct StructureTablePropertiesView: View { + @ObservedObject var session: StructureEditingSession + @ObservedObject var changeManager: StructureChangeManager + let tableMetadata: TableMetadata? + + // A session-bound table belongs to one connection's session, so its definition is not this window's to change. + private var isReadOnly: Bool { + session.editor.isReadOnly || session.isSessionBound + } + + var body: some View { + let offersComment = session.offersTableComment + let offersOptions = session.offersTableOptionsTab + if !offersComment, !offersOptions, tableMetadata == nil { + UnavailableStateView( + String(localized: "No Selection"), + systemImage: TrailingPaneSurface.inspector.symbolName, + description: Text(String(localized: "Select a definition to edit it")) + ) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if offersComment { + StructureTableCommentSection( + session: session, changeManager: changeManager, isReadOnly: isReadOnly + ) + } + if offersOptions { + StructureTableOptionsSection( + session: session, changeManager: changeManager, isReadOnly: isReadOnly + ) + } + if let tableMetadata { + TableInfoSections(metadata: tableMetadata, includesComment: false) + } + } + .padding(.horizontal, InspectorMetrics.horizontalInset) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.subheadline) + .controlSize(.small) + } + } +} + +// MARK: - Comment + +private struct StructureTableCommentSection: View { + @ObservedObject var session: StructureEditingSession + @ObservedObject var changeManager: StructureChangeManager + let isReadOnly: Bool + + @FocusState private var isFocused: Bool + + private var field: StructureTableCommentPolicy.Field? { + StructureTableCommentPolicy.resolve( + objectKind: session.objectKind, + support: DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: session.connection.id)), + isReadOnly: isReadOnly, + load: session.tableComment.erased, + isSaving: changeManager.isHeldForSave || session.editor.isAwaitingBaseline + ) + } + + // One model write per keystroke, so a save from the keyboard never misses what is still in the field. + private var text: Binding { + Binding( + get: { changeManager.tableComment.text }, + set: { changeManager.stageTableComment($0) } + ) + } + + var body: some View { + if let field { + VStack(alignment: .leading, spacing: 6) { + StructureInspectorSectionHeader(title: String(localized: "Table")) + VStack(alignment: .leading, spacing: 10) { + StructureInspectorRow( + label: String(localized: "Comment"), + isModified: changeManager.tableComment.hasChanges + ) { + TextField(field.placeholder, text: text, axis: .vertical) + .lineLimit(2...6) + .textFieldStyle(.squareBorder) + .disabled(!field.isEditable) + .focused($isFocused) + .onSubmit { changeManager.endTableCommentRun() } + .help(field.unavailableReason ?? "") + .accessibilityLabel(String(localized: "Comment")) + .accessibilityIdentifier("structure-table-comment") + } + } + } + .onChange(of: isFocused) { focused in + guard !focused else { return } + changeManager.endTableCommentRun() + } + .onDisappear { changeManager.endTableCommentRun() } + } + } +} + +// MARK: - Options + +// Every edit is one whole-value write to the change manager, so the coupled character set and collation pick +// lands as one staged change and one undo step. +private struct StructureTableOptionsSection: View { + @ObservedObject var session: StructureEditingSession + @ObservedObject var changeManager: StructureChangeManager + let isReadOnly: Bool + + @State private var counterText = "" + @FocusState private var isCounterFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + StructureInspectorSectionHeader(title: String(localized: "Options")) + if let options = displayedOptions { + form(options) + } else { + stateView + } + } + .task { await session.readTableOptionsCatalog() } + .onAppear { counterText = Self.text(displayedCounter) } + .onChange(of: displayedCounter) { counter in + guard !isCounterFocused else { return } + counterText = Self.text(counter) + } + // Text that is not a valid counter stays in the field beside its error, since it holds Save until fixed. + .onChange(of: isCounterFocused) { focused in + guard !focused, entry(counterText)?.message == nil else { return } + counterText = Self.text(displayedCounter) + } + } + + // MARK: State + + private var draft: TableOptions? { changeManager.tableOptions.current } + + // A read that landed while edits were staged has not become the baseline yet, so it shows read-only. + private var displayedOptions: TableOptions? { + draft ?? (session.tableOptions.value ?? nil) + } + + private var isCounterConfirmed: Bool { + session.autoIncrementRead == .confirmed && changeManager.tableOptions.isAutoIncrementConfirmed + } + + // An unconfirmed counter may be one the server moved past; a read deferred behind staged edits shows read-only. + private var displayedCounter: UInt64? { + guard session.autoIncrementRead == .confirmed else { return nil } + guard changeManager.tableOptions.isAutoIncrementConfirmed else { return session.loadedTableOptions?.autoIncrement } + return displayedOptions?.autoIncrement + } + + // The draft's column, not the loaded one: the counter is set after the column work in the same save. + private var autoIncrementMaximum: UInt64? { + changeManager.autoIncrementColumn.map { AutoIncrementEntry.maximum(for: $0) } + } + + private var editability: StructureTableOptionsPolicy.Editability { + StructureTableOptionsPolicy.resolve( + hasBaseline: draft != nil, + isReadOnly: isReadOnly, + isSaving: changeManager.isHeldForSave || session.editor.isAwaitingBaseline + ) + } + + private var catalog: TableOptionsCatalog? { + guard case .loaded(let catalog) = session.tableOptionsCatalog else { return nil } + return catalog + } + + private func entry(_ text: String) -> AutoIncrementEntry? { + autoIncrementMaximum.map { + AutoIncrementEntry(text, maximum: $0, original: changeManager.tableOptions.original?.autoIncrement) + } + } + + private static func text(_ counter: UInt64?) -> String { + counter.map { String($0) } ?? "" + } + + // A retried read lands only in the session; the baseline moves unless edits are staged. + private func retryOptions() { + Task { + await session.readTableOptions() + session.rebaselineUnlessEdited() + } + } + + @ViewBuilder + private var stateView: some View { + switch session.tableOptions { + case .idle, .loading: + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .leading) + case .failed(let message): + VStack(alignment: .leading, spacing: 6) { + Label(String(localized: "Couldn't read the table options"), systemImage: "exclamationmark.triangle") + RevealedTextView(message) + .foregroundStyle(.secondary) + Button(String(localized: "Try Again"), action: retryOptions) + .accessibilityIdentifier("structure-options-retry") + } + case .loaded: + StructureInspectorFootnote(text: String(localized: "This table has no options to change.")) + } + } + + // MARK: Form + + private func form(_ options: TableOptions) -> some View { + let editability = self.editability + let original = changeManager.tableOptions.original + return VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { + StructureInspectorRow( + label: String(localized: "Character Set"), + isModified: draft != nil && options.characterSet != original?.characterSet + ) { + Picker(String(localized: "Character Set"), selection: characterSetBinding) { + if options.characterSet == nil { + Text(String(localized: "Unknown")).tag(String?.none) + } + ForEach(TableOptionsForm.characterSets(in: catalog, keeping: options.characterSet), id: \.self) { + Text($0).tag(Optional($0)) + } + } + .labelsHidden() + .disabled(!editability.isEditable || catalog == nil) + .accessibilityIdentifier("structure-options-charset") + } + + StructureInspectorRow( + label: String(localized: "Collation"), + isModified: draft != nil && options.collation != original?.collation + ) { + Picker(String(localized: "Collation"), selection: collationBinding) { + if options.collation == nil { + Text(String(localized: "Unknown")).tag(String?.none) + } + ForEach(collationChoices(options)) { choice in + Text(choice.title).tag(Optional(choice.name)) + } + } + .labelsHidden() + .disabled(!editability.isEditable || catalog == nil) + .accessibilityIdentifier("structure-options-collation") + } + + Group { + VStack(alignment: .leading, spacing: 4) { + Toggle(String(localized: "Convert existing columns"), isOn: convertBinding) + .toggleStyle(.checkbox) + .fontWeight(options.convertsColumns ? .semibold : .regular) + .disabled(!editability.isEditable) + .accessibilityIdentifier("structure-options-convert") + StructureInspectorFootnote(text: Self.convertFootnote(options.convertsColumns)) + } + } + + catalogStatusRow + + if let maximum = autoIncrementMaximum { + counterRows(maximum: maximum, isEditable: editability.isEditable, original: original) + } + } + + if let reason = editability.unavailableReason, !changeManager.isHeldForSave { + Label(reason, systemImage: "lock") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("structure-options-unavailable-note") + } + } + } + + private static func convertFootnote(_ converts: Bool) -> String { + guard converts else { + return String(localized: "Applies to columns added later. Existing columns keep their character set.") + } + return String( + localized: "Rewrites every text column to this character set. TEXT can grow to MEDIUMTEXT, and the table is copied while it runs." + ) + } + + private func collationChoices(_ options: TableOptions) -> [TableOptionsForm.Choice] { + TableOptionsForm.collations(in: catalog, characterSet: options.characterSet, keeping: options.collation) + } + + @ViewBuilder + private var catalogStatusRow: some View { + switch session.tableOptionsCatalog { + case .idle, .loading: + Group { + HStack(spacing: 6) { + ProgressView() + .controlSize(.mini) + StructureInspectorFootnote(text: String(localized: "Reading the server's character sets…")) + } + } + case .failed: + Group { + VStack(alignment: .leading, spacing: 4) { + Label(String(localized: "Couldn't read the server's character sets."), systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(String(localized: "Try Again")) { + Task { await session.readTableOptionsCatalog() } + } + .accessibilityIdentifier("structure-options-catalog-retry") + } + } + case .loaded: + EmptyView() + } + } + + @ViewBuilder + private func counterRows(maximum: UInt64, isEditable: Bool, original: TableOptions?) -> some View { + StructureInspectorRow( + label: String(localized: "Next Auto Increment"), + isModified: draft != nil && draft?.autoIncrement != original?.autoIncrement + ) { + TextField(String(localized: "Next Auto Increment"), text: counterBinding) + .labelsHidden() + .textFieldStyle(.squareBorder) + .focused($isCounterFocused) + .disabled(!isEditable || !isCounterConfirmed) + .accessibilityLabel(String(localized: "Next Auto Increment")) + .accessibilityIdentifier("structure-options-auto-increment") + } + // The server raises a value below the next free one, so a saved counter is read back, never shown as typed. + switch session.autoIncrementRead { + case .rereading: + Group { + HStack(spacing: 6) { + ProgressView() + .controlSize(.mini) + StructureInspectorFootnote(text: String(localized: "Reading the saved value…")) + } + } + case .unconfirmed: + Group { + VStack(alignment: .leading, spacing: 4) { + Label(String(localized: "Couldn't read the saved value."), systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + Button(String(localized: "Try Again"), action: retryOptions) + .accessibilityIdentifier("structure-options-auto-increment-retry") + } + } + case .confirmed: + EmptyView() + } + if let message = AutoIncrementEntry(counterText, maximum: maximum, original: original?.autoIncrement).message { + Group { + Text(message) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("structure-options-auto-increment-error") + } + } + Group { + StructureInspectorFootnote(text: String(localized: "A value at or below the highest existing ID becomes that ID plus one.")) + } + } + + // MARK: Bindings + + private var characterSetBinding: Binding { + Binding( + get: { displayedOptions?.characterSet }, + set: { name in + guard let name, let current = draft, name != current.characterSet else { return } + changeManager.stageTableOptions(TableOptionsForm.selectingCharacterSet(name, in: current, catalog: catalog)) + } + ) + } + + private var collationBinding: Binding { + Binding( + get: { displayedOptions?.collation }, + set: { name in + guard let name, var next = draft, name != next.collation else { return } + next.collation = name + changeManager.stageTableOptions(next) + } + ) + } + + private var convertBinding: Binding { + Binding( + get: { displayedOptions?.convertsColumns ?? false }, + set: { converts in + guard var next = draft, converts != next.convertsColumns else { return } + next.convertsColumns = converts + changeManager.stageTableOptions(next) + } + ) + } + + // Staged per keystroke, as the comment is, so a save from the keyboard never misses a value still in the field. + // Text that is not a valid counter stages the loaded one and holds Save until it is fixed or cleared. + private var counterBinding: Binding { + Binding( + get: { counterText }, + set: { text in + counterText = text + guard var next = draft, let entry = entry(text) else { return } + switch entry { + case .value(let value): + next.autoIncrement = value + case .empty, .invalid: + next.autoIncrement = changeManager.tableOptions.original?.autoIncrement + } + changeManager.noteAutoIncrementEntry(invalid: entry.message) + guard next != draft else { return } + changeManager.stageTableOptions(next) + } + ) + } +} diff --git a/apple/TablePro/Views/Structure/StructureDDLPane.swift b/apple/TablePro/Views/Structure/StructureDDLPane.swift new file mode 100644 index 0000000000..c6b0e7a544 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureDDLPane.swift @@ -0,0 +1,133 @@ +// +// StructureDDLPane.swift +// TablePro +// + +import AppKit +import os +import SwiftUI +import UniformTypeIdentifiers + +// The table's CREATE statement as the database reports it, read-only, with the ways to take it elsewhere. +struct StructureDDLPane: View { + private static let logger = Logger(subsystem: "com.TablePro", category: "StructureDDLPane") + + let ddl: String + let tableName: String + let databaseType: DatabaseType + let failure: String? + let isLoading: Bool + let coordinator: MainContentCoordinator? + let onRetry: () -> Void + + @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize: Double = 13 + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + ControlGroup { + Button { + fontSize = max(10, fontSize - 1) + } label: { + Label(String(localized: "Decrease Font Size"), systemImage: "textformat.size.smaller") + } + Button { + fontSize = min(24, fontSize + 1) + } label: { + Label(String(localized: "Increase Font Size"), systemImage: "textformat.size.larger") + } + } + .controlGroupStyle(.navigation) + .labelStyle(.iconOnly) + .fixedSize() + + Spacer() + + Button(String(localized: "Open in Editor"), action: openInEditor) + .disabled(ddl.isEmpty) + Button(String(localized: "Copy"), action: copy) + .disabled(ddl.isEmpty) + Button(String(localized: "Export…"), action: export) + .disabled(ddl.isEmpty) + } + .controlSize(.small) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .paneBarBackground() + + Divider() + + content + } + } + + @ViewBuilder + private var content: some View { + if let failure, ddl.isEmpty { + UnavailableStateView { + Label(String(localized: "Couldn't Read the DDL"), systemImage: "exclamationmark.triangle") + } description: { + Text(failure) + } actions: { + Button(String(localized: "Try Again"), action: onRetry) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .valueStandInBackground() + } else if ddl.isEmpty { + StructureDDLStandIn(isLoading: isLoading) + } else { + DDLTextView(ddl: ddl, fontSize: $fontSize, databaseType: databaseType) + } + } + + private func openInEditor() { + guard !ddl.isEmpty else { return } + coordinator?.tabManager.addTab(initialQuery: ddl, title: String(format: String(localized: "%@ DDL"), tableName)) + } + + private func copy() { + ClipboardService.shared.writeText(ddl) + } + + private func export() { + let savePanel = NSSavePanel() + if let sqlType = UTType(filenameExtension: "sql") { + savePanel.allowedContentTypes = [sqlType] + } + savePanel.nameFieldStringValue = "\(tableName).sql" + + guard let window = coordinator?.contentWindow else { return } + let ddl = ddl + savePanel.beginSheetModal(for: window) { response in + guard response == .OK, let url = savePanel.url else { return } + do { + try ddl.write(to: url, atomically: true, encoding: .utf8) + } catch { + Self.logger.error("Failed to export: \(error.publicLogShape, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the schema"), + message: error.localizedDescription, + window: window + ) + } + } + } +} + +// Where the statement would be, on the editor's background. +struct StructureDDLStandIn: View { + var isLoading = false + + var body: some View { + Group { + if isLoading { + ProgressView().controlSize(.small) + } else { + Text(String(localized: "No DDL Available")) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .valueStandInBackground() + } +} diff --git a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift new file mode 100644 index 0000000000..10a0747cd4 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift @@ -0,0 +1,329 @@ +// +// StructureDefinitionEditor.swift +// TablePro +// + +import Combine +import Foundation +import TableProPluginKit + +// The Definition page's model, shared by the Structure tab and Create Table: one grid per section over one change +// manager, one filter, and what the inspector shows. A row selected in one section clears the others, so the +// inspector always names one thing. +@MainActor +final class StructureDefinitionEditor: ObservableObject { + enum InspectorTarget: Equatable { + case table + case entity(StructureSection, UUID) + case several(StructureSection, Int) + } + + let manager: StructureChangeManager + let context: StructureEditingContext + let referenceMenus: ForeignKeyReferenceMenus + private(set) var adapters: [StructureSection: StructureSectionAdapter] = [:] + + @Published var searchText = "" { + didSet { + guard searchText != oldValue else { return } + refresh() + } + } + @Published private(set) var inspectorTarget: InspectorTarget = .table + @Published private(set) var collapsedSections: Set + @Published var serverSupport = StructureServerSupport.unrestricted { + didSet { + guard serverSupport != oldValue else { return } + // A section the server withdraws leaves no selection behind for the inspector or the commands. + for section in StructureSection.allCases where !offers(section) { + adapter(for: section).model.clearSelection() + } + refresh() + } + } + @Published var isReadOnly = false { + didSet { + guard isReadOnly != oldValue else { return } + refresh() + } + } + // After a save, until the table is read again: an edit made now would stage against what the save replaced. + @Published var isAwaitingBaseline = false { + didSet { + guard isAwaitingBaseline != oldValue else { return } + refresh() + } + } + @Published private(set) var revision = 0 + + var readOnlyReason: String? + + weak var coordinator: MainContentCoordinator? + var moveColumnHandler: ((Int, Int) -> Void)? + var characterSetContextProvider: () -> ColumnCharacterSetMenus.Context = { + ColumnCharacterSetMenus.Context(catalog: .idle, tableCharacterSet: nil) + } + var characterSetCatalogReader: () -> Void = {} + + private let defaults: UserDefaults + private var cancellables: Set = [] + private var fieldDraft: (text: String, commit: (String) -> Void)? + private var isSyncingSelection = false + + init( + manager: StructureChangeManager, + context: StructureEditingContext, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults + ) { + self.manager = manager + self.context = context + self.defaults = defaults + let menus = ForeignKeyReferenceMenus(connectionId: context.connection.id, databaseType: context.databaseType) + menus.schemaName = context.schemaName + referenceMenus = menus + let stored = defaults.stringArray(forKey: Self.collapsedSectionsKey) ?? [] + collapsedSections = Set(stored.compactMap(StructureSection.init(rawValue:))) + for section in StructureSection.allCases { + let adapter = StructureSectionAdapter( + section: section, manager: manager, context: context, referenceMenus: menus, defaults: defaults + ) + adapters[section] = adapter + adapter.editor = self + adapter.model.$selectionRevision + .dropFirst() + .sink { [weak self] _ in self?.selectionDidChange(in: section) } + .store(in: &cancellables) + } + menus.onListsChanged = { [weak self] in self?.revision &+= 1 } + manager.$reloadVersion + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.refresh() } + .store(in: &cancellables) + } + + static let collapsedSectionsKey = "structure.collapsedSections" + + // MARK: - Sections + + var sections: [StructureSection] { + StructureSection.allCases.filter(offers) + } + + func offers(_ section: StructureSection) -> Bool { + switch section { + case .columns, .indexes: + return true + case .foreignKeys: + return context.databaseType.supportsForeignKeys + case .checkConstraints: + guard serverSupport.offers(.checkConstraints) else { return false } + guard !context.isCreatingTable else { return context.allows(.addCheckConstraint) } + return context.databaseType.supportsCheckConstraints + } + } + + func adapter(for section: StructureSection) -> StructureSectionAdapter { + guard let adapter = adapters[section] else { + preconditionFailure("Every section has an adapter") + } + return adapter + } + + func isExpanded(_ section: StructureSection) -> Bool { + !collapsedSections.contains(section) || !searchText.isEmpty + } + + func setExpanded(_ section: StructureSection, _ isExpanded: Bool) { + if isExpanded { + collapsedSections.remove(section) + } else { + collapsedSections.insert(section) + adapter(for: section).model.clearSelection() + } + defaults.set(collapsedSections.map(\.rawValue).sorted(), forKey: Self.collapsedSectionsKey) + } + + func count(of section: StructureSection) -> Int { + let ids = StructureGridEdits.entityIDs(in: section, manager: manager) + return ids.indices.filter { !manager.deleteInsertState(for: $0, tab: section).isDeleted }.count + } + + var characterSetContext: ColumnCharacterSetMenus.Context { characterSetContextProvider() } + + func readCharacterSetCatalogIfNeeded() { + characterSetCatalogReader() + } + + // MARK: - Refresh + + func refresh() { + for section in StructureSection.allCases { + adapter(for: section).refresh() + } + settleInspectorTarget() + revision &+= 1 + } + + func definitionsDidChange() { + refresh() + } + + // MARK: - Selection + + var focusedSection: StructureSection? { + switch inspectorTarget { + case .table: nil + case .entity(let section, _), .several(let section, _): section + } + } + + var focusedModel: DataGridModel? { + focusedSection.map { adapter(for: $0).model } + } + + private func selectionDidChange(in section: StructureSection) { + guard !isSyncingSelection else { return } + let model = adapter(for: section).model + if !model.selection.isEmpty { + isSyncingSelection = true + for other in StructureSection.allCases where other != section { + adapter(for: other).model.clearSelection() + } + isSyncingSelection = false + } + settleInspectorTarget() + } + + private func settleInspectorTarget() { + var target = InspectorTarget.table + for section in StructureSection.allCases { + let ids = adapter(for: section).selectedEntityIDs + guard !ids.isEmpty else { continue } + target = ids.count == 1 ? .entity(section, ids[0]) : .several(section, ids.count) + break + } + if target != inspectorTarget { + inspectorTarget = target + } + } + + func clearSelection() { + for section in StructureSection.allCases { + adapter(for: section).model.clearSelection() + } + } + + func reveal(section: StructureSection, entity id: UUID) { + if collapsedSections.contains(section) { + setExpanded(section, true) + } + adapter(for: section).select(entity: id) + } + + // MARK: - Commands + + func add(_ section: StructureSection) { + adapter(for: section).addRow() + } + + func canAdd(_ section: StructureSection) -> Bool { + let adapter = adapter(for: section) + return adapter.canEdit && adapter.canAdd + } + + func canRemoveSelection(in section: StructureSection) -> Bool { + let adapter = adapter(for: section) + return adapter.canEdit && adapter.canRemove && !adapter.model.selection.isEmpty + } + + func removeSelection(in section: StructureSection) { + let adapter = adapter(for: section) + let rowIDs = adapter.model.selection.affectedRows.compactMap { adapter.model.rowID(at: $0) } + adapter.deleteRows(rowIDs) + } + + func refusal(adding section: StructureSection) -> String? { + let adapter = adapter(for: section) + if let reason = adapter.editRefusal { return reason } + return context.refusal(context.addOperation(for: section)) + } + + // The section the menu bar's row commands act on: the one holding the selection, else Columns. + var commandSection: StructureSection { focusedSection ?? .columns } + + func selectedRowIDs(in section: StructureSection) -> [RowID] { + let model = adapter(for: section).model + return model.selection.affectedRows.compactMap { model.rowID(at: $0) } + } + + var canDuplicateSelection: Bool { + guard let section = focusedSection else { return false } + return canAdd(section) && !selectedRowIDs(in: section).isEmpty + } + + func duplicateSelection() { + guard canDuplicateSelection, let section = focusedSection else { return } + adapter(for: section).duplicateRows(selectedRowIDs(in: section)) + } + + func copySelection() { + guard let section = focusedSection else { return } + let rowIDs = selectedRowIDs(in: section) + guard !rowIDs.isEmpty else { return } + adapter(for: section).copyRows(rowIDs) + } + + var canPaste: Bool { + canAdd(commandSection) && StructurePasteboard.canPaste + } + + func paste() { + guard canPaste else { return } + adapter(for: commandSection).pasteRows() + } + + func moveColumn(from sourceIndex: Int, to destination: Int) { + moveColumnHandler?(sourceIndex, destination) + } + + // MARK: - Pending edits + + // An inspector field's typed text is committed on Return or when focus leaves; until then it is noted here, so + // Save, close and the other commands see it and commit it first. + var hasPendingFieldDraft: Bool { fieldDraft != nil } + + func noteFieldDraft(_ text: String, commit: @escaping (String) -> Void) { + fieldDraft = (text, commit) + } + + func clearFieldDraft() { + fieldDraft = nil + } + + func commitPendingEdits() { + if let draft = fieldDraft { + fieldDraft = nil + draft.commit(draft.text) + } + for section in StructureSection.allCases { + adapter(for: section).model.commitEditing() + } + } + + // MARK: - Inspector + + func entity(_ section: StructureSection, id: UUID) -> Int? { + StructureGridEdits.sourceIndex(of: id, in: section, manager: manager) + } + + func inspectorForm(for section: StructureSection, id: UUID) -> StructureInspectorForm? { + let adapter = adapter(for: section) + guard let sourceIndex = entity(section, id: id) else { return nil } + return StructureInspectorForm.build(adapter: adapter, sourceIndex: sourceIndex, entityID: id) + } + + func commitInspectorField(section: StructureSection, id: UUID, field: Int, value: String) { + adapter(for: section).applyEdit(entity: id, field: field, text: value) + } +} diff --git a/apple/TablePro/Views/Structure/StructureDefinitionPage.swift b/apple/TablePro/Views/Structure/StructureDefinitionPage.swift new file mode 100644 index 0000000000..542df40dd0 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureDefinitionPage.swift @@ -0,0 +1,242 @@ +// +// StructureDefinitionPage.swift +// TablePro +// + +import AppKit +import SwiftUI + +// Columns, indexes, foreign keys and check constraints on one scrolling page, each section with its own add and +// remove beneath it, the way Xcode's model editor stacks an entity's parts. The selected definition's full form is +// in the inspector. +struct StructureDefinitionPage: View { + @ObservedObject var editor: StructureDefinitionEditor + let loadState: StructureLoadState + var isLoading = false + var notes: [StructureSection: [StructureSectionNote]] = [:] + var header: AnyView? + var onRetry: (StructureSection) -> Void = { _ in } + + var body: some View { + VStack(spacing: 0) { + filterBar + Divider() + if isLoading { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() + } else { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 0) { + if let header { + header + } + ForEach(editor.sections, id: \.self) { section in + StructureSectionView( + section: section, + editor: editor, + model: editor.adapter(for: section).model, + failure: loadState.failure(StructureLoadState.Item(section)), + hasData: editor.context.isCreatingTable || loadState.hasData(StructureLoadState.Item(section)), + notes: notes[section] ?? [], + onRetry: { onRetry(section) } + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .gridStandInBackground() + } + } + } + + private var filterBar: some View { + NativeSearchField( + text: $editor.searchText, + placeholder: String(localized: "Filter"), + accessibilityIdentifier: "structure-filter" + ) + .padding(.horizontal, 8) + .padding(.vertical, 6) + } +} + +struct StructureSectionNote: Equatable, Identifiable { + let systemImage: String + let text: String + let identifier: String + + var id: String { identifier } +} + +struct StructureSectionView: View { + let section: StructureSection + @ObservedObject var editor: StructureDefinitionEditor + @ObservedObject var model: DataGridModel + let failure: String? + let hasData: Bool + let notes: [StructureSectionNote] + let onRetry: () -> Void + + private var adapter: StructureSectionAdapter { editor.adapter(for: section) } + private var isExpanded: Bool { editor.isExpanded(section) } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + sectionHeader + if isExpanded { + content + } + Divider() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("structure-section-\(section.rawValue)") + } + + private var sectionHeader: some View { + HStack(spacing: 6) { + DisclosureTriangle( + isExpanded: Binding( + get: { isExpanded }, + set: { editor.setExpanded(section, $0) } + ), + label: section.displayName + ) + Text(section.displayName) + .font(.headline) + if hasData { + Text(editor.count(of: section), format: .number) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.top, 10) + .padding(.bottom, 6) + .contentShape(Rectangle()) + .onTapGesture { editor.setExpanded(section, !isExpanded) } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + } + + @ViewBuilder + private var content: some View { + if let failure, !hasData { + failureRow(failure) + } else { + if let failure { + failureRow(failure) + } + if adapter.visibleRowCount > 0 { + DataGridView(model: model) + } else { + emptyRow + } + ForEach(notes) { note in + Label(note.text, systemImage: note.systemImage) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 10) + .padding(.top, 6) + .accessibilityIdentifier(note.identifier) + } + footer + } + } + + private var emptyRow: some View { + Text(emptyText) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + private var emptyText: String { + guard editor.searchText.isEmpty else { return String(localized: "No Matches") } + switch section { + case .columns: return String(localized: "No Columns") + case .indexes: return String(localized: "No Indexes") + case .foreignKeys: return String(localized: "No Foreign Keys") + case .checkConstraints: return String(localized: "No Check Constraints") + } + } + + private func failureRow(_ message: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(message) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + Spacer(minLength: 0) + Button(String(localized: "Try Again"), action: onRetry) + .controlSize(.small) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + private var footer: some View { + let labels = StructureFooterPolicy.labels(for: section) + let reason = editor.refusal(adding: section) + return AddRemoveControlGroup( + addLabel: labels.add, + removeLabel: labels.remove, + canAdd: editor.canAdd(section), + canRemove: editor.canRemoveSelection(in: section), + addHelp: reason, + removeHelp: reason, + addIdentifier: "structure-\(section.rawValue)-add", + removeIdentifier: "structure-\(section.rawValue)-remove", + onAdd: { editor.add(section) }, + onRemove: { editor.removeSelection(in: section) } + ) + .padding(.horizontal, 10) + .padding(.top, 6) + .padding(.bottom, 10) + } +} + +// The stock disclosure triangle, the control the HIG names for showing and hiding a section's details. +struct DisclosureTriangle: NSViewRepresentable { + @Binding var isExpanded: Bool + let label: String + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(title: "", target: context.coordinator, action: #selector(Coordinator.toggle(_:))) + button.bezelStyle = .disclosure + button.setButtonType(.pushOnPushOff) + button.imagePosition = .imageOnly + button.setContentHuggingPriority(.required, for: .horizontal) + return button + } + + func updateNSView(_ button: NSButton, context: Context) { + context.coordinator.parent = self + button.state = isExpanded ? .on : .off + button.setAccessibilityLabel(label) + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + @MainActor + final class Coordinator: NSObject { + var parent: DisclosureTriangle + + init(parent: DisclosureTriangle) { + self.parent = parent + } + + @objc func toggle(_ sender: NSButton) { + parent.isExpanded = sender.state == .on + } + } +} diff --git a/apple/TablePro/Views/Structure/StructureEditGate.swift b/apple/TablePro/Views/Structure/StructureEditGate.swift index 21878ef68f..c8ac61e248 100644 --- a/apple/TablePro/Views/Structure/StructureEditGate.swift +++ b/apple/TablePro/Views/Structure/StructureEditGate.swift @@ -117,21 +117,18 @@ struct StructureEditGate { && TriggerEditEligibility.kindAcceptsTriggers(objectKind) } - func locksField(at index: Int, on tab: StructureTab, orderedFields: [StructureColumnField]) -> Bool { + func locksField(at index: Int, on tab: StructureSection, orderedFields: [StructureColumnField]) -> Bool { switch tab { case .columns: guard orderedFields.indices.contains(index) else { return false } return !editableColumnFields.contains(orderedFields[index]) case .indexes, .foreignKeys, .checkConstraints: - guard let adding = StructureFooterPolicy.operation(forAdding: tab) else { return false } - return !allows(adding) - case .options, .ddl, .parts, .triggers: - return false + return !allows(StructureFooterPolicy.operation(forAdding: tab)) } } func lockedFieldIndices( - on tab: StructureTab, + on tab: StructureSection, orderedFields: [StructureColumnField], fieldCount: Int ) -> Set { diff --git a/apple/TablePro/Views/Structure/StructureEditingContext.swift b/apple/TablePro/Views/Structure/StructureEditingContext.swift new file mode 100644 index 0000000000..34aa6bf230 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureEditingContext.swift @@ -0,0 +1,97 @@ +// +// StructureEditingContext.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// The table a structure editor works on: an existing one, or the one Create Table is drafting. +@MainActor +struct StructureEditingContext { + let connection: DatabaseConnection + let databaseName: String + let schemaName: String? + let tableName: String + let objectKind: TableInfo.TableType + let isSessionBound: Bool + let isCreatingTable: Bool + + static func existingTable( + connection: DatabaseConnection, + databaseName: String, + schemaName: String?, + tableName: String, + objectKind: TableInfo.TableType, + isSessionBound: Bool + ) -> StructureEditingContext { + StructureEditingContext( + connection: connection, + databaseName: databaseName, + schemaName: schemaName, + tableName: tableName, + objectKind: objectKind, + isSessionBound: isSessionBound, + isCreatingTable: false + ) + } + + static func newTable(connection: DatabaseConnection, databaseName: String, schemaName: String?) -> StructureEditingContext { + StructureEditingContext( + connection: connection, + databaseName: databaseName, + schemaName: schemaName, + tableName: "", + objectKind: .table, + isSessionBound: false, + isCreatingTable: true + ) + } + + var databaseType: DatabaseType { connection.type } + + var scope: DatabaseScope { + DatabaseScope(connectionId: connection.id, database: databaseName, schema: schemaName, isSessionBound: isSessionBound) + } + + var editGate: StructureEditGate { + StructureEditGate(databaseType: connection.type, objectKind: objectKind, schema: schemaName, isSessionBound: isSessionBound) + } + + // A drafted table is created in one run, so every definition it can hold is open; an existing one asks the gate. + // Its checks are added after the CREATE, so they need an engine that adds one to an existing table. + func allows(_ operation: StructureEditOperation) -> Bool { + guard isCreatingTable else { return editGate.allows(operation) } + switch operation { + case .addForeignKey, .dropForeignKey: + return connection.type.supportsForeignKeys + case .addCheckConstraint, .dropCheckConstraint: + return connection.type.supportsCheckConstraintEditing + default: + return true + } + } + + func refusal(_ operation: StructureEditOperation) -> String? { + guard !isCreatingTable else { return nil } + return editGate.resolve(operation).unavailableReason + } + + func addOperation(for section: StructureSection) -> StructureEditOperation { + switch section { + case .columns: .addColumn + case .indexes: .addIndex + case .foreignKeys: .addForeignKey + case .checkConstraints: .addCheckConstraint + } + } + + func removeOperation(for section: StructureSection) -> StructureEditOperation { + switch section { + case .columns: .dropColumn + case .indexes: .dropIndex + case .foreignKeys: .dropForeignKey + case .checkConstraints: .dropCheckConstraint + } + } +} diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Apply.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Apply.swift index 441fe58031..c2f9c83325 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession+Apply.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Apply.swift @@ -88,6 +88,7 @@ internal extension StructureEditingSession { /// mounted structure view ever fills. Answering Save from anywhere else ran nothing, reported /// success, and closed the tab over the work. func applyStagedChanges(coordinator: MainContentCoordinator?) async -> StructureSaveOutcome { + editor.commitPendingEdits() /// Asked before Safe Mode, so a tab with nothing staged is never refused. `.refused` stands /// the close down, and refusing a save that had no work to do would leave the user unable to /// close the tab through Save at all. diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Baseline.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Baseline.swift deleted file mode 100644 index 746544fc03..0000000000 --- a/apple/TablePro/Views/Structure/StructureEditingSession+Baseline.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// StructureEditingSession+Baseline.swift -// TablePro -// - -import Foundation -import TableProPluginKit - -internal extension StructureEditingSession { - // PostgreSQL leaves `isPrimaryKey` false on every column, so the primary index decides first. - func adoptLoadedBaseline() { - serverSupport = StructureServerSupport.forConnection(connection.id) - let pkFromIndexes = indexes.first(where: { $0.isPrimary })?.columns ?? [] - let pkFromColumns = columns.filter { $0.isPrimaryKey }.map { $0.name } - let primaryKey = pkFromIndexes.isEmpty ? pkFromColumns : pkFromIndexes - - changeManager.loadSchema( - tableName: tableName, - columns: columns, - indexes: indexes, - foreignKeys: foreignKeys, - checkConstraints: checkConstraints, - primaryKey: primaryKey, - tableComment: loadedTableComment, - tableOptions: loadedTableOptions, - autoIncrementConfirmed: autoIncrementRead == .confirmed - ) - } - - // Reads what the Structure tab's first mount reads, so an edit staged without the tab composes - // the statements the tab would. - func loadEditingBaseline(provider: any ScopedMetadataProviding = DatabaseManager.shared) async throws { - let loader = TableStructureLoader(scope: scope, tableName: tableName, provider: provider) - serverSupport = StructureServerSupport.forConnection(connection.id) - columns = try await loader.columns() - indexes = try await loader.indexes() - foreignKeys = try await loader.foreignKeys() - if availableTabs.contains(.checkConstraints) { - checkConstraints = try await loader.checkConstraints() - } - adoptLoadedBaseline() - hasLoaded = true - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift b/apple/TablePro/Views/Structure/StructureEditingSession+ColumnReorder.swift similarity index 93% rename from apple/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift rename to apple/TablePro/Views/Structure/StructureEditingSession+ColumnReorder.swift index 8ae123566d..30c1ff93cc 100644 --- a/apple/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession+ColumnReorder.swift @@ -1,17 +1,16 @@ // -// TableStructureView+ColumnReorder.swift +// StructureEditingSession+ColumnReorder.swift // TablePro // -import Combine import Foundation -import SwiftUI import TableProPluginKit import TableProSQLGrammar -extension TableStructureView { +// Applies now, not staged: a reorder is offered only while nothing else is staged. +internal extension StructureEditingSession { func beginColumnReorder(fromIndex: Int, toIndex: Int) { - let columnsSnapshot = structureChangeManager.workingColumns + let columnsSnapshot = changeManager.workingColumns let clearTarget = coordinator?.selectedColumnLayoutClearTarget() let reorderScope = scope @@ -59,7 +58,7 @@ extension TableStructureView { action: TableRebuildReviewRequest.Action( title: String(localized: "Rebuild Table"), operationDescription: StructureColumnReorderHandler.operationDescription - ) { + ) { [self] in do { try await StructureColumnReorderHandler.execute( prepared, tableName: tableName, databaseType: connection.type, isConfirmationPreCleared: true @@ -88,7 +87,7 @@ extension TableStructureView { _ prepared: StructureColumnReorderHandler.PreparedReorder, clearTarget: ColumnLayoutClearTarget? ) async { - await services.queryHistoryManager.record( + await QueryHistoryManager.shared.record( QueryHistoryRecordRequest( query: prepared.plan.scriptStatements .map { $0.hasSuffix(";") ? $0 : $0 + ";" } @@ -103,7 +102,7 @@ extension TableStructureView { ) ) isReloadingAfterSave = true - await loadColumns() + await fetch(.columns) adoptFetchedBaseline() isReloadingAfterSave = false if let clearTarget { diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift new file mode 100644 index 0000000000..f8b58edfc2 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift @@ -0,0 +1,133 @@ +// +// StructureEditingSession+Commands.swift +// TablePro +// + +import AppKit +import Foundation + +// What the menu bar, the toolbar and the keyboard ask of a structure tab. The coordinator calls these on the +// selected tab's session, so they work whether or not a view is mounted. +internal extension StructureEditingSession { + var hasChanges: Bool { changeManager.hasChanges } + + var pendingChangeCount: Int { changeManager.getChangesArray().count } + + // MARK: - Undo + + var canUndo: Bool { changeManager.canUndo } + var canRedo: Bool { changeManager.canRedo } + var undoMenuItemTitle: String { changeManager.undoMenuItemTitle } + var redoMenuItemTitle: String { changeManager.redoMenuItemTitle } + + func undo() { + guard canUndo else { return } + commitOpenEdits() + changeManager.undo() + editor.refresh() + } + + func redo() { + guard canRedo else { return } + commitOpenEdits() + changeManager.redo() + editor.refresh() + } + + private func commitOpenEdits() { + editor.commitPendingEdits() + } + + // MARK: - Rows + + private var takesRowCommands: Bool { pane == .definition && hasBaseline } + + var canAddRow: Bool { takesRowCommands && editor.canAdd(editor.commandSection) } + var canRemoveRows: Bool { takesRowCommands && editor.canRemoveSelection(in: editor.commandSection) } + var canPasteRows: Bool { takesRowCommands && editor.canPaste } + + func addRow() { + guard canAddRow else { return } + editor.add(editor.commandSection) + } + + func removeSelectedRows() { + guard canRemoveRows else { return } + editor.removeSelection(in: editor.commandSection) + } + + func duplicateSelectedRows() { + guard takesRowCommands else { return } + editor.duplicateSelection() + } + + func copySelectedRows() { + guard pane == .definition else { return } + editor.copySelection() + } + + func pasteRows() { + guard canPasteRows else { return } + editor.paste() + } + + // MARK: - Discard + + func discardChanges() { + editor.clearFieldDraft() + for section in StructureSection.allCases { + editor.adapter(for: section).model.discardEditing() + } + changeManager.discardChanges() + editor.refresh() + if settleOwedRefetch() { + Task { await load() } + } + } + + func discardChangesAfterConfirming() { + guard hasChanges else { return } + let count = pendingChangeCount + Task { + let message = count == 1 + ? String(localized: "The change to this table's structure will be lost.") + : String(format: String(localized: "%d changes to this table's structure will be lost."), count) + guard await confirmDiscard(message: message) else { return } + discardChanges() + } + } + + func confirmDiscard(message: String) async -> Bool { + await AlertHelper.confirmDestructive( + title: String(localized: "Discard Changes?"), + message: message, + confirmButton: String(localized: "Discard Changes"), + cancelButton: String(localized: "Cancel"), + window: coordinator?.contentWindow + ) + } + + // MARK: - Preview and save + + // Preview checks what Save checks, so the statements it shows are the ones Save would run. + func previewChanges() { + editor.commitPendingEdits() + guard hasChanges, let coordinator else { + coordinator?.toolbarState.previewStatements = [] + return + } + guard changeManager.canCommit else { + AlertHelper.showErrorSheet( + title: String(localized: "Some Changes Are Incomplete"), + message: changeManager.validationSummary, + window: coordinator.contentWindow + ) + return + } + Task { await previewStagedChanges(coordinator: coordinator) } + } + + func save() { + Task { _ = await applyStagedChanges(coordinator: coordinator) } + } +} diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift new file mode 100644 index 0000000000..2316b2cffa --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift @@ -0,0 +1,290 @@ +// +// StructureEditingSession+Loading.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +internal extension StructureEditingSession { + var loader: TableStructureLoader { + TableStructureLoader(scope: scope, tableName: tableName) + } + + var offersTableComment: Bool { + DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: connection.id)) + .commentableTypes.contains(objectKind) + } + + // A failed read ends the wait, so its section can show the error and Try Again; edits stay closed until a baseline. + var isLoadingFirstTime: Bool { + !hasBaseline && !editor.sections.contains { loadState.failure(StructureLoadState.Item($0)) != nil } + } + + // Idempotent: a pane re-added after a connection switch restarts this, and only what is missing is read. + func load() async { + settleOwedRefetch() + guard !hasLoaded else { + await reloadConcurrentRefreshAvailability() + await readTableOptionsCatalogIfNeeded() + await loadPaneIfNeeded(pane) + return + } + serverSupport = StructureServerSupport.forConnection(connection.id) + await fetchDefinitionsNeedingFetch() + guard !Task.isCancelled else { return } + await loadTableComment() + guard !Task.isCancelled else { return } + await readTableOptions() + guard !Task.isCancelled else { return } + adoptIfCurrent() + await reloadConcurrentRefreshAvailability() + await readTableOptionsCatalogIfNeeded() + await loadPaneIfNeeded(pane) + } + + // Every offered section read since the last change; a baseline built from less would stage against a guess. + var definitionsAreCurrent: Bool { + editor.sections.allSatisfy { !loadState.needsFetch(StructureLoadState.Item($0)) } + } + + func fetchDefinitionsNeedingFetch() async { + for section in editor.sections where loadState.needsFetch(StructureLoadState.Item(section)) { + guard !Task.isCancelled else { return } + await fetch(StructureLoadState.Item(section)) + } + } + + func adoptIfCurrent() { + guard definitionsAreCurrent else { return } + hasLoaded = true + adoptFetchedBaseline() + } + + func loadPaneIfNeeded(_ pane: StructurePane) async { + switch pane { + case .triggers: + guard loadState.needsFetch(.triggers) else { return } + await fetch(.triggers) + case .ddl: + guard loadState.needsFetch(.ddl) else { return } + await fetch(.ddl) + case .definition, .parts: + return + } + } + + func fetch(_ item: StructureLoadState.Item) async { + do { + switch item { + case .columns: + columns = try await loader.columns() + case .indexes: + indexes = try await loader.indexes() + case .foreignKeys: + foreignKeys = try await loader.foreignKeys() + case .checkConstraints: + checkConstraints = try await loader.checkConstraints() + case .triggers: + triggers = try await loader.triggers() + case .ddl: + let table = tableName + let schema = scope.schema + ddlStatement = try await loader.perform { driver in + try await TableDDLComposer.fetchDDL( + for: table, using: driver, includesDependencies: true, schema: schema + ) + } + } + loadState.markFetched(item) + } catch { + guard let message = StructureFetchFailure(error, taskIsCancelled: Task.isCancelled).message else { return } + Self.logger.error("Failed to load \(String(describing: item), privacy: .public): \(error.publicLogShape, privacy: .public)") + loadState.markFailed(item, message: message) + } + } + + func retry(_ item: StructureLoadState.Item) { + Task { + switch item { + case .columns, .indexes, .foreignKeys, .checkConstraints: + await fetchDefinitionsNeedingFetch() + adoptIfCurrent() + case .triggers, .ddl: + await fetch(item) + } + } + } + + func loadTableComment() async { + guard offersTableComment else { return } + beginTableCommentLoad() + let outcome: MetadataFetchOutcome + do { + outcome = .fetched(try await loader.tableComment()) + } catch { + switch StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) { + case .cancelled: + outcome = .cancelled + case .failed(let message): + Self.logger.error("Failed to load the table comment: \(error.publicLogShape, privacy: .public)") + outcome = .failed(message) + } + } + settleTableComment(outcome) + } + + func readTableOptionsCatalogIfNeeded() async { + guard needsTableOptionsCatalog else { return } + await readTableOptionsCatalog() + } + + // MARK: - Baseline + + // PostgreSQL leaves `isPrimaryKey` false on every column, so the primary index decides first. + func adoptLoadedBaseline() { + let pkFromIndexes = indexes.first(where: { $0.isPrimary })?.columns ?? [] + let pkFromColumns = columns.filter { $0.isPrimaryKey }.map { $0.name } + let primaryKey = pkFromIndexes.isEmpty ? pkFromColumns : pkFromIndexes + + changeManager.loadSchema( + tableName: tableName, + columns: columns, + indexes: indexes, + foreignKeys: foreignKeys, + checkConstraints: checkConstraints, + primaryKey: primaryKey, + tableComment: loadedTableComment, + tableOptions: loadedTableOptions, + autoIncrementConfirmed: autoIncrementRead == .confirmed + ) + hasBaseline = true + editor.isAwaitingBaseline = false + editor.refresh() + } + + func adoptFetchedBaseline() { + guard !changeManager.hasChanges else { + markStructureStale() + return + } + adoptLoadedBaseline() + } + + func rebaselineUnlessEdited() { + guard !isReloadingAfterSave, hasLoaded else { return } + adoptFetchedBaseline() + } + + // Reads what the Structure tab's first mount reads, so an edit staged without the tab composes the statements + // the tab would. + func loadEditingBaseline(provider: any ScopedMetadataProviding = DatabaseManager.shared) async throws { + let loader = TableStructureLoader(scope: scope, tableName: tableName, provider: provider) + serverSupport = StructureServerSupport.forConnection(connection.id) + columns = try await loader.columns() + indexes = try await loader.indexes() + foreignKeys = try await loader.foreignKeys() + if editor.offers(.checkConstraints) { + checkConstraints = try await loader.checkConstraints() + } + adoptLoadedBaseline() + hasLoaded = true + } + + // MARK: - Reload + + func reloadDefinitions() async { + let includesForeignKeys = editor.offers(.foreignKeys) + let offersTableOptions = offersTableOptionsTab + do { + let reloaded = try await loader.coreTabs( + includingForeignKeys: includesForeignKeys, + includingTableComment: offersTableComment, + includingTableOptions: offersTableOptions + ) + if let comment = reloaded.tableComment { + settleTableComment(comment) + } + // A save never adopts the counter it wrote: the server raises a low value, so this read is what shows. + if let options = reloaded.tableOptions { + settleTableOptions(options) + } + columns = reloaded.columns + indexes = reloaded.indexes + loadState.markFetched(.columns) + loadState.markFetched(.indexes) + if includesForeignKeys { + foreignKeys = reloaded.foreignKeys + loadState.markFetched(.foreignKeys) + } + } catch { + if offersTableOptions { + settleTableOptions(.cancelled) + } + if let message = StructureFetchFailure(error, taskIsCancelled: Task.isCancelled).message { + Self.logger.error("Failed to reload structure: \(error.publicLogShape, privacy: .public)") + loadState.markFailed(.columns, message: message) + } + } + if editor.offers(.checkConstraints) { + await fetch(.checkConstraints) + } + await reloadConcurrentRefreshAvailability() + } + + // A failed read keeps the save's tables closed to edits until Try Again reads them. + func refreshAfterApply() async { + isReloadingAfterSave = true + await reloadDefinitions() + isReloadingAfterSave = false + adoptIfCurrent() + await loadPaneIfNeeded(pane) + } + + // Refresh reads everything again; staged edits are discarded first, after asking. + func refresh() { + guard !isReloadingAfterSave else { return } + let justSaved = lastAppliedAt.map { Date().timeIntervalSince($0) < 2.0 } ?? false + guard changeManager.hasChanges, !justSaved else { + Task { await reloadEverything() } + return + } + Task { + guard await confirmDiscard( + message: String(localized: "Refreshing reads the table structure again and discards the changes you have not saved.") + ) else { return } + discardChanges() + await reloadEverything() + } + } + + private func reloadEverything() async { + markEveryTabStale() + editor.referenceMenus.invalidateTableLists() + partsReloadToken &+= 1 + await reloadDefinitions() + adoptIfCurrent() + await loadPaneIfNeeded(pane) + } +} + +internal enum StructureFetchFailure: Equatable { + case cancelled + case failed(String) + + internal init(_ error: any Error, taskIsCancelled: Bool) { + if error is CancellationError || taskIsCancelled { + self = .cancelled + } else { + self = .failed(error.localizedDescription) + } + } + + internal var message: String? { + switch self { + case .cancelled: nil + case .failed(let message): message + } + } +} diff --git a/apple/TablePro/Views/Structure/StructureEditingSession.swift b/apple/TablePro/Views/Structure/StructureEditingSession.swift index 5ff284e764..5e4cd04c59 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession.swift @@ -8,6 +8,8 @@ import Foundation import os import TableProPluginKit +// One table tab's structure: what was read, what is staged, and the commands that act on it. The coordinator +// holds it by tab id, so staged edits, the close prompt and Save work with no view mounted. @MainActor internal final class StructureEditingSession: ObservableObject { static let logger = Logger(subsystem: "com.TablePro", category: "StructureEditingSession") @@ -23,12 +25,7 @@ internal final class StructureEditingSession: ObservableObject { internal let objectKind: TableInfo.TableType internal let changeManager = StructureChangeManager() - - // Owned here, not by the view, so the grid the user types into and the edits the close prompt - // reports are one object, and its model outlives every remount. - internal let gridAdapter: StructureGridAdapter - - internal var gridModel: DataGridModel { gridAdapter.model } + internal let editor: StructureDefinitionEditor internal var scope: DatabaseScope { DatabaseScope( @@ -42,7 +39,7 @@ internal final class StructureEditingSession: ObservableObject { @Published internal var checkConstraints: [CheckConstraintInfo] = [] @Published internal var triggers: [TriggerInfo] = [] @Published internal var ddlStatement: String = "" - @Published internal var tabData = StructureTabDataState() + @Published internal var loadState = StructureLoadState() @Published internal var concurrentRefresh: MetadataLoadState = .idle private var concurrentRefreshRequest = 0 @@ -55,56 +52,51 @@ internal final class StructureEditingSession: ObservableObject { internal var loadedTableOptions: TableOptions? { tableOptions.value ?? nil } - /// Leaves `.confirmed` when a save writes the counter and returns only on a read that lands, since the server may raise it. + // Leaves `.confirmed` when a save writes the counter and returns only on a read that lands, since the server may raise it. @Published internal private(set) var autoIncrementRead: AutoIncrementReadState = .confirmed @Published internal private(set) var tableOptionsCatalog: MetadataLoadState = .idle internal var tableOptionsCatalogRead: Task? - @Published internal var selectedTab: StructureTab = .columns - @Published internal var searchText = "" - @Published internal var sortState = SortState() - @Published internal var sortDescriptor: StructureSortDescriptor? - @Published internal var columnLayouts: [StructureTab: ColumnLayoutState] = [:] + @Published internal var pane: StructurePane = .definition + @Published internal var partsReloadToken = 0 @Published internal var serverSupport = StructureServerSupport.unrestricted { didSet { - guard !availableTabs.contains(selectedTab) else { return } - selectedTab = .columns + editor.serverSupport = serverSupport + if !availablePanes.contains(pane) { + pane = .definition + } } } - internal var availableTabs: [StructureTab] { - StructureTabAvailability.tabs( - for: connection.type, - serverSupport: serverSupport, - offersTableOptions: offersTableOptionsTab - ) - } - - internal var tabsFetchedOnMount: [StructureTab] { - var tabs: [StructureTab] = [.columns, .indexes, .foreignKeys] - if availableTabs.contains(.checkConstraints) { - tabs.append(.checkConstraints) - } - if !tabs.contains(selectedTab) { - tabs.append(selectedTab) + internal var availablePanes: [StructurePane] { + StructurePane.allCases.filter { pane in + switch pane { + case .definition, .ddl: true + case .triggers: connection.type.supportsTriggers + case .parts: connection.type == .clickhouse + } } - return tabs } - @Published internal var footer = StructureFooterCapability() - @Published internal var hasLoaded = false - + @Published internal var hasBaseline = false + // The ER diagram's sessions watch this to learn a save landed. @Published internal private(set) var appliedVersion = 0 - @Published internal var isApplying = false - @Published internal var lastAppliedAt: Date? + internal var isReloadingAfterSave = false + + weak var coordinator: MainContentCoordinator? { + didSet { editor.coordinator = coordinator } + } + internal var executionGate: any ExecutionGate = ExecutionGateProvider.shared + private var cancellables: Set = [] + internal init( identity: String, connection: DatabaseConnection, @@ -122,24 +114,59 @@ internal final class StructureEditingSession: ObservableObject { self.isSessionBound = isSessionBound self.tableName = tableName self.objectKind = objectKind - self.serverSupport = serverSupport - gridAdapter = StructureGridAdapter( - structureChangeManager: changeManager, - connection: connection, - tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - objectKind: objectKind, - isSessionBound: isSessionBound + editor = StructureDefinitionEditor( + manager: changeManager, + context: .existingTable( + connection: connection, + databaseName: databaseName, + schemaName: schemaName, + tableName: tableName, + objectKind: objectKind, + isSessionBound: isSessionBound + ) ) - gridAdapter.session = self - gridAdapter.store.session = self + self.serverSupport = serverSupport + editor.serverSupport = serverSupport + // Nothing is editable before the table's definitions are read. + editor.isAwaitingBaseline = true + editor.characterSetContextProvider = { [weak self] in + ColumnCharacterSetMenus.Context( + catalog: self?.tableOptionsCatalog ?? .idle, + tableCharacterSet: self?.changeManager.tableOptions.current?.characterSet + ) + } + editor.characterSetCatalogReader = { [weak self] in + guard let self else { return } + switch tableOptionsCatalog { + case .idle, .failed: + Task { await self.readTableOptionsCatalog() } + case .loading, .loaded: + break + } + } + editor.moveColumnHandler = { [weak self] from, to in + self?.beginColumnReorder(fromIndex: from, toIndex: to) + } + changeManager.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + $tableOptionsCatalog + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.editor.refresh() } + .store(in: &cancellables) } + // A tab's session reads the table again at once; a diagram's has no view to show it and is dropped instead. internal func markApplied() { appliedVersion += 1 + guard coordinator != nil else { return } + editor.isAwaitingBaseline = true + Task { await refreshAfterApply() } } + // MARK: - Staleness + internal private(set) var owesRefetch = false internal func markStructureStale() { @@ -159,10 +186,12 @@ internal final class StructureEditingSession: ObservableObject { } internal func markEveryTabStale() { - tabData.markAllStale() + loadState.markAllStale() owesRefetch = false } + // MARK: - Table comment and options + internal func beginTableCommentLoad() { tableComment = tableComment.enteringLoad } @@ -238,41 +267,23 @@ internal final class StructureEditingSession: ObservableObject { concurrentRefresh = concurrentRefresh.settled(by: outcome, discardingValue: true) } - // The mounted view installs closures that capture it, and it holds this session; called wherever a - // session is dropped, never from `onDisappear`. + // Called wherever a session is dropped, never from `onDisappear`. internal func releaseViewWiring() { - gridAdapter.releaseViewWiring() + coordinator = nil } } internal enum AutoIncrementReadState: Equatable { case confirmed case rereading - /// The re-read failed or was cancelled, so the loaded counter may be one the server has moved past. + // The re-read failed or was cancelled, so the loaded counter may be one the server has moved past. case unconfirmed } extension StructureEditingSession: ActiveGridModelProviding { - // The schema grid while a sub-tab shows one; Parts, Triggers and Options draw views of their own. + // The section that holds the selection while the Definition page is on screen; the other panes draw no grid. var activeGridModel: DataGridModel? { - switch selectedTab { - case .columns, .indexes, .foreignKeys, .checkConstraints: - return gridModel - case .ddl, .parts, .triggers, .options: - return nil - } - } -} - -internal struct StructureFooterCapability: Equatable { - internal var canAdd = false - internal var canRemove = false - internal var addLabel = "" - internal var removeLabel = "" - - internal var unavailableReason: String? - - internal var isActive: Bool { - !addLabel.isEmpty + guard pane == .definition else { return nil } + return editor.focusedModel ?? editor.adapter(for: .columns).model } } diff --git a/apple/TablePro/Views/Structure/StructureFooterPolicy.swift b/apple/TablePro/Views/Structure/StructureFooterPolicy.swift index 65d8be1e2f..b42c3ffd7e 100644 --- a/apple/TablePro/Views/Structure/StructureFooterPolicy.swift +++ b/apple/TablePro/Views/Structure/StructureFooterPolicy.swift @@ -2,104 +2,45 @@ // StructureFooterPolicy.swift // TablePro // -// What the add/remove pair under a structure list offers, and why it is dimmed when it is. -// import Foundation import TableProPluginKit -/// The single decision behind the "+" and "-" under the Structure tab's list. -/// -/// Pure and SwiftUI-free so the rule is testable, and one function so the label, the enabled state -/// and the tooltip can never disagree. They did: the labels came from one switch, the enabled state -/// from a second that read engine capability flags alone, and the tooltip from a third that returned -/// nil for every tab but Foreign Keys. So a view offered an enabled "Add Column" over an -/// `ALTER TABLE … ADD COLUMN` PostgreSQL always refuses, with nothing to explain it. (#2726) +// The add and remove pair beneath each section's table: what it is called and which edit it stages. enum StructureFooterPolicy { - /// Nil for a tab with nothing to add: DDL is text, Parts is read-only, and a trigger is created - /// through its own editor rather than by typing a row. - static func operation(forAdding tab: StructureTab) -> StructureEditOperation? { - switch tab { - case .columns: return .addColumn - case .indexes: return .addIndex - case .foreignKeys: return .addForeignKey - case .checkConstraints: return .addCheckConstraint - case .options, .ddl, .parts, .triggers: return nil + static func operation(forAdding section: StructureSection) -> StructureEditOperation { + switch section { + case .columns: .addColumn + case .indexes: .addIndex + case .foreignKeys: .addForeignKey + case .checkConstraints: .addCheckConstraint } } - static func operation(forRemoving tab: StructureTab) -> StructureEditOperation? { - switch tab { - case .columns: return .dropColumn - case .indexes: return .dropIndex - case .foreignKeys: return .dropForeignKey - case .checkConstraints: return .dropCheckConstraint - case .options, .ddl, .parts, .triggers: return nil + static func operation(forRemoving section: StructureSection) -> StructureEditOperation { + switch section { + case .columns: .dropColumn + case .indexes: .dropIndex + case .foreignKeys: .dropForeignKey + case .checkConstraints: .dropCheckConstraint } } - /// Why the grid and the pair under it take no edits while a save runs. + // Why the tables and the pairs under them take no edits while a save runs. static var savingReason: String { String(localized: "The staged changes are being saved.") } - static func labels(for tab: StructureTab) -> (add: String, remove: String)? { - switch tab { + static func labels(for section: StructureSection) -> (add: String, remove: String) { + switch section { case .columns: - return (String(localized: "Add Column"), String(localized: "Remove Column")) + (String(localized: "Add Column"), String(localized: "Remove Column")) case .indexes: - return (String(localized: "Add Index"), String(localized: "Remove Index")) + (String(localized: "Add Index"), String(localized: "Remove Index")) case .foreignKeys: - return (String(localized: "Add Foreign Key"), String(localized: "Remove Foreign Key")) + (String(localized: "Add Foreign Key"), String(localized: "Remove Foreign Key")) case .checkConstraints: - return (String(localized: "Add Check Constraint"), String(localized: "Remove Check Constraint")) - case .options, .ddl, .parts, .triggers: - return nil - } - } - - /// - Parameter resolve: The full availability of one operation, which the caller supplies because - /// it owns the connection the engine half of the answer comes from. - /// - /// A refusing object kind keeps the pair on screen and dims it with the reason, the way the - /// Foreign Keys tab already did. An engine that cannot edit structure at all hides it instead: - /// there is nothing to explain per object when the whole tab is read-only. - static func resolve( - tab: StructureTab, - canEditSchema: Bool, - hasSelection: Bool, - isSaving: Bool, - resolve: (StructureEditOperation) -> StructureEditAvailability - ) -> StructureFooterCapability { - guard canEditSchema, - let labels = labels(for: tab), - let adding = operation(forAdding: tab), - let removing = operation(forRemoving: tab) - else { - return StructureFooterCapability() + (String(localized: "Add Check Constraint"), String(localized: "Remove Check Constraint")) } - - /// A save holds what is staged until it ends, so nothing can be added or removed meanwhile. - guard !isSaving else { - return StructureFooterCapability( - addLabel: labels.add, - removeLabel: labels.remove, - unavailableReason: savingReason - ) - } - - let addAvailability = resolve(adding) - let removeAvailability = resolve(removing) - - return StructureFooterCapability( - canAdd: addAvailability.isAvailable, - canRemove: hasSelection && removeAvailability.isAvailable, - addLabel: labels.add, - removeLabel: labels.remove, - /// Never the absence of a selection. The pair dims while nothing is selected on every - /// tab and on every engine, which needs no sentence; only a refusal the user could not - /// have predicted does. - unavailableReason: addAvailability.unavailableReason ?? removeAvailability.unavailableReason - ) } } diff --git a/apple/TablePro/Views/Structure/StructureGridAdapter+Inspector.swift b/apple/TablePro/Views/Structure/StructureGridAdapter+Inspector.swift deleted file mode 100644 index b988b70087..0000000000 --- a/apple/TablePro/Views/Structure/StructureGridAdapter+Inspector.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// StructureGridAdapter+Inspector.swift -// TablePro -// - -import Foundation - -// The inspector names a row by its position in the grid, the same position the grid's selection uses. -extension StructureGridAdapter: InspectorRowSource { - func inspectorRow(atDisplayRow displayRow: Int) -> InspectorRow? { - guard let rowID = model.rowID(at: displayRow), - let sourceIndex = StructureRowProvider.sourceIndex(of: rowID), - let providerRow = provider.displayRow(ofSourceIndex: sourceIndex) else { return nil } - return StructureInspectorRowBuilder.row( - atDisplayRow: providerRow, - tab: selectedTab, - provider: provider, - canEditSchema: canEdit, - lockedFieldIndices: lockedFieldIndices.union(characterSetLockedFields(sourceIndex: sourceIndex)), - rowOptions: { menuOptions(sourceIndex: sourceIndex, field: $0) } - ) - } - - func commitInspectorField(displayRow: Int, fieldIndex: Int, value: String?) { - guard let rowID = model.rowID(at: displayRow) else { return } - applyEdit(rowID: rowID, field: fieldIndex, text: value ?? "") - } -} diff --git a/apple/TablePro/Views/Structure/StructureGridAdapter.swift b/apple/TablePro/Views/Structure/StructureGridAdapter.swift deleted file mode 100644 index cd22297687..0000000000 --- a/apple/TablePro/Views/Structure/StructureGridAdapter.swift +++ /dev/null @@ -1,546 +0,0 @@ -// -// StructureGridAdapter.swift -// TablePro -// - -import AppKit -import TableProPluginKit - -// Feeds the Structure tab's grid from the session's working definitions and writes its edits back, -// always by the definition a row names, never by where the row sits on screen. -@MainActor -final class StructureGridAdapter { - let model: DataGridModel - let store: StructureGridStateStore - let structureChangeManager: StructureChangeManager - let connection: DatabaseConnection - let tableName: String - let databaseName: String - let schemaName: String? - let objectKind: TableInfo.TableType - let isSessionBound: Bool - let referenceMenus: ForeignKeyReferenceMenus - - weak var session: StructureEditingSession? - weak var coordinator: MainContentCoordinator? - // Installed by the mounted view while a column may be moved; it owns the reorder flow and its sheets. - var moveRowHandler: ((Int, Int) -> Void)? - - private(set) var provider: StructureRowProvider - private var snapshot = StructureGridSnapshot() - private let changeTracking: StructureGridChangeState - private let changeState: AnyChangeManager - - init( - structureChangeManager: StructureChangeManager, - connection: DatabaseConnection, - tableName: String, - databaseName: String, - schemaName: String?, - objectKind: TableInfo.TableType, - isSessionBound: Bool - ) { - self.structureChangeManager = structureChangeManager - self.connection = connection - self.tableName = tableName - self.databaseName = databaseName - self.schemaName = schemaName - self.objectKind = objectKind - self.isSessionBound = isSessionBound - let store = StructureGridStateStore() - self.store = store - self.model = DataGridModel(store: store) - self.referenceMenus = ForeignKeyReferenceMenus(connectionId: connection.id, databaseType: connection.type) - self.changeTracking = StructureGridChangeState(manager: structureChangeManager) - self.changeState = AnyChangeManager(changeTracking) - self.provider = StructureRowProvider( - changeManager: structureChangeManager, - tab: .columns, - databaseType: connection.type, - additionalFields: [.primaryKey], - serverSupport: .unrestricted - ) - referenceMenus.schemaName = schemaName - // Read when a list lands, not captured: the view hands the coordinator over on every appearance. - referenceMenus.onListsChanged = { [weak self] in - self?.coordinator?.inspectorRowSourceRevision += 1 - } - model.dataSource = self - model.actions = self - } - - // MARK: - State - - // The sub-tab whose rows the grid shows. The session's selection moves first and refresh() follows, so every - // write, menu and command reads this, never the incoming sub-tab (an open edit commits into the rows it was on). - var selectedTab: StructureTab { store.subTab } - private var serverSupport: StructureServerSupport { session?.serverSupport ?? .unrestricted } - private var searchText: String { session?.searchText ?? "" } - private var sortDescriptor: StructureSortDescriptor? { session?.sortDescriptor } - - var characterSetContext: ColumnCharacterSetMenus.Context { - ColumnCharacterSetMenus.Context( - catalog: session?.tableOptionsCatalog ?? .idle, - tableCharacterSet: structureChangeManager.tableOptions.current?.characterSet - ) - } - - var editGate: StructureEditGate { - StructureEditGate( - databaseType: connection.type, objectKind: objectKind, schema: schemaName, isSessionBound: isSessionBound - ) - } - - var canEditForeignKeys: Bool { - editGate.allows(.addForeignKey) - } - - var canStageAddForSelectedTab: Bool { - guard let adding = StructureFooterPolicy.operation(forAdding: selectedTab) else { return false } - return editGate.allows(adding) - } - - var canStageRemoveForSelectedTab: Bool { - guard let removing = StructureFooterPolicy.operation(forRemoving: selectedTab) else { return false } - return editGate.allows(removing) - } - - var canEdit: Bool { - editGate.allowsAnyEdit && !structureChangeManager.isHeldForSave - } - - var lockedFieldIndices: Set { - editGate.lockedFieldIndices( - on: selectedTab, - orderedFields: provider.orderedColumnFields, - fieldCount: provider.columns.count - ) - } - - var editRefusal: String? { - if structureChangeManager.isHeldForSave { return StructureFooterPolicy.savingReason } - guard !editGate.allowsAnyEdit else { return nil } - return editGate.resolve(.renameColumn).unavailableReason - } - - var columnReorderAvailability: ColumnReorderAvailability { - ColumnReorderPolicy.resolve( - support: PluginManager.shared.columnReorderSupport(for: connection.type), - engineName: connection.type.displayName, - isColumnsTab: selectedTab == .columns, - kindRefusal: editGate.kindRefusal(.reorderColumns), - canEditSchema: editGate.canEditSchema, - hasStagedChanges: structureChangeManager.hasChanges, - isRearranged: !searchText.isEmpty || sortDescriptor != nil || store.valueFilter.isActive - ) - } - - var showsGrid: Bool { - switch selectedTab { - case .columns, .indexes, .foreignKeys, .checkConstraints: true - case .triggers, .options, .ddl, .parts: false - } - } - - // MARK: - Refresh - - // Called whenever the working definitions, the sub-tab, the search, the sort or the edit gate moved. - func refresh() { - let tab = session?.selectedTab ?? store.subTab - if store.subTab != tab { - model.commitEditing() - store.switchSubTab(to: tab) - } - changeTracking.tab = tab - changeTracking.rowCount = StructureGridEdits.rowCount(on: tab, manager: structureChangeManager) - - let provider = StructureRowProvider( - changeManager: structureChangeManager, - tab: tab, - databaseType: connection.type, - additionalFields: [.primaryKey], - serverSupport: serverSupport, - filterText: searchText.isEmpty ? nil : searchText, - sortDescriptor: sortDescriptor, - picksCharacterSets: ColumnCharacterSetMenus.offersMenus(characterSetContext.catalog) - ) - self.provider = provider - let rows = provider.asTableRows() - let states = rows.rows.map { row in - StructureGridEdits.visualState( - sourceIndex: StructureRowProvider.sourceIndex(of: row.id) ?? -1, - tab: tab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - tintsModifiedFields: true - ) - } - let wasEditable = model.configuration.isEditable - model.configuration = makeConfiguration(provider: provider) - let previous = snapshot - snapshot = StructureGridSnapshot(rows: rows, states: states) - model.applyHeldSnapshot(from: previous, to: snapshot) - // A save hold changes only the editability, and the inspector's fields have to follow it. - if previous.rows.rows != rows.rows || previous.states != states || previous.rows.columns != rows.columns - || wasEditable != model.configuration.isEditable { - coordinator?.inspectorRowSourceRevision += 1 - } - } - - private func makeConfiguration(provider: StructureRowProvider) -> DataGridConfiguration { - var configuration = DataGridConfiguration() - let options = provider.customDropdownOptions - configuration.dropdownColumns = provider.dropdownColumns - configuration.typePickerColumns = provider.typePickerColumns - configuration.customDropdownOptions = options.isEmpty ? nil : options - configuration.lockedColumns = lockedColumns(provider: provider) - configuration.editRefusalMessage = editRefusal - configuration.isEditable = canEdit - configuration.traits = DataGridEngineTraits( - databaseType: connection.type, - connectionScope: DatabaseScope(connectionId: connection.id, database: databaseName, schema: schemaName), - looksUpUserDefinedTypes: true - ) - let reorder = columnReorderAvailability - configuration.rowReorder = DataGridRowReorder( - isEnabled: reorder.isAvailable, - unavailableReason: reorder.unavailableReason - ) - configuration.commands = commands(reorderAvailable: reorder.isAvailable) - return configuration - } - - private func commands(reorderAvailable: Bool) -> Set { - var commands: Set = [.valueFilter, .freezeColumns] - guard showsGrid, !structureChangeManager.isHeldForSave else { return commands } - if canStageAddForSelectedTab { - commands.formUnion([.addRow, .duplicateRows, .pasteRows]) - } - if canStageRemoveForSelectedTab { - commands.formUnion([.deleteRows, .undoDelete]) - } - if reorderAvailable { - commands.insert(.moveRows) - } - return commands - } - - private func lockedColumns(provider: StructureRowProvider) -> Set { - let headings = provider.columns - let locked = editGate.lockedFieldIndices( - on: selectedTab, - orderedFields: provider.orderedColumnFields, - fieldCount: headings.count - ) - return Set(locked.compactMap { headings.indices.contains($0) ? headings[$0] : nil }) - } - - // MARK: - Rows - - func sourceIndices(of rowIDs: [RowID]) -> [Int] { - rowIDs.compactMap(StructureRowProvider.sourceIndex(of:)) - } - - func menuOptions(sourceIndex: Int, field: Int) -> [GridMenuOption]? { - switch selectedTab { - case .indexes: - guard structureChangeManager.workingIndexes.indices.contains(sourceIndex) else { return nil } - return StructureRowProvider.indexMenuOptions( - columnIndex: field, - index: structureChangeManager.workingIndexes[sourceIndex], - serverSupport: serverSupport - ) - case .foreignKeys: - guard canEditForeignKeys, - structureChangeManager.workingForeignKeys.indices.contains(sourceIndex) else { return nil } - referenceMenus.origin = coordinator?.selectedTabScope - return referenceMenus.options( - columnIndex: field, - foreignKey: structureChangeManager.workingForeignKeys[sourceIndex], - tableColumns: structureChangeManager.workingColumns.map(\.name) - ) - case .columns: - guard structureChangeManager.workingColumns.indices.contains(sourceIndex), - provider.orderedColumnFields.indices.contains(field) else { return nil } - return ColumnCharacterSetMenus.options( - for: provider.orderedColumnFields[field], - column: structureChangeManager.workingColumns[sourceIndex], - context: characterSetContext - ) - case .checkConstraints, .options, .ddl, .parts, .triggers: - return nil - } - } - - // Read-only per row, which the column-wide lock cannot say: a type with no character set, or a catalog not read. - func characterSetLockedFields(sourceIndex: Int) -> Set { - guard selectedTab == .columns, structureChangeManager.workingColumns.indices.contains(sourceIndex) else { return [] } - return ColumnCharacterSetMenus.lockedFieldIndices( - orderedFields: provider.orderedColumnFields, - column: structureChangeManager.workingColumns[sourceIndex], - catalog: characterSetContext.catalog - ) - } - - // Opening the menu is the retry, as it is for the reference menus. The inspector reads the options on every - // rebuild, so it never starts a read. - private func readCharacterSetCatalogIfNeeded(field: Int) { - guard selectedTab == .columns, provider.orderedColumnFields.indices.contains(field), - ColumnCharacterSetMenus.isCharacterSetField(provider.orderedColumnFields[field]) else { return } - switch characterSetContext.catalog { - case .idle, .failed: - Task { await session?.readTableOptionsCatalog() } - case .loading, .loaded: - break - } - } - - func characterSetCatalogDidChange() { - refresh() - coordinator?.inspectorRowSourceRevision += 1 - } - - // MARK: - Commands - - @discardableResult - func applyEdit(rowID: RowID, field: Int, text: String) -> Bool { - guard canEdit, let sourceIndex = StructureRowProvider.sourceIndex(of: rowID) else { return false } - let applied = write(text, field: field, sourceIndex: sourceIndex) - refresh() - return applied - } - - private func write(_ text: String, field: Int, sourceIndex: Int) -> Bool { - let tab = selectedTab - let characterSets = characterSetContext - guard field >= 0, - !editGate.locksField(at: field, on: tab, orderedFields: provider.orderedColumnFields), - !structureChangeManager.deleteInsertState(for: sourceIndex, tab: tab).isDeleted, - !StructureGridEdits.refuses( - text, - field: field, - sourceIndex: sourceIndex, - tab: tab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - characterSets: characterSets - ) else { return false } - let changedReference = StructureGridEdits.apply( - text, - field: field, - sourceIndex: sourceIndex, - tab: tab, - manager: structureChangeManager, - orderedFields: provider.orderedColumnFields, - connection: connection, - characterSets: characterSets - ) - if let changedReference { - referenceMenus.origin = coordinator?.selectedTabScope - referenceMenus.prefetchReferencedColumns( - of: changedReference.referencedTable, schema: changedReference.referencedSchema - ) - } - return true - } - - func deleteRows(_ rowIDs: [RowID]) { - guard !rowIDs.isEmpty, showsGrid else { - model.clearSelection() - return - } - let allowed: Bool - switch selectedTab { - case .columns: allowed = editGate.allows(.dropColumn) - case .indexes: allowed = editGate.allows(.dropIndex) - case .foreignKeys: allowed = canEditForeignKeys - case .checkConstraints: allowed = editGate.allows(.dropCheckConstraint) - case .options, .ddl, .parts, .triggers: allowed = false - } - guard allowed else { return } - let displayRows = IndexSet(rowIDs.compactMap { model.displayIndex(of: $0) }) - let countBefore = model.rowCount - StructureGridEdits.delete( - sourceIndices: sourceIndices(of: rowIDs), tab: selectedTab, manager: structureChangeManager - ) - refresh() - selectAfterDeleting(displayRows, countBefore: countBefore) - } - - private func selectAfterDeleting(_ displayRows: IndexSet, countBefore: Int) { - let remaining = countBefore - displayRows.count - guard remaining > 0, let minRow = displayRows.first, let maxRow = displayRows.last else { - model.clearSelection() - return - } - let target = maxRow < remaining ? maxRow : (minRow > 0 ? minRow - 1 : 0) - guard let rowID = model.rowID(at: target) else { - model.clearSelection() - return - } - model.selectRows([rowID]) - } - - func addRow() { - guard canStageAddForSelectedTab else { return } - StructureGridEdits.addRow(on: selectedTab, manager: structureChangeManager) - refresh() - } - - func duplicateRows(_ rowIDs: [RowID]) { - guard canStageAddForSelectedTab else { return } - StructureGridEdits.duplicate( - sourceIndices: sourceIndices(of: rowIDs), tab: selectedTab, manager: structureChangeManager - ) - refresh() - } - - func undoDelete(_ rowIDs: [RowID]) { - for index in sourceIndices(of: rowIDs) { - structureChangeManager.undoDelete(for: selectedTab, at: index) - } - refresh() - } - - // Options stages its edits through the same manager, so its undo is the structure's. - private var undoesStructureEdits: Bool { - switch selectedTab { - case .columns, .indexes, .foreignKeys, .checkConstraints, .options, .parts: true - case .ddl, .triggers: false - } - } - - // The comment bar shows on every sub-tab, so its step undoes even where no grid is on screen. - func undo() { - guard undoesStructureEdits || structureChangeManager.undoesTableCommentNext else { return } - model.commitEditing() - structureChangeManager.undo() - refresh() - } - - func redo() { - guard undoesStructureEdits || structureChangeManager.redoesTableCommentNext else { return } - model.commitEditing() - structureChangeManager.redo() - refresh() - } - - func navigateToForeignKey(sourceIndex: Int) { - guard structureChangeManager.workingForeignKeys.indices.contains(sourceIndex), let coordinator else { return } - let foreignKey = structureChangeManager.workingForeignKeys[sourceIndex] - guard let origin = coordinator.selectedTabScope else { - coordinator.openTableTab(foreignKey.referencedTable, schema: foreignKey.referencedSchema) - return - } - let target = ForeignKeyTargetScope.resolve( - origin: origin, referencedSchema: foreignKey.referencedSchema, databaseType: connection.type - ) - coordinator.openTableTab(foreignKey.referencedTable, schema: target.schema, database: target.database) - } - - func releaseViewWiring() { - moveRowHandler = nil - coordinator = nil - } -} - -// MARK: - DataGridDataSource - -extension StructureGridAdapter: DataGridDataSource { - var gridRows: TableRows { snapshot.rows } - var gridChangeState: AnyChangeManager { changeState } - - func gridVisualState(for rowID: RowID) -> RowVisualState? { - snapshot.state(for: rowID) - } - - func gridMenuOptions(for rowID: RowID, column: DataGridColumn) -> [GridMenuOption]? { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: rowID) else { return nil } - let options = menuOptions(sourceIndex: sourceIndex, field: column.dataIndex) - readCharacterSetCatalogIfNeeded(field: column.dataIndex) - return options - } -} - -// MARK: - DataGridActions - -extension StructureGridAdapter: DataGridActions { - func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool { - guard canEdit, !edits.isEmpty else { return false } - var applied = false - let writeAll = { - for edit in edits { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: edit.rowID) else { continue } - if self.write(edit.newValue.asText ?? "", field: edit.dataIndex, sourceIndex: sourceIndex) { - applied = true - } - } - } - if edits.count > 1 { - structureChangeManager.performAsOneUndoStep(writeAll) - } else { - writeAll() - } - refresh() - return applied - } - - func gridDeleteRows(_ rowIDs: [RowID]) { - deleteRows(rowIDs) - } - - func gridUndoDelete(_ rowIDs: [RowID]) { - undoDelete(rowIDs) - } - - func gridAddRow() { - addRow() - } - - func gridDuplicateRows(_ rowIDs: [RowID]) { - duplicateRows(rowIDs) - } - - func gridCanPasteRows() -> Bool { - canStageAddForSelectedTab && TableStructureView.canPasteStructureRows - } - - func gridPasteRows() { - pasteRows() - } - - // A move is offered only while nothing filters or sorts the list, so the drop index is a working index. - func gridMoveRow(from source: RowID, toDisplayIndex destination: Int) { - guard let sourceIndex = StructureRowProvider.sourceIndex(of: source) else { return } - moveRowHandler?(sourceIndex, destination) - } - - // The provider sorts by one column, so a secondary key the header appended is dropped. - func gridSortDidChange(_ sort: SortState) { - guard let session else { return } - if let primary = sort.columns.first { - session.sortDescriptor = StructureSortDescriptor( - column: primary.columnIndex, ascending: primary.direction == .ascending - ) - store.sortState = SortState(columns: [primary], source: .user) - } else { - session.sortDescriptor = nil - store.sortState = SortState(columns: [], source: .user) - } - refresh() - } - - // A value filter rearranges the list, which withdraws the column move. - func gridConfirmDisplayOrderChange(then apply: @escaping @MainActor () -> Void) { - apply() - model.configuration = makeConfiguration(provider: provider) - } - - func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] { - structureRowMenuItems(for: rowIDs, clickedRow: clickedRow) - } - - func gridEmptySpaceMenu() -> NSMenu? { - makeEmptySpaceMenu() - } -} diff --git a/apple/TablePro/Views/Structure/StructureGridStateStore.swift b/apple/TablePro/Views/Structure/StructureGridStateStore.swift index 26cc6933b1..2a07bf9734 100644 --- a/apple/TablePro/Views/Structure/StructureGridStateStore.swift +++ b/apple/TablePro/Views/Structure/StructureGridStateStore.swift @@ -4,54 +4,55 @@ // import Foundation +import TableProPluginKit -// The Structure grid's view state lives on the tab's session, one column layout per sub-tab, and never -// reaches a table's saved layout. +// One section's grid state. It never reaches a table's saved layout; only the columns a person hides are kept, +// per section, for every table. @MainActor final class StructureGridStateStore: DataGridStateStore { - weak var session: StructureEditingSession? - private(set) var subTab: StructureTab = .columns + let section: StructureSection + private let defaults: UserDefaults + var sortState = SortState() var valueFilter = GridValueFilterState() var displayFormats: [ValueDisplayFormat?] = [] var selectionSnapshot = GridSelectionSnapshot.empty var viewportAnchor: GridViewportPlacement? let layoutScope: DataGridLayoutScope = .ephemeral - private var detachedLayouts: [StructureTab: ColumnLayoutState] = [:] - private var detachedSort = SortState() - - var sortState: SortState { - get { session?.sortState ?? detachedSort } - set { - guard let session else { - detachedSort = newValue - return - } - guard session.sortState != newValue else { return } - session.sortState = newValue + var columnLayout: ColumnLayoutState { + didSet { + guard columnLayout.hiddenColumns != oldValue.hiddenColumns else { return } + defaults.set(Array(columnLayout.hiddenColumns).sorted(), forKey: Self.hiddenColumnsKey(section)) } } - var columnLayout: ColumnLayoutState { - get { session?.columnLayouts[subTab] ?? detachedLayouts[subTab] ?? ColumnLayoutState() } - set { - guard let session else { - detachedLayouts[subTab] = newValue - return - } - guard session.columnLayouts[subTab] != newValue else { return } - session.columnLayouts[subTab] = newValue + init(section: StructureSection, defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { + self.section = section + self.defaults = defaults + var layout = ColumnLayoutState() + if let stored = defaults.stringArray(forKey: Self.hiddenColumnsKey(section)) { + layout.hiddenColumns = Set(stored) + } else { + layout.hiddenColumns = Self.defaultHiddenColumns(section) } + columnLayout = layout } - // Search, sort, filter and selection start over on another sub-tab; the column layout is kept per sub-tab. - func switchSubTab(to tab: StructureTab) { - guard tab != subTab else { return } - subTab = tab - valueFilter = GridValueFilterState() - displayFormats = [] - selectionSnapshot = .empty - viewportAnchor = nil + static func hiddenColumnsKey(_ section: StructureSection) -> String { + "structure.hiddenColumns.\(section.rawValue)" + } + + // The inspector edits every field; the table keeps the ones read at a glance. + static func defaultHiddenColumns(_ section: StructureSection) -> Set { + switch section { + case .columns: + let hidden: [StructureColumnField] = [.onUpdate, .autoIncrement, .charset, .collation, .generated, .generationExpression] + return Set(hidden.map(\.displayName)) + case .foreignKeys: + return [String(localized: "Ref Schema")] + case .indexes, .checkConstraints: + return [] + } } } diff --git a/apple/TablePro/Views/Structure/StructureGridSupport.swift b/apple/TablePro/Views/Structure/StructureGridSupport.swift index 16ffdc1dbb..bc603c1a01 100644 --- a/apple/TablePro/Views/Structure/StructureGridSupport.swift +++ b/apple/TablePro/Views/Structure/StructureGridSupport.swift @@ -17,7 +17,7 @@ enum StructureGridEdits { _ text: String, field: Int, sourceIndex: Int, - tab: StructureTab, + tab: StructureSection, manager: StructureChangeManager, orderedFields: [StructureColumnField], connection: DatabaseConnection, @@ -62,8 +62,6 @@ enum StructureGridEdits { var constraint = manager.workingCheckConstraints[sourceIndex] StructureEditingSupport.updateCheckConstraint(&constraint, at: field, with: text) manager.updateCheckConstraint(id: constraint.id, with: constraint) - case .options, .ddl, .parts, .triggers: - break } return nil } @@ -73,7 +71,7 @@ enum StructureGridEdits { _ text: String, field: Int, sourceIndex: Int, - tab: StructureTab, + tab: StructureSection, manager: StructureChangeManager, orderedFields: [StructureColumnField], characterSets: ColumnCharacterSetMenus.Context? @@ -95,7 +93,7 @@ enum StructureGridEdits { } // One Cmd+Z brings the whole selection back: the manager registers an undo per row. - static func delete(sourceIndices: [Int], tab: StructureTab, manager: StructureChangeManager) { + static func delete(sourceIndices: [Int], tab: StructureSection, manager: StructureChangeManager) { manager.performAsOneUndoStep { for index in sourceIndices.sorted(by: >) { switch tab { @@ -111,59 +109,73 @@ enum StructureGridEdits { case .checkConstraints: guard manager.workingCheckConstraints.indices.contains(index) else { continue } manager.deleteCheckConstraint(id: manager.workingCheckConstraints[index].id) - case .options, .ddl, .parts, .triggers: - break } } } } - static func duplicate(sourceIndices: [Int], tab: StructureTab, manager: StructureChangeManager) { - for index in sourceIndices.sorted() { - switch tab { - case .columns: - guard manager.workingColumns.indices.contains(index) else { continue } - manager.addColumn(manager.workingColumns[index].withNewIdentity()) - case .indexes: - guard manager.workingIndexes.indices.contains(index) else { continue } - var copy = manager.workingIndexes[index].withNewIdentity() - copy.isPrimary = false - manager.addIndex(copy) - case .foreignKeys: - guard manager.workingForeignKeys.indices.contains(index) else { continue } - manager.addForeignKey(manager.workingForeignKeys[index].withNewIdentity()) - case .checkConstraints: - guard manager.workingCheckConstraints.indices.contains(index) else { continue } - manager.addCheckConstraint(manager.workingCheckConstraints[index].withNewIdentity()) - case .options, .ddl, .parts, .triggers: - break + static func duplicate(sourceIndices: [Int], tab: StructureSection, manager: StructureChangeManager) { + manager.performAsOneUndoStep { + for index in sourceIndices.sorted() { + switch tab { + case .columns: + guard manager.workingColumns.indices.contains(index) else { continue } + manager.addColumn(manager.workingColumns[index].withNewIdentity()) + case .indexes: + guard manager.workingIndexes.indices.contains(index) else { continue } + var copy = manager.workingIndexes[index].withNewIdentity() + copy.isPrimary = false + manager.addIndex(copy) + case .foreignKeys: + guard manager.workingForeignKeys.indices.contains(index) else { continue } + manager.addForeignKey(manager.workingForeignKeys[index].withNewIdentity()) + case .checkConstraints: + guard manager.workingCheckConstraints.indices.contains(index) else { continue } + manager.addCheckConstraint(manager.workingCheckConstraints[index].withNewIdentity()) + } } } } - static func addRow(on tab: StructureTab, manager: StructureChangeManager) { + static func addRow(on tab: StructureSection, manager: StructureChangeManager) { switch tab { case .columns: manager.addNewColumn() case .indexes: manager.addNewIndex() case .foreignKeys: manager.addNewForeignKey() case .checkConstraints: manager.addNewCheckConstraint() - case .options, .ddl, .parts, .triggers: break } } - static func rowCount(on tab: StructureTab, manager: StructureChangeManager) -> Int { + static func entityIDs(in section: StructureSection, manager: StructureChangeManager) -> [UUID] { + switch section { + case .columns: manager.workingColumns.map(\.id) + case .indexes: manager.workingIndexes.map(\.id) + case .foreignKeys: manager.workingForeignKeys.map(\.id) + case .checkConstraints: manager.workingCheckConstraints.map(\.id) + } + } + + static func sourceIndex(of id: UUID, in section: StructureSection, manager: StructureChangeManager) -> Int? { + switch section { + case .columns: manager.workingColumns.firstIndex { $0.id == id } + case .indexes: manager.workingIndexes.firstIndex { $0.id == id } + case .foreignKeys: manager.workingForeignKeys.firstIndex { $0.id == id } + case .checkConstraints: manager.workingCheckConstraints.firstIndex { $0.id == id } + } + } + + static func rowCount(on tab: StructureSection, manager: StructureChangeManager) -> Int { switch tab { case .columns: manager.workingColumns.count case .indexes: manager.workingIndexes.count case .foreignKeys: manager.workingForeignKeys.count case .checkConstraints: manager.workingCheckConstraints.count - case .options, .ddl, .parts, .triggers: 0 } } static func visualState( sourceIndex: Int, - tab: StructureTab, + tab: StructureSection, manager: StructureChangeManager, orderedFields: [StructureColumnField], tintsModifiedFields: Bool @@ -182,7 +194,7 @@ enum StructureGridEdits { private static func modifiedFields( sourceIndex: Int, - tab: StructureTab, + tab: StructureSection, manager: StructureChangeManager, orderedFields: [StructureColumnField] ) -> Set { @@ -207,23 +219,24 @@ enum StructureGridEdits { let working = manager.workingCheckConstraints[sourceIndex] guard let original = manager.currentCheckConstraints.first(where: { $0.id == working.id }) else { return [] } return StructureEditingSupport.checkConstraintModifiedIndices(old: original, new: working) - case .options, .ddl, .parts, .triggers: - return [] } } } -// The schema manager has no row identities of its own; this answers the grid's row questions by the -// source-index RowIDs `StructureRowProvider` hands out. +// The schema manager has no row identities of its own; this answers the grid's row questions through the RowIDs +// the section's adapter minted, one per working definition in order. @MainActor final class StructureGridChangeState: ChangeManaging { let manager: StructureChangeManager - var tab: StructureTab - var rowCount: Int = 0 + let section: StructureSection + var rowIDs: [RowID] = [] { + didSet { sourceIndexByRowID = Dictionary(uniqueKeysWithValues: rowIDs.enumerated().map { ($1, $0) }) } + } + private var sourceIndexByRowID: [RowID: Int] = [:] - init(manager: StructureChangeManager, tab: StructureTab = .columns) { + init(manager: StructureChangeManager, section: StructureSection) { self.manager = manager - self.tab = tab + self.section = section } var hasChanges: Bool { manager.hasChanges } @@ -232,18 +245,16 @@ final class StructureGridChangeState: ChangeManaging { var rowChanges: [RowChange] { [] } var insertedRowIDs: Set { - Set((0.. { - Set((0.. Bool { - guard let index = StructureRowProvider.sourceIndex(of: rowID), index >= 0 else { return false } - return manager.deleteInsertState(for: index, tab: tab).isDeleted + guard let index = sourceIndexByRowID[rowID] else { return false } + return manager.deleteInsertState(for: index, tab: section).isDeleted } func recordCellChange( @@ -256,8 +267,10 @@ final class StructureGridChangeState: ChangeManaging { ) {} func undoRowDeletion(rowID: RowID) { - guard let index = StructureRowProvider.sourceIndex(of: rowID) else { return } - manager.undoDelete(for: tab, at: index) + guard let index = sourceIndexByRowID[rowID] else { return } + let ids = StructureGridEdits.entityIDs(in: section, manager: manager) + guard ids.indices.contains(index) else { return } + manager.undoDelete(section: section, id: ids[index]) } } diff --git a/apple/TablePro/Views/Structure/StructureInspectorRowBuilder.swift b/apple/TablePro/Views/Structure/StructureInspectorRowBuilder.swift deleted file mode 100644 index cbbc261b7d..0000000000 --- a/apple/TablePro/Views/Structure/StructureInspectorRowBuilder.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// 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, - lockedFieldIndices: Set = [], - rowOptions: (Int) -> [GridMenuOption]? = { _ in nil } - ) -> InspectorRow? { - switch tab { - case .columns, .indexes, .foreignKeys, .checkConstraints: - break - case .options, .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, - options: rowOptions(index) ?? dropdownOptions[index], - typePickerColumns: typePickerColumns - ), - isModified: modified.contains(index), - isEditable: !lockedFieldIndices.contains(index) - ) - } - - return InspectorRow( - fields: fields, - isEditable: canEditSchema && !provider.isPendingDelete(atDisplay: displayRow) - ) - } - - /// An option list that carries a `Custom…` entry is an open vocabulary, so the field keeps its - /// text editor and puts the list beside it. A closed list becomes the picker it already was; - /// giving the Default field one would take away the only way to type an expression. - private static func editor( - at index: Int, - options: [GridMenuOption]?, - typePickerColumns: Set - ) -> FieldEditorKind { - if let options, !options.isEmpty { - if options.contains(where: { if case .custom = $0 { return true } else { return false } }) { - return .valuePicker(options: options) - } - return .enumPicker(values: options.compactMap(\.sql)) - } - if typePickerColumns.contains(index) { - return .typePicker - } - return .schemaText - } -} diff --git a/apple/TablePro/Views/Structure/StructureLoadState.swift b/apple/TablePro/Views/Structure/StructureLoadState.swift new file mode 100644 index 0000000000..d63d7700ee --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureLoadState.swift @@ -0,0 +1,64 @@ +// +// StructureLoadState.swift +// TablePro +// + +import Foundation + +// What a structure tab has read, what a refresh made stale, and which read failed, per part. A failed read keeps +// what was loaded before it and is reported where that part shows, never over the whole tab. +struct StructureLoadState: Equatable { + enum Item: Hashable, CaseIterable { + case columns + case indexes + case foreignKeys + case checkConstraints + case triggers + case ddl + + init(_ section: StructureSection) { + switch section { + case .columns: self = .columns + case .indexes: self = .indexes + case .foreignKeys: self = .foreignKeys + case .checkConstraints: self = .checkConstraints + } + } + } + + private var loaded: Set = [] + private var stale: Set = [] + private(set) var failures: [Item: String] = [:] + + func hasData(_ item: Item) -> Bool { + loaded.contains(item) + } + + func needsFetch(_ item: Item) -> Bool { + !loaded.contains(item) || stale.contains(item) || failures[item] != nil + } + + func failure(_ item: Item) -> String? { + failures[item] + } + + mutating func markAllStale() { + stale = loaded + } + + mutating func markFetched(_ item: Item) { + loaded.insert(item) + stale.remove(item) + failures.removeValue(forKey: item) + } + + mutating func markFailed(_ item: Item, message: String) { + failures[item] = message + } + + mutating func reset() { + loaded.removeAll() + stale.removeAll() + failures.removeAll() + } +} diff --git a/apple/TablePro/Views/Structure/StructureRowProvider.swift b/apple/TablePro/Views/Structure/StructureRowProvider.swift index 1bc918aa9a..7258b82d77 100644 --- a/apple/TablePro/Views/Structure/StructureRowProvider.swift +++ b/apple/TablePro/Views/Structure/StructureRowProvider.swift @@ -18,7 +18,7 @@ final class StructureRowProvider { ] private let changeManager: StructureChangeManager - private let tab: StructureTab + private let tab: StructureSection private let databaseType: DatabaseType private let additionalFields: Set private let serverSupport: StructureServerSupport @@ -42,13 +42,13 @@ final class StructureRowProvider { case .columns: return orderedColumnFields.map { $0.displayName } case .indexes: - return [ + let headings = [ String(localized: "Name"), String(localized: "Columns"), String(localized: "Type"), - String(localized: "Unique"), - String(localized: "Condition") + String(localized: "Unique") ] + return Self.offersIndexCondition(databaseType) ? headings + [String(localized: "Condition")] : headings case .foreignKeys: return [ String(localized: "Name"), @@ -65,8 +65,6 @@ final class StructureRowProvider { String(localized: "Expression"), String(localized: "Columns") ] - case .options, .ddl, .parts, .triggers: - return [] } } @@ -87,11 +85,21 @@ final class StructureRowProvider { picksCharacterSets ? Set(orderedColumnFields.indices.filter { ColumnCharacterSetMenus.isCharacterSetField(orderedColumnFields[$0]) }) : [] - case .checkConstraints, .options, .ddl, .parts, .triggers: [] + case .checkConstraints: [] } } static let indexTypeColumn = 2 + static let indexUniqueColumn = 3 + + // Yes-or-no fields, drawn as checkboxes in the grid and the inspector. + var booleanColumns: Set { + switch tab { + case .columns: Set(Self.booleanFields.compactMap { orderedColumnFields.firstIndex(of: $0) }) + case .indexes: [Self.indexUniqueColumn] + case .foreignKeys, .checkConstraints: [] + } + } static func indexMenuOptions( columnIndex: Int, @@ -127,12 +135,11 @@ final class StructureRowProvider { result[index] = ColumnDefaultVocabulary.options(for: databaseType) } return result - case .checkConstraints, .options, .ddl, .parts, .triggers: + case .checkConstraints: return [:] } } - static let booleanOptions = ["YES", "NO"] static let notGeneratedOption = String(localized: "Not generated") @@ -144,7 +151,7 @@ final class StructureRowProvider { case .columns: if let i = orderedColumnFields.firstIndex(of: .type) { return [i] } return [] - case .indexes, .foreignKeys, .checkConstraints, .options, .ddl, .parts, .triggers: + case .indexes, .foreignKeys, .checkConstraints: return [] } } @@ -155,7 +162,7 @@ final class StructureRowProvider { init( changeManager: StructureChangeManager, - tab: StructureTab, + tab: StructureSection, databaseType: DatabaseType = .mysql, additionalFields: Set = [], serverSupport: StructureServerSupport, @@ -178,7 +185,7 @@ final class StructureRowProvider { ) let allRows = Self.buildAllRows( - tab: tab, changeManager: changeManager, orderedColumnFields: self.orderedColumnFields + tab: tab, changeManager: changeManager, orderedColumnFields: self.orderedColumnFields, databaseType: databaseType ) self.cachedRows = Self.applyFilterAndSort( allRows, filterText: filterText, sortDescriptor: sortDescriptor @@ -233,7 +240,7 @@ final class StructureRowProvider { 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) + return Self.row(for: original, offersCondition: Self.offersIndexCondition(databaseType)) case .foreignKeys: guard sourceIndex < changeManager.workingForeignKeys.count else { return nil } let id = changeManager.workingForeignKeys[sourceIndex].id @@ -246,8 +253,6 @@ final class StructureRowProvider { return nil } return Self.row(for: original) - case .options, .ddl, .parts, .triggers: - return nil } } @@ -259,9 +264,10 @@ final class StructureRowProvider { } private static func buildAllRows( - tab: StructureTab, + tab: StructureSection, changeManager: StructureChangeManager, - orderedColumnFields: [StructureColumnField] + orderedColumnFields: [StructureColumnField], + databaseType: DatabaseType ) -> [IndexedRow] { switch tab { case .columns: @@ -270,7 +276,7 @@ final class StructureRowProvider { } case .indexes: return changeManager.workingIndexes.enumerated().map { index, indexInfo in - IndexedRow(sourceIndex: index, row: row(for: indexInfo)) + IndexedRow(sourceIndex: index, row: row(for: indexInfo, offersCondition: offersIndexCondition(databaseType))) } case .foreignKeys: return changeManager.workingForeignKeys.enumerated().map { index, fk in @@ -280,8 +286,6 @@ final class StructureRowProvider { return changeManager.workingCheckConstraints.enumerated().map { index, constraint in IndexedRow(sourceIndex: index, row: row(for: constraint)) } - case .options, .ddl, .parts, .triggers: - return [] } } @@ -316,20 +320,25 @@ final class StructureRowProvider { } } - private static func row(for index: EditableIndexDefinition) -> [String?] { + // MySQL and MariaDB have no partial indexes and their DDL drops a WHERE clause, so none is offered. + static func offersIndexCondition(_ type: DatabaseType) -> Bool { + SQLTypeFamily.of(type) != .mysql + } + + private static func row(for index: EditableIndexDefinition, offersCondition: Bool) -> [String?] { let columnsStr = index.columns.map { col in if let prefix = index.columnPrefixes[col] { return "\(col)(\(prefix))" } return col }.joined(separator: ", ") - return [ + let values: [String?] = [ index.name, columnsStr, index.type.rawValue, - index.isUnique ? "YES" : "NO", - index.whereClause ?? "" + index.isUnique ? "YES" : "NO" ] + return offersCondition ? values + [index.whereClause ?? ""] : values } private static func row(for fk: EditableForeignKeyDefinition) -> [String?] { @@ -372,27 +381,16 @@ final class StructureRowProvider { } extension StructureRowProvider { - // A row is named by its index in the working list, so a search, a sort or the grid's value filter - // never changes which definition an edit lands on. - static func rowID(forSourceIndex index: Int) -> RowID { - .existing(index) - } - - static func sourceIndex(of rowID: RowID) -> Int? { - guard case .existing(let index) = rowID, index >= 0 else { return nil } - return index - } - func displayRow(ofSourceIndex sourceIndex: Int) -> Int? { cachedRows.firstIndex { $0.sourceIndex == sourceIndex } } - func asTableRows() -> TableRows { + func asTableRows(rowID: (Int) -> RowID) -> TableRows { var rows = ContiguousArray() rows.reserveCapacity(cachedRows.count) for indexed in cachedRows { rows.append(Row( - id: Self.rowID(forSourceIndex: indexed.sourceIndex), + id: rowID(indexed.sourceIndex), values: ContiguousArray(indexed.row.map(PluginCellValue.fromOptional)) )) } diff --git a/apple/TablePro/Views/Structure/StructureSavePlan.swift b/apple/TablePro/Views/Structure/StructureSavePlan.swift index 1141f328d4..6f2f4950bc 100644 --- a/apple/TablePro/Views/Structure/StructureSavePlan.swift +++ b/apple/TablePro/Views/Structure/StructureSavePlan.swift @@ -47,7 +47,9 @@ internal extension StructureEditingSession { plan = try await stagedSavePlan(for: changes) } catch { guard mayPresentPreview(on: coordinator) else { return } - coordinator.toolbarState.previewStatements = ["-- Error generating SQL: \(error.localizedDescription)"] + coordinator.toolbarState.previewStatements = [ + String(format: String(localized: "-- Error generating SQL: %@"), error.localizedDescription) + ] coordinator.activeSheet = .sqlPreview return } diff --git a/apple/TablePro/Views/Structure/StructureGridAdapter+Menus.swift b/apple/TablePro/Views/Structure/StructureSectionAdapter+Menus.swift similarity index 69% rename from apple/TablePro/Views/Structure/StructureGridAdapter+Menus.swift rename to apple/TablePro/Views/Structure/StructureSectionAdapter+Menus.swift index 65447cc5ee..553d944eb5 100644 --- a/apple/TablePro/Views/Structure/StructureGridAdapter+Menus.swift +++ b/apple/TablePro/Views/Structure/StructureSectionAdapter+Menus.swift @@ -1,20 +1,20 @@ // -// StructureGridAdapter+Menus.swift +// StructureSectionAdapter+Menus.swift // TablePro // import AppKit import TableProPluginKit -extension StructureGridAdapter { +extension StructureSectionAdapter { // The grid adds Copy, Duplicate, Delete and Undo Delete from its commands; these are the schema's own. // The copies act on every targeted row, Open and the moves on the clicked one; nil when that is not known. - func structureRowMenuItems(for rowIDs: [RowID], clickedRow: RowID?) -> [NSMenuItem] { - guard showsGrid, !rowIDs.isEmpty else { return [] } + func rowMenuItems(for rowIDs: [RowID], clickedRow: RowID?) -> [NSMenuItem] { + guard !rowIDs.isEmpty else { return [] } let indices = sourceIndices(of: rowIDs) - let tab = selectedTab - let clicked = clickedRow.flatMap(StructureRowProvider.sourceIndex(of:)) - if let clicked, structureChangeManager.deleteInsertState(for: clicked, tab: tab).isDeleted { + let tab = section + let clicked = clickedRow.flatMap(sourceIndex(of:)) + if let clicked, manager.deleteInsertState(for: clicked, tab: tab).isDeleted { return [] } @@ -24,8 +24,8 @@ extension StructureGridAdapter { copyAsItem(indices), ] - if tab == .foreignKeys, let clicked, structureChangeManager.workingForeignKeys.indices.contains(clicked) { - let referenced = structureChangeManager.workingForeignKeys[clicked].referencedTable + if tab == .foreignKeys, let clicked, manager.workingForeignKeys.indices.contains(clicked) { + let referenced = manager.workingForeignKeys[clicked].referencedTable if !referenced.isEmpty { items.append(.separator()) items.append(menuItem(String(format: String(localized: "Open %@"), referenced)) { [weak self] in @@ -44,10 +44,10 @@ extension StructureGridAdapter { // Moving by menu gives the keyboard and VoiceOver the reorder a drag gives the pointer. private func columnMoveItems(movingRow row: Int?) -> [NSMenuItem] { - guard selectedTab == .columns else { return [] } + guard section == .columns, !context.isCreatingTable else { return [] } let reorder = model.configuration.rowReorder guard reorder.isEnabled || reorder.unavailableReason != nil else { return [] } - let count = structureChangeManager.workingColumns.count + let count = manager.workingColumns.count var items = [ columnMoveItem(String(localized: "Move Column Up"), row: row, .up, count: count, isEnabled: reorder.isEnabled), columnMoveItem(String(localized: "Move Column Down"), row: row, .down, count: count, isEnabled: reorder.isEnabled), @@ -70,7 +70,7 @@ extension StructureGridAdapter { return NSMenuItem(title: title, action: nil, keyEquivalent: "") } return menuItem(title) { [weak self] in - self?.moveRowHandler?(row, ColumnMove.dropIndex(movingRow: row, direction)) + self?.editor?.moveColumn(from: row, to: ColumnMove.dropIndex(movingRow: row, direction)) } } @@ -84,30 +84,6 @@ extension StructureGridAdapter { return item } - func makeEmptySpaceMenu() -> NSMenu? { - guard showsGrid, editGate.canEditSchema else { return nil } - let label: String - switch selectedTab { - case .columns: - guard editGate.allows(.addColumn) else { return nil } - label = String(localized: "Add Column") - case .indexes: - guard editGate.allows(.addIndex) else { return nil } - label = String(localized: "Add Index") - case .foreignKeys: - guard canEditForeignKeys else { return nil } - label = String(localized: "Add Foreign Key") - case .checkConstraints: - guard editGate.allows(.addCheckConstraint) else { return nil } - label = String(localized: "Add Check Constraint") - case .options, .ddl, .parts, .triggers: - return nil - } - let menu = NSMenu() - menu.addItem(menuItem(label) { [weak self] in self?.addRow() }) - return menu - } - private func menuItem(_ title: String, action: @escaping () -> Void) -> NSMenuItem { let target = StructureMenuTarget(action: action) let item = NSMenuItem(title: title, action: #selector(StructureMenuTarget.runAction), keyEquivalent: "") @@ -120,11 +96,11 @@ extension StructureGridAdapter { private func unsortedProvider(includingPrimaryKey: Bool) -> StructureRowProvider { StructureRowProvider( - changeManager: structureChangeManager, - tab: selectedTab, - databaseType: connection.type, + changeManager: manager, + tab: section, + databaseType: context.databaseType, additionalFields: includingPrimaryKey ? [.primaryKey] : [], - serverSupport: session?.serverSupport ?? .unrestricted + serverSupport: serverSupport ) } @@ -136,33 +112,31 @@ extension StructureGridAdapter { } func copyDefinitions(_ indices: [Int]) { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } + guard let driver = DatabaseManager.shared.driver(for: context.connection.id) else { return } var definitions: [String] = [] for index in indices.sorted() { - switch selectedTab { + switch section { case .columns: - guard structureChangeManager.workingColumns.indices.contains(index), - let sql = driver.generateColumnDefinitionSQL(column: structureChangeManager.workingColumns[index].toPlugin()) + guard manager.workingColumns.indices.contains(index), + let sql = driver.generateColumnDefinitionSQL(column: manager.workingColumns[index].toPlugin()) else { continue } definitions.append(sql) case .indexes: - guard structureChangeManager.workingIndexes.indices.contains(index), + guard manager.workingIndexes.indices.contains(index), let sql = driver.generateIndexDefinitionSQL( - index: structureChangeManager.workingIndexes[index].toPlugin(), tableName: tableName + index: manager.workingIndexes[index].toPlugin(), tableName: context.tableName ) else { continue } definitions.append(sql) case .foreignKeys: - guard structureChangeManager.workingForeignKeys.indices.contains(index), - let sql = driver.generateForeignKeyDefinitionSQL(fk: structureChangeManager.workingForeignKeys[index].toPlugin()) + guard manager.workingForeignKeys.indices.contains(index), + let sql = driver.generateForeignKeyDefinitionSQL(fk: manager.workingForeignKeys[index].toPlugin()) else { continue } definitions.append(sql) case .checkConstraints: - guard structureChangeManager.workingCheckConstraints.indices.contains(index) else { continue } - let constraint = structureChangeManager.workingCheckConstraints[index] + guard manager.workingCheckConstraints.indices.contains(index) else { continue } + let constraint = manager.workingCheckConstraints[index] definitions.append("CONSTRAINT \(driver.quoteIdentifier(constraint.name)) CHECK (\(constraint.expression))") - case .options, .ddl, .parts, .triggers: - break } } guard !definitions.isEmpty else { return } @@ -199,22 +173,14 @@ extension StructureGridAdapter { ClipboardService.shared.writeText(json) } - // The grid's own Copy reaches the definitions too, so a paste in this tab stages them again. - func gridCopyRows(_ rowIDs: [RowID]) -> Bool { - guard showsGrid, !rowIDs.isEmpty else { return false } - copyRows(rowIDs) - return true - } - // The definitions ride on the structure pasteboard type, so Paste can stage them again. - func copyRows(_ rowIDs: [RowID]) { - guard showsGrid, !rowIDs.isEmpty else { return } + func copyRows(_ rowIDs: [RowID], to pasteboard: NSPasteboard = .general) { + guard !rowIDs.isEmpty else { return } let ordered = rowIDs.sorted { (model.displayIndex(of: $0) ?? .max) < (model.displayIndex(of: $1) ?? .max) } let indices = sourceIndices(of: ordered) - let manager = structureChangeManager let encoder = JSONEncoder() let encoded: Data? - switch selectedTab { + switch section { case .columns: encoded = try? encoder.encode(indices.sorted().compactMap { manager.workingColumns.indices.contains($0) ? manager.workingColumns[$0] : nil }) case .indexes: @@ -227,8 +193,6 @@ extension StructureGridAdapter { encoded = try? encoder.encode(indices.sorted().compactMap { manager.workingCheckConstraints.indices.contains($0) ? manager.workingCheckConstraints[$0] : nil }) - case .options, .ddl, .parts, .triggers: - encoded = nil } let rows = model.rows @@ -238,23 +202,30 @@ extension StructureGridAdapter { let item = NSPasteboardItem() if let encoded, let json = String(data: encoded, encoding: .utf8) { - item.setString(json, forType: TableStructureView.structurePasteboardType) - item.setString(connection.type.rawValue, forType: TableStructureView.structureSourceTypePasteboardType) + item.setString(json, forType: StructurePasteboard.definitionsType) + item.setString(context.databaseType.rawValue, forType: StructurePasteboard.sourceDatabaseType) } if !lines.isEmpty { item.setString(lines.joined(separator: "\n"), forType: .string) } guard !item.types.isEmpty else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.writeObjects([item]) + pasteboard.clearContents() + pasteboard.writeObjects([item]) + } + + // Pasting several definitions is one undo step. + func pasteRows(from pasteboard: NSPasteboard = .general) { + guard canEdit, canAdd, let data = pasteboard.data(forType: StructurePasteboard.definitionsType) else { return } + let source = pasteboard.string(forType: StructurePasteboard.sourceDatabaseType).map(DatabaseType.init(rawValue:)) + manager.performAsOneUndoStep { + stagePasted(data, from: source) + } + editor?.definitionsDidChange() } - func pasteRows() { - guard canStageAddForSelectedTab, - let data = NSPasteboard.general.data(forType: TableStructureView.structurePasteboardType) else { return } + private func stagePasted(_ data: Data, from source: DatabaseType?) { let decoder = JSONDecoder() - let manager = structureChangeManager - switch selectedTab { + switch section { case .columns: guard let columns = try? decoder.decode([EditableColumnDefinition].self, from: data) else { return } for column in columns { @@ -262,11 +233,8 @@ extension StructureGridAdapter { } case .indexes: guard let indexes = try? decoder.decode([EditableIndexDefinition].self, from: data) else { return } - let source = NSPasteboard.general - .string(forType: TableStructureView.structureSourceTypePasteboardType) - .map(DatabaseType.init(rawValue:)) for index in indexes { - manager.addIndex(index.pasted(from: source, into: connection.type)) + manager.addIndex(index.pasted(from: source, into: context.databaseType)) } case .foreignKeys: guard let foreignKeys = try? decoder.decode([EditableForeignKeyDefinition].self, from: data) else { return } @@ -278,10 +246,16 @@ extension StructureGridAdapter { for constraint in constraints { manager.addCheckConstraint(constraint.withNewIdentity()) } - case .options, .ddl, .parts, .triggers: - return } - refresh() + } +} + +enum StructurePasteboard { + static let definitionsType = NSPasteboard.PasteboardType("com.TablePro.structure") + static let sourceDatabaseType = NSPasteboard.PasteboardType("com.TablePro.structure.database-type") + + static var canPaste: Bool { + NSPasteboard.general.data(forType: definitionsType) != nil } } diff --git a/apple/TablePro/Views/Structure/StructureSectionAdapter.swift b/apple/TablePro/Views/Structure/StructureSectionAdapter.swift new file mode 100644 index 0000000000..f40aaeae17 --- /dev/null +++ b/apple/TablePro/Views/Structure/StructureSectionAdapter.swift @@ -0,0 +1,514 @@ +// +// StructureSectionAdapter.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +// Feeds one section's grid from the working definitions and writes its edits back. A row is named by its +// definition's id, so a reload, a sort, a filter or a removed row above it never moves the selection. +@MainActor +final class StructureSectionAdapter { + let section: StructureSection + let model: DataGridModel + let store: StructureGridStateStore + let manager: StructureChangeManager + let context: StructureEditingContext + let referenceMenus: ForeignKeyReferenceMenus + + weak var editor: StructureDefinitionEditor? + + private(set) var provider: StructureRowProvider + private var snapshot = StructureGridSnapshot() + private var identities = StructureRowIdentities() + private var sortDescriptor: StructureSortDescriptor? + private let changeTracking: StructureGridChangeState + private let changeState: AnyChangeManager + + init( + section: StructureSection, + manager: StructureChangeManager, + context: StructureEditingContext, + referenceMenus: ForeignKeyReferenceMenus, + defaults: UserDefaults = AppStorageEnvironment.shared.defaults + ) { + self.section = section + self.manager = manager + self.context = context + self.referenceMenus = referenceMenus + let store = StructureGridStateStore(section: section, defaults: defaults) + self.store = store + self.model = DataGridModel(store: store) + self.changeTracking = StructureGridChangeState(manager: manager, section: section) + self.changeState = AnyChangeManager(changeTracking) + self.provider = StructureRowProvider( + changeManager: manager, + tab: section, + databaseType: context.databaseType, + additionalFields: [.primaryKey], + serverSupport: .unrestricted + ) + model.dataSource = self + model.actions = self + } + + // MARK: - State + + var serverSupport: StructureServerSupport { editor?.serverSupport ?? .unrestricted } + private var searchText: String { editor?.searchText ?? "" } + private var coordinator: MainContentCoordinator? { editor?.coordinator } + + var characterSetContext: ColumnCharacterSetMenus.Context { + editor?.characterSetContext ?? ColumnCharacterSetMenus.Context(catalog: .idle, tableCharacterSet: nil) + } + + var canAdd: Bool { context.allows(context.addOperation(for: section)) } + var canRemove: Bool { context.allows(context.removeOperation(for: section)) } + + var canEdit: Bool { + guard !manager.isHeldForSave, editor?.isReadOnly != true, editor?.isAwaitingBaseline != true else { return false } + return context.isCreatingTable || context.editGate.allowsAnyEdit + } + + var editRefusal: String? { + if manager.isHeldForSave { return StructureFooterPolicy.savingReason } + if editor?.isAwaitingBaseline == true { return String(localized: "The table is being read again after the save.") } + if editor?.isReadOnly == true { return editor?.readOnlyReason } + guard !context.isCreatingTable, !context.editGate.allowsAnyEdit else { return nil } + return context.editGate.resolve(.renameColumn).unavailableReason + } + + var lockedFieldIndices: Set { + guard !context.isCreatingTable else { return [] } + return context.editGate.lockedFieldIndices( + on: section, + orderedFields: provider.orderedColumnFields, + fieldCount: provider.columns.count + ) + } + + var columnReorderAvailability: ColumnReorderAvailability { + ColumnReorderPolicy.resolve( + support: PluginManager.shared.columnReorderSupport(for: context.databaseType), + engineName: context.databaseType.displayName, + isColumnsTab: section == .columns && !context.isCreatingTable, + kindRefusal: context.editGate.kindRefusal(.reorderColumns), + canEditSchema: context.editGate.canEditSchema && editor?.isReadOnly != true, + hasStagedChanges: manager.hasChanges, + isRearranged: !searchText.isEmpty || sortDescriptor != nil || store.valueFilter.isActive + ) + } + + var rowCount: Int { StructureGridEdits.rowCount(on: section, manager: manager) } + + var visibleRowCount: Int { snapshot.rows.rows.count } + + // MARK: - Identity + + func rowID(forEntity id: UUID) -> RowID { + identities.rowID(for: id) + } + + func entityID(of rowID: RowID) -> UUID? { + identities.entityID(of: rowID) + } + + func sourceIndex(of rowID: RowID) -> Int? { + guard let id = identities.entityID(of: rowID) else { return nil } + return StructureGridEdits.sourceIndex(of: id, in: section, manager: manager) + } + + func sourceIndices(of rowIDs: [RowID]) -> [Int] { + rowIDs.compactMap(sourceIndex(of:)) + } + + var selectedEntityIDs: [UUID] { + model.selection.affectedRows.compactMap { model.rowID(at: $0) }.compactMap(entityID(of:)) + } + + func select(entity id: UUID) { + model.selectRows([rowID(forEntity: id)]) + } + + // MARK: - Refresh + + func refresh() { + let entityIDs = StructureGridEdits.entityIDs(in: section, manager: manager) + let rowIDs = entityIDs.map { identities.rowID(for: $0) } + changeTracking.rowIDs = rowIDs + + let provider = StructureRowProvider( + changeManager: manager, + tab: section, + databaseType: context.databaseType, + additionalFields: [.primaryKey], + serverSupport: serverSupport, + filterText: searchText.isEmpty ? nil : searchText, + sortDescriptor: sortDescriptor, + picksCharacterSets: ColumnCharacterSetMenus.offersMenus(characterSetContext.catalog) + ) + self.provider = provider + let rows = provider.asTableRows { rowIDs.indices.contains($0) ? rowIDs[$0] : .existing(-1) } + let states = provider.filteredToSourceMap.map { sourceIndex in + StructureGridEdits.visualState( + sourceIndex: sourceIndex, + tab: section, + manager: manager, + orderedFields: provider.orderedColumnFields, + tintsModifiedFields: !context.isCreatingTable + ) + } + model.configuration = makeConfiguration(provider: provider) + let previous = snapshot + snapshot = StructureGridSnapshot(rows: rows, states: states) + model.applyHeldSnapshot(from: previous, to: snapshot) + } + + private func makeConfiguration(provider: StructureRowProvider) -> DataGridConfiguration { + var configuration = DataGridConfiguration() + let checkboxes = provider.booleanColumns + let options = provider.customDropdownOptions.filter { !checkboxes.contains($0.key) } + configuration.checkboxColumns = checkboxes + configuration.dropdownColumns = provider.dropdownColumns.subtracting(checkboxes) + configuration.typePickerColumns = provider.typePickerColumns + configuration.customDropdownOptions = options.isEmpty ? nil : options + configuration.marksEmptyText = false + configuration.lockedColumns = lockedColumns(provider: provider) + configuration.editRefusalMessage = editRefusal + configuration.isEditable = canEdit + configuration.fitsRows = true + configuration.traits = DataGridEngineTraits( + databaseType: context.databaseType, + connectionScope: DatabaseScope( + connectionId: context.connection.id, database: context.databaseName, schema: context.schemaName + ), + looksUpUserDefinedTypes: true + ) + let reorder = columnReorderAvailability + configuration.rowReorder = DataGridRowReorder( + isEnabled: reorder.isAvailable, + unavailableReason: section == .columns && !context.isCreatingTable ? reorder.unavailableReason : nil + ) + configuration.commands = commands(reorderAvailable: reorder.isAvailable) + return configuration + } + + private func commands(reorderAvailable: Bool) -> Set { + // Hide Column and Show All Columns bring back the fields a section starts with hidden. + var commands: Set = [.valueFilter, .hideColumns] + guard canEdit else { return commands } + if canAdd { + commands.formUnion([.addRow, .duplicateRows, .pasteRows]) + } + if canRemove { + commands.formUnion([.deleteRows, .undoDelete]) + } + if reorderAvailable { + commands.insert(.moveRows) + } + return commands + } + + private func lockedColumns(provider: StructureRowProvider) -> Set { + let headings = provider.columns + return Set(lockedFieldIndices.compactMap { headings.indices.contains($0) ? headings[$0] : nil }) + } + + // MARK: - Menus + + func menuOptions(sourceIndex: Int, field: Int) -> [GridMenuOption]? { + switch section { + case .indexes: + guard manager.workingIndexes.indices.contains(sourceIndex) else { return nil } + return StructureRowProvider.indexMenuOptions( + columnIndex: field, + index: manager.workingIndexes[sourceIndex], + serverSupport: serverSupport + ) + case .foreignKeys: + guard context.allows(.addForeignKey), manager.workingForeignKeys.indices.contains(sourceIndex) else { return nil } + referenceMenus.origin = context.scope + return referenceMenus.options( + columnIndex: field, + foreignKey: manager.workingForeignKeys[sourceIndex], + tableColumns: manager.workingColumns.map(\.name) + ) + case .columns: + guard manager.workingColumns.indices.contains(sourceIndex), + provider.orderedColumnFields.indices.contains(field) else { return nil } + return ColumnCharacterSetMenus.options( + for: provider.orderedColumnFields[field], + column: manager.workingColumns[sourceIndex], + context: characterSetContext + ) + case .checkConstraints: + return nil + } + } + + // Read-only per row, which the column-wide lock cannot say: a type with no character set, or a catalog not read. + func characterSetLockedFields(sourceIndex: Int) -> Set { + guard section == .columns, manager.workingColumns.indices.contains(sourceIndex) else { return [] } + return ColumnCharacterSetMenus.lockedFieldIndices( + orderedFields: provider.orderedColumnFields, + column: manager.workingColumns[sourceIndex], + catalog: characterSetContext.catalog + ) + } + + // Opening the menu is the retry, as it is for the reference menus. + func readCharacterSetCatalogIfNeeded(field: Int) { + guard section == .columns, provider.orderedColumnFields.indices.contains(field), + ColumnCharacterSetMenus.isCharacterSetField(provider.orderedColumnFields[field]) else { return } + editor?.readCharacterSetCatalogIfNeeded() + } + + // MARK: - Edits + + @discardableResult + func applyEdit(rowID: RowID, field: Int, text: String) -> Bool { + guard canEdit, let sourceIndex = sourceIndex(of: rowID) else { return false } + let applied = write(text, field: field, sourceIndex: sourceIndex) + editor?.definitionsDidChange() + return applied + } + + @discardableResult + func applyEdit(entity id: UUID, field: Int, text: String) -> Bool { + applyEdit(rowID: rowID(forEntity: id), field: field, text: text) + } + + private func write(_ text: String, field: Int, sourceIndex: Int) -> Bool { + let characterSets = characterSetContext + guard field >= 0, + !lockedFieldIndices.contains(field), + !manager.deleteInsertState(for: sourceIndex, tab: section).isDeleted, + !StructureGridEdits.refuses( + text, + field: field, + sourceIndex: sourceIndex, + tab: section, + manager: manager, + orderedFields: provider.orderedColumnFields, + characterSets: characterSets + ) else { return false } + let changedReference = StructureGridEdits.apply( + text, + field: field, + sourceIndex: sourceIndex, + tab: section, + manager: manager, + orderedFields: provider.orderedColumnFields, + connection: context.connection, + characterSets: characterSets + ) + if let changedReference { + referenceMenus.origin = context.scope + referenceMenus.prefetchReferencedColumns( + of: changedReference.referencedTable, schema: changedReference.referencedSchema + ) + } + return true + } + + func addRow() { + guard canEdit, canAdd else { return } + StructureGridEdits.addRow(on: section, manager: manager) + editor?.definitionsDidChange() + if let id = StructureGridEdits.entityIDs(in: section, manager: manager).last { + editor?.reveal(section: section, entity: id) + } + } + + func deleteRows(_ rowIDs: [RowID]) { + guard canEdit, canRemove, !rowIDs.isEmpty else { return } + let displayRows = IndexSet(rowIDs.compactMap { model.displayIndex(of: $0) }) + let countBefore = model.rowCount + StructureGridEdits.delete(sourceIndices: sourceIndices(of: rowIDs), tab: section, manager: manager) + editor?.definitionsDidChange() + selectAfterDeleting(displayRows, countBefore: countBefore) + } + + private func selectAfterDeleting(_ displayRows: IndexSet, countBefore: Int) { + let remaining = model.rowCount + guard remaining > 0, let minRow = displayRows.first, let maxRow = displayRows.last else { + model.clearSelection() + return + } + // Deleting an existing definition keeps its row (struck through); the selection stays on it. + guard remaining < countBefore else { return } + let removed = countBefore - remaining + let target = maxRow - removed + 1 < remaining ? maxRow - removed + 1 : (minRow > 0 ? minRow - 1 : 0) + guard let rowID = model.rowID(at: min(max(target, 0), remaining - 1)) else { + model.clearSelection() + return + } + model.selectRows([rowID]) + } + + func duplicateRows(_ rowIDs: [RowID]) { + guard canEdit, canAdd else { return } + StructureGridEdits.duplicate(sourceIndices: sourceIndices(of: rowIDs), tab: section, manager: manager) + editor?.definitionsDidChange() + } + + func undoDelete(_ rowIDs: [RowID]) { + let ids = rowIDs.compactMap(entityID(of:)) + manager.performAsOneUndoStep { + for id in ids { + manager.undoDelete(section: section, id: id) + } + } + editor?.definitionsDidChange() + } + + func navigateToForeignKey(sourceIndex: Int) { + guard manager.workingForeignKeys.indices.contains(sourceIndex), let coordinator else { return } + let foreignKey = manager.workingForeignKeys[sourceIndex] + let target = ForeignKeyTargetScope.resolve( + origin: context.scope, referencedSchema: foreignKey.referencedSchema, databaseType: context.databaseType + ) + coordinator.openTableTab(foreignKey.referencedTable, schema: target.schema, database: target.database) + } + + func sortDidChange(_ sort: SortState) { + if let primary = sort.columns.first { + sortDescriptor = StructureSortDescriptor(column: primary.columnIndex, ascending: primary.direction == .ascending) + store.sortState = SortState(columns: [primary], source: .user) + } else { + sortDescriptor = nil + store.sortState = SortState(columns: [], source: .user) + } + refresh() + } +} + +// MARK: - DataGridDataSource + +extension StructureSectionAdapter: DataGridDataSource { + var gridRows: TableRows { snapshot.rows } + var gridChangeState: AnyChangeManager { changeState } + + func gridVisualState(for rowID: RowID) -> RowVisualState? { + snapshot.state(for: rowID) + } + + func gridCheckboxState(for rowID: RowID, column: DataGridColumn) -> Bool? { + guard provider.booleanColumns.contains(column.dataIndex), + let row = snapshot.rows.row(withID: rowID), column.dataIndex < row.values.count else { return nil } + return row.values[column.dataIndex].asText == "YES" + } + + func gridMenuOptions(for rowID: RowID, column: DataGridColumn) -> [GridMenuOption]? { + guard let sourceIndex = sourceIndex(of: rowID) else { return nil } + let options = menuOptions(sourceIndex: sourceIndex, field: column.dataIndex) + readCharacterSetCatalogIfNeeded(field: column.dataIndex) + return options + } +} + +// MARK: - DataGridActions + +extension StructureSectionAdapter: DataGridActions { + func gridCommit(_ edits: [DataGridResolvedEdit], actionName: String) -> Bool { + guard canEdit, !edits.isEmpty else { return false } + var applied = false + manager.performAsOneUndoStep { + for edit in edits { + guard let sourceIndex = sourceIndex(of: edit.rowID) else { continue } + if write(edit.newValue.asText ?? "", field: edit.dataIndex, sourceIndex: sourceIndex) { + applied = true + } + } + } + editor?.definitionsDidChange() + return applied + } + + func gridSetCheckbox(_ isOn: Bool, rowIDs: [RowID], column: DataGridColumn) { + guard canEdit, provider.booleanColumns.contains(column.dataIndex) else { return } + manager.performAsOneUndoStep { + for rowID in rowIDs { + guard let sourceIndex = sourceIndex(of: rowID) else { continue } + _ = write(isOn ? "YES" : "NO", field: column.dataIndex, sourceIndex: sourceIndex) + } + } + editor?.definitionsDidChange() + } + + func gridDeleteRows(_ rowIDs: [RowID]) { + deleteRows(rowIDs) + } + + func gridUndoDelete(_ rowIDs: [RowID]) { + undoDelete(rowIDs) + } + + func gridAddRow() { + addRow() + } + + func gridDuplicateRows(_ rowIDs: [RowID]) { + duplicateRows(rowIDs) + } + + func gridCanPasteRows() -> Bool { + canEdit && canAdd && StructurePasteboard.canPaste + } + + func gridPasteRows() { + pasteRows() + } + + func gridCopyRows(_ rowIDs: [RowID]) -> Bool { + guard !rowIDs.isEmpty else { return false } + copyRows(rowIDs) + return true + } + + // A move is offered only while nothing filters or sorts the list, so the drop index is a working index. + func gridMoveRow(from source: RowID, toDisplayIndex destination: Int) { + guard let sourceIndex = sourceIndex(of: source) else { return } + editor?.moveColumn(from: sourceIndex, to: destination) + } + + // The provider sorts by one column, so a secondary key the header appended is dropped. + func gridSortDidChange(_ sort: SortState) { + sortDidChange(sort) + } + + // A value filter rearranges the list, which withdraws the column move. + func gridConfirmDisplayOrderChange(then apply: @escaping @MainActor () -> Void) { + apply() + model.configuration = makeConfiguration(provider: provider) + } + + func gridRowMenuItems(for rowIDs: [RowID], clickedRow: RowID) -> [NSMenuItem] { + rowMenuItems(for: rowIDs, clickedRow: clickedRow) + } + + func gridEmptySpaceMenu() -> NSMenu? { + nil + } +} + +// Mints one RowID per definition id and keeps it for the adapter's lifetime. +struct StructureRowIdentities { + private var rowIDs: [UUID: RowID] = [:] + private var entityIDs: [RowID: UUID] = [:] + private var next = 0 + + mutating func rowID(for id: UUID) -> RowID { + if let existing = rowIDs[id] { return existing } + let rowID = RowID.existing(next) + next += 1 + rowIDs[id] = rowID + entityIDs[rowID] = id + return rowID + } + + func entityID(of rowID: RowID) -> UUID? { + entityIDs[rowID] + } +} diff --git a/apple/TablePro/Views/Structure/StructureServerSupport.swift b/apple/TablePro/Views/Structure/StructureServerSupport.swift index 8a35217ea4..7029f3f3d5 100644 --- a/apple/TablePro/Views/Structure/StructureServerSupport.swift +++ b/apple/TablePro/Views/Structure/StructureServerSupport.swift @@ -49,11 +49,11 @@ struct StructureServerSupport: Equatable, Sendable { } /// Exhaustive on purpose: a new tab has to state whether this server can refuse it. - func offers(_ tab: StructureTab) -> Bool { + func offers(_ tab: StructureSection) -> Bool { switch tab { case .checkConstraints: return checkConstraintRefusal == nil - case .columns, .indexes, .foreignKeys, .triggers, .options, .ddl, .parts: + case .columns, .indexes, .foreignKeys: return true } } diff --git a/apple/TablePro/Views/Structure/StructureTabAvailability.swift b/apple/TablePro/Views/Structure/StructureTabAvailability.swift deleted file mode 100644 index 155b53f9a8..0000000000 --- a/apple/TablePro/Views/Structure/StructureTabAvailability.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// StructureTabAvailability.swift -// TablePro -// - -import Foundation - -/// Which tabs the Structure editor offers, from what the engine can do and what the connected -/// server answers for. -/// -/// Both halves matter. `supportsCheckConstraints` is the engine at its newest release; the server -/// in front of the user may be older than the release that gained them, and MySQL before 8.0.16 -/// and MariaDB before 10.2.1 answer `Query OK` to a `CHECK` clause and throw it away. Offering the -/// tab there is offering an edit that reports success and changes nothing. -enum StructureTabAvailability { - // Table options are the driver's answer for this object (`offersTableOptionsTab`), never the engine's type. - static func tabs( - for type: DatabaseType, - serverSupport: StructureServerSupport, - offersTableOptions: Bool = false - ) -> [StructureTab] { - StructureTab.allCases.filter { tab in - engineOffers(tab, on: type, offersTableOptions: offersTableOptions) && serverSupport.offers(tab) - } - } - - private static func engineOffers(_ tab: StructureTab, on type: DatabaseType, offersTableOptions: Bool) -> Bool { - switch tab { - case .foreignKeys: - return type.supportsForeignKeys - case .parts: - return type == .clickhouse - case .triggers: - return type.supportsTriggers - case .checkConstraints: - return type.supportsCheckConstraints - case .options: - return offersTableOptions - case .columns, .indexes, .ddl: - return true - } - } -} diff --git a/apple/TablePro/Views/Structure/StructureTabDataState.swift b/apple/TablePro/Views/Structure/StructureTabDataState.swift deleted file mode 100644 index e1b436835b..0000000000 --- a/apple/TablePro/Views/Structure/StructureTabDataState.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// StructureTabDataState.swift -// TablePro -// - -import Foundation - -/// Tracks which structure tabs hold data and which of them need re-fetching. -/// Keeping the two apart lets a refresh mark every tab stale without dropping the -/// counts already on screen, so the segmented picker never reflows mid-reload. -struct StructureTabDataState: Equatable { - private var loaded: Set = [] - private var stale: Set = [] - - func hasData(_ tab: StructureTab) -> Bool { - loaded.contains(tab) - } - - func needsFetch(_ tab: StructureTab) -> Bool { - !loaded.contains(tab) || stale.contains(tab) - } - - mutating func markAllStale() { - stale = loaded - } - - mutating func markFetched(_ tab: StructureTab) { - loaded.insert(tab) - stale.remove(tab) - } - - mutating func reset() { - loaded.removeAll() - stale.removeAll() - } - - static func label(for tab: StructureTab, count: Int?) -> String { - guard let count else { return tab.displayName } - return "\(tab.displayName) (\(count))" - } -} diff --git a/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift b/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift index 629952ae49..e9909116b1 100644 --- a/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift +++ b/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift @@ -101,7 +101,7 @@ enum StructureTableRebuildHandler { ) async throws -> StructureRebuildPlanRunner.Prepared { let partition = partition(changes) guard partition.unsupported.isEmpty else { - throw RebuildError.cannotExpress(partition.unsupported.map(\.description)) + throw RebuildError.cannotExpress(partition.unsupported.map(\.localizedDescription)) } let respecification = respecification(from: partition.rebuilt) diff --git a/apple/TablePro/Views/Structure/StructureViewActionHandler.swift b/apple/TablePro/Views/Structure/StructureViewActionHandler.swift deleted file mode 100644 index eadc44ff64..0000000000 --- a/apple/TablePro/Views/Structure/StructureViewActionHandler.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// StructureViewActionHandler.swift -// TablePro -// - -import Foundation - -/// The structure actions that need the grid that is on screen. -/// -/// Saving is deliberately absent. The staged edits belong to the tab's `StructureEditingSession`, -/// and the close prompt offers to save them from tabs that have no mounted structure view at all, -/// so `applyStagedChanges` lives on the session and every caller goes there instead. -@MainActor -final class StructureViewActionHandler { - var previewSQL: (() -> Void)? - var copyRows: (() -> Void)? - var pasteRows: (() -> Void)? - var undo: (() -> Void)? - var redo: (() -> Void)? - var addRow: (() -> Void)? - var removeRow: (() -> Void)? - var refresh: (() -> Void)? -} diff --git a/apple/TablePro/Views/Structure/TableOptionsView.swift b/apple/TablePro/Views/Structure/TableOptionsView.swift deleted file mode 100644 index 2309b09190..0000000000 --- a/apple/TablePro/Views/Structure/TableOptionsView.swift +++ /dev/null @@ -1,281 +0,0 @@ -// -// TableOptionsView.swift -// TablePro -// - -import SwiftUI - -// The Structure tab's Options sub-tab: the table's default character set and collation, and its AUTO_INCREMENT -// counter. Every edit is one whole-value write to the change manager, so the coupled charset and collation pick -// lands as one staged change and one undo step. -internal struct TableOptionsView: View { - @ObservedObject var session: StructureEditingSession - @ObservedObject var changeManager: StructureChangeManager - let isReadOnly: Bool - // The draft AUTO_INCREMENT column's largest value, nil when the table has none. - let autoIncrementMaximum: UInt64? - let onRetry: () -> Void - let onRetryCatalog: () -> Void - - @State private var counterText = "" - @FocusState private var isCounterFocused: Bool - - var body: some View { - Group { - if let options = displayedOptions { - form(options) - } else { - stateView - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { syncCounterText() } - .onChange(of: displayedCounter) { _ in - guard !isCounterFocused else { return } - syncCounterText() - } - .onChange(of: isCounterFocused) { isFocused in - if !isFocused { syncCounterText() } - } - } - - // MARK: - State - - private var draft: TableOptions? { changeManager.tableOptions.current } - - // A read that landed while edits were staged has not become the baseline yet, so it shows read-only. - private var displayedOptions: TableOptions? { - draft ?? (session.tableOptions.value ?? nil) - } - - private var isCounterConfirmed: Bool { - session.autoIncrementRead == .confirmed && changeManager.tableOptions.isAutoIncrementConfirmed - } - - // An unconfirmed counter may be one the server moved past; a read deferred behind staged edits shows read-only. - private var displayedCounter: UInt64? { - guard session.autoIncrementRead == .confirmed else { return nil } - guard changeManager.tableOptions.isAutoIncrementConfirmed else { return session.loadedTableOptions?.autoIncrement } - return displayedOptions?.autoIncrement - } - - private var editability: StructureTableOptionsPolicy.Editability { - StructureTableOptionsPolicy.resolve( - hasBaseline: draft != nil, - isReadOnly: isReadOnly, - isSaving: changeManager.isHeldForSave - ) - } - - private var catalog: TableOptionsCatalog? { - guard case .loaded(let catalog) = session.tableOptionsCatalog else { return nil } - return catalog - } - - @ViewBuilder - private var stateView: some View { - switch session.tableOptions { - case .idle, .loading: - ProgressView() - .controlSize(.small) - case .failed(let message): - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) - .accessibilityHidden(true) - Text("Couldn't read the table options") - RevealedTextView(message) - .foregroundStyle(.secondary) - Button("Try Again", action: onRetry) - .accessibilityIdentifier("structure-options-retry") - } - .padding() - case .loaded: - Text("This table has no options to change.") - .foregroundStyle(.secondary) - } - } - - // MARK: - Form - - private func form(_ options: TableOptions) -> some View { - let editability = self.editability - return Form { - Section { - Picker("Character Set", selection: characterSetBinding) { - if options.characterSet == nil { - Text("Unknown").tag(String?.none) - } - ForEach(TableOptionsForm.characterSets(in: catalog, keeping: options.characterSet), id: \.self) { - Text($0).tag(Optional($0)) - } - } - .disabled(!editability.isEditable || catalog == nil) - .accessibilityIdentifier("structure-options-charset") - - Picker("Collation", selection: collationBinding) { - if options.collation == nil { - Text("Unknown").tag(String?.none) - } - ForEach(collationChoices(options)) { choice in - Text(choice.title).tag(Optional(choice.name)) - } - } - .disabled(!editability.isEditable || catalog == nil) - .accessibilityIdentifier("structure-options-collation") - - Toggle("Convert existing columns", isOn: convertBinding) - .disabled(!editability.isEditable) - .accessibilityIdentifier("structure-options-convert") - - catalogStatusRow - } footer: { - if options.convertsColumns { - Text("Rewrites every text column to this character set. TEXT can grow to MEDIUMTEXT, and the table is copied while it runs.") - } else { - Text("Applies to columns added later. Existing columns keep their character set.") - } - } - - if let maximum = autoIncrementMaximum { - counterSection(maximum: maximum, isEditable: editability.isEditable) - } - } - .formStyle(.grouped) - .safeAreaInset(edge: .bottom, spacing: 0) { - if let reason = editability.unavailableReason, !changeManager.isHeldForSave { - StructureTabNoteView(systemImage: "lock", text: reason, identifier: "structure-options-unavailable-note") - } - } - } - - private func collationChoices(_ options: TableOptions) -> [TableOptionsForm.Choice] { - TableOptionsForm.collations(in: catalog, characterSet: options.characterSet, keeping: options.collation) - } - - @ViewBuilder - private var catalogStatusRow: some View { - switch session.tableOptionsCatalog { - case .idle, .loading: - HStack { - Text("Reading the server's character sets…") - .foregroundStyle(.secondary) - Spacer() - ProgressView() - .controlSize(.small) - } - case .failed: - HStack { - Label("Couldn't read the server's character sets.", systemImage: "exclamationmark.triangle") - .foregroundStyle(.secondary) - Spacer() - Button("Try Again", action: onRetryCatalog) - .accessibilityIdentifier("structure-options-catalog-retry") - } - case .loaded: - EmptyView() - } - } - - private func counterSection(maximum: UInt64, isEditable: Bool) -> some View { - Section { - TextField("Next Auto Increment", text: counterBinding) - .focused($isCounterFocused) - .disabled(!isEditable || !isCounterConfirmed) - .accessibilityIdentifier("structure-options-auto-increment") - // The server raises a value below the next free one, so a saved counter is read back, never shown as typed. - switch session.autoIncrementRead { - case .rereading: - HStack { - Text("Reading the saved value…") - .foregroundStyle(.secondary) - Spacer() - ProgressView() - .controlSize(.small) - } - case .unconfirmed: - HStack { - Label("Couldn't read the saved value.", systemImage: "exclamationmark.triangle") - .foregroundStyle(.secondary) - Spacer() - Button("Try Again", action: onRetry) - .accessibilityIdentifier("structure-options-auto-increment-retry") - } - case .confirmed: - EmptyView() - } - if let message = AutoIncrementEntry( - counterText, maximum: maximum, original: changeManager.tableOptions.original?.autoIncrement - ).message { - Text(message) - .foregroundStyle(.red) - .accessibilityIdentifier("structure-options-auto-increment-error") - } - } footer: { - Text("A value at or below the highest existing ID becomes that ID plus one.") - } - } - - // MARK: - Bindings - - private var characterSetBinding: Binding { - Binding( - get: { displayedOptions?.characterSet }, - set: { name in - guard let name, let current = draft, name != current.characterSet else { return } - changeManager.stageTableOptions(TableOptionsForm.selectingCharacterSet(name, in: current, catalog: catalog)) - } - ) - } - - private var collationBinding: Binding { - Binding( - get: { displayedOptions?.collation }, - set: { name in - guard let name, var next = draft, name != next.collation else { return } - next.collation = name - changeManager.stageTableOptions(next) - } - ) - } - - private var convertBinding: Binding { - Binding( - get: { displayedOptions?.convertsColumns ?? false }, - set: { converts in - guard var next = draft, converts != next.convertsColumns else { return } - next.convertsColumns = converts - changeManager.stageTableOptions(next) - } - ) - } - - // Staged per keystroke, as the comment is, so a save from the keyboard never misses a value still in the field. - // Text that is not a valid counter stages the loaded one and holds Save until it is fixed or cleared. - private var counterBinding: Binding { - Binding( - get: { counterText }, - set: { text in - counterText = text - guard var next = draft, let maximum = autoIncrementMaximum else { return } - let entry = AutoIncrementEntry( - text, maximum: maximum, original: changeManager.tableOptions.original?.autoIncrement - ) - switch entry { - case .value(let value): - next.autoIncrement = value - case .empty, .invalid: - next.autoIncrement = changeManager.tableOptions.original?.autoIncrement - } - changeManager.noteAutoIncrementEntry(invalid: entry.message) - guard next != draft else { return } - changeManager.stageTableOptions(next) - } - ) - } - - private func syncCounterText() { - counterText = displayedCounter.map { String($0) } ?? "" - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/apple/TablePro/Views/Structure/TableStructureView+DataLoading.swift deleted file mode 100644 index d41c293577..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ /dev/null @@ -1,259 +0,0 @@ -// -// TableStructureView+DataLoading.swift -// TablePro -// - -import AppKit -import Combine -import os -import SwiftUI -import TableProPluginKit -import UniformTypeIdentifiers - -// MARK: - Data Loading - -extension TableStructureView { - @Sendable - func loadInitialData() async { - session.settleOwedRefetch() - guard !session.hasLoaded else { - isInitialLoading = false - isLoading = false - await session.reloadConcurrentRefreshAvailability() - await loadTableOptionsCatalogIfNeeded() - return - } - await loadColumns() - for tab in session.tabsFetchedOnMount where tab != .columns { - guard !Task.isCancelled else { break } - await loadTabDataIfNeeded(tab) - } - // A workspace switch cancels this task and restarts it on return, so a cut-short load must not count. - guard !Task.isCancelled else { return } - await loadTableComment() - guard !Task.isCancelled else { return } - await session.readTableOptions() - guard !Task.isCancelled else { return } - adoptFetchedBaseline() - session.hasLoaded = true - isInitialLoading = false - // After the baseline, so the columns are editable while the catalog is read. - await loadTableOptionsCatalogIfNeeded() - } - - func loadColumns() async { - isLoading = true - errorMessage = nil - - do { - columns = try await structureLoader.columns() - tabData.markFetched(.columns) - } catch { - if let message = fetchFailure(error).message { - errorMessage = message - } - } - - isLoading = false - } - - func loadTabDataIfNeeded(_ tab: StructureTab) async { - guard tabData.needsFetch(tab) else { return } - await fetchTabData(tab) - } - - func fetchTabData(_ tab: StructureTab) async { - do { - switch tab { - case .columns: - columns = try await structureLoader.columns() - case .indexes: - indexes = try await structureLoader.indexes() - await session.reloadConcurrentRefreshAvailability() - case .foreignKeys: - foreignKeys = try await structureLoader.foreignKeys() - case .checkConstraints: - checkConstraints = try await structureLoader.checkConstraints() - case .ddl: - let table = tableName - let schema = structureLoader.scope.schema - ddlStatement = try await structureLoader.perform { driver in - try await TableDDLComposer.fetchDDL( - for: table, using: driver, includesDependencies: true, schema: schema - ) - } - case .triggers: - do { - triggers = try await structureLoader.triggers() - } catch { - guard fetchFailure(error) != .cancelled else { return } - Self.logger.error("Failed to load triggers: \(error.publicLogShape, privacy: .public)") - triggers = [] - } - case .parts, .options: - return - } - tabData.markFetched(tab) - } catch { - guard let message = fetchFailure(error).message else { return } - Self.logger.error("Failed to load \(tab.rawValue, privacy: .public): \(error.publicLogShape, privacy: .public)") - errorMessage = message - } - } - - private func fetchFailure(_ error: any Error) -> StructureFetchFailure { - StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) - } - - func loadSchemaForEditing() { - session.adoptLoadedBaseline() - } - - // MARK: - Lifecycle Callbacks - - func onSelectedTabChanged(_ new: StructureTab) { - searchText = "" - structureSortDescriptor = nil - sortState = SortState() - gridAdapter.refresh() - Task { - await loadTabDataIfNeeded(new) - } - } - - func adoptFetchedBaseline() { - guard !structureChangeManager.hasChanges else { - session.markStructureStale() - return - } - loadSchemaForEditing() - } - - func rebaselineUnlessEdited() { - guard !isReloadingAfterSave, !isInitialLoading else { return } - adoptFetchedBaseline() - } - - func onColumnsChanged() { - rebaselineUnlessEdited() - } - - func onIndexesChanged() { - rebaselineUnlessEdited() - } - - func onCheckConstraintsChanged() { - rebaselineUnlessEdited() - } - - func onForeignKeysChanged() { - rebaselineUnlessEdited() - } - - func onRefreshData() { - guard !isReloadingAfterSave else { - Self.logger.debug("Ignoring refresh notification - currently reloading after save") - return - } - - let justSaved = lastSaveTime.map { Date().timeIntervalSince($0) < 2.0 } ?? false - - if structureChangeManager.hasChanges && !justSaved { - Task { @MainActor in - let window = coordinator?.contentWindow - let confirmed = await AlertHelper.confirmDestructive( - title: String(localized: "Discard Changes?"), - message: String(localized: "You have unsaved changes to the table structure. Refreshing will discard these changes."), - confirmButton: String(localized: "Discard"), - cancelButton: String(localized: "Cancel"), - window: window - ) - - if confirmed { - discardChanges() - await reloadAllTabs() - } - } - } else { - Task { @MainActor in - await reloadAllTabs() - } - } - } - - private func reloadAllTabs() async { - session.markEveryTabStale() - gridAdapter.referenceMenus.invalidateTableLists() - partsReloadToken += 1 - await reloadCoreTabs() - switch selectedTab { - case .ddl: - await fetchTabData(.ddl) - case .triggers where connection.type.supportsTriggers: - await fetchTabData(.triggers) - case .columns, .indexes, .foreignKeys, .checkConstraints, .triggers, .options, .parts: - break - } - } - - func reloadCoreTabs() async { - isLoading = true - errorMessage = nil - defer { isLoading = false } - - let includesForeignKeys = connection.type.supportsForeignKeys - let offersTableOptions = session.offersTableOptionsTab - do { - let reloaded = try await structureLoader.coreTabs( - includingForeignKeys: includesForeignKeys, - includingTableComment: offersTableComment, - includingTableOptions: offersTableOptions - ) - - if let comment = reloaded.tableComment { - session.settleTableComment(comment) - } - // A save never adopts the counter it wrote: the server raises a low value, so this read is what shows. - if let options = reloaded.tableOptions { - session.settleTableOptions(options) - } - columns = reloaded.columns - indexes = reloaded.indexes - tabData.markFetched(.columns) - tabData.markFetched(.indexes) - if includesForeignKeys { - foreignKeys = reloaded.foreignKeys - tabData.markFetched(.foreignKeys) - } - await session.reloadConcurrentRefreshAvailability() - } catch { - // A counter re-read the save started ends unconfirmed, keeping the options already on screen. - if offersTableOptions { - session.settleTableOptions(.cancelled) - } - guard let message = fetchFailure(error).message else { return } - Self.logger.error("Failed to reload structure: \(error.publicLogShape, privacy: .public)") - errorMessage = message - } - } -} - -internal enum StructureFetchFailure: Equatable { - case cancelled - case failed(String) - - internal init(_ error: any Error, taskIsCancelled: Bool) { - if error is CancellationError || taskIsCancelled { - self = .cancelled - } else { - self = .failed(error.localizedDescription) - } - } - - internal var message: String? { - switch self { - case .cancelled: nil - case .failed(let message): message - } - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+EditGate.swift b/apple/TablePro/Views/Structure/TableStructureView+EditGate.swift deleted file mode 100644 index 92fdacfe69..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+EditGate.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// TableStructureView+EditGate.swift -// TablePro -// - -import Foundation -import TableProPluginKit - -extension TableStructureView { - var editGate: StructureEditGate { - StructureEditGate( - databaseType: connection.type, objectKind: objectKind, schema: schemaName, isSessionBound: isSessionBound - ) - } - - var foreignKeyEditAvailability: ForeignKeyEditAvailability { - ForeignKeyEditPolicy.resolve( - support: PluginManager.shared.foreignKeyEditSupport(for: connection.type), - engineName: connection.type.displayName, - kindRefusal: editGate.kindRefusal(.addForeignKey), - canEditSchema: editGate.canEditSchema - ) - } - - func publishFooterCapability() { - let gate = editGate - let footer = StructureFooterPolicy.resolve( - tab: selectedTab, - canEditSchema: gate.canEditSchema, - hasSelection: !session.gridModel.selection.isEmpty, - isSaving: structureChangeManager.isHeldForSave, - resolve: { gate.resolve($0) } - ) - // Published on every write, and this runs on every selection change. - guard session.footer != footer else { return } - session.footer = footer - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+Schema.swift b/apple/TablePro/Views/Structure/TableStructureView+Schema.swift deleted file mode 100644 index e76b921e7e..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+Schema.swift +++ /dev/null @@ -1,187 +0,0 @@ -// -// TableStructureView+Schema.swift -// TablePro -// - -import AppKit -import Combine -import os -import SwiftUI -import TableProPluginKit -import UniformTypeIdentifiers - -// MARK: - Schema Operations - -extension TableStructureView { - func generateStructurePreviewSQL() { - let changes = structureChangeManager.getChangesArray() - guard !changes.isEmpty else { - // An undo back to a clean state would otherwise leave the last preview's SQL behind. - toolbarState.previewStatements = [] - return - } - - if skipSchemaPreview { - Task { _ = await session.applyStagedChanges(coordinator: coordinator) } - return - } - - guard let coordinator else { return } - Task { await session.previewStagedChanges(coordinator: coordinator) } - } - - func refreshAfterApply() async { - isReloadingAfterSave = true - await reloadCoreTabs() - adoptFetchedBaseline() - await loadTabDataIfNeeded(selectedTab) - - gridAdapter.refresh() - - // Left false, the next mount re-baselines the change manager and drops anything staged since. - session.hasLoaded = true - isReloadingAfterSave = false - } - - func discardChanges() { - session.gridModel.discardEditing() - structureChangeManager.discardChanges() - gridAdapter.refresh() - } - - // MARK: - DDL View - - var ddlView: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - HStack(spacing: 4) { - Button(action: { ddlFontSize = max(10, ddlFontSize - 1) }) { - Image(systemName: "textformat.size.smaller") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Decrease font size")) - Text("\(Int(ddlFontSize))") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 24) - Button(action: { ddlFontSize = min(24, ddlFontSize + 1) }) { - Image(systemName: "textformat.size.larger") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Increase font size")) - } - .buttonStyle(.borderless) - - Spacer() - - if showCopyConfirmation { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Copied!") - } - .transition(.opacity) - } - - Button(action: openInEditor) { - Label("Open in Editor", systemImage: "square.and.pencil") - } - .buttonStyle(.bordered) - .disabled(ddlStatement.isEmpty) - - Button(action: copyDDL) { - Label("Copy", systemImage: "doc.on.doc") - } - .buttonStyle(.bordered) - - Button(action: exportDDL) { - Label("Export", systemImage: "square.and.arrow.up") - } - .buttonStyle(.bordered) - } - .padding() - .paneBarBackground() - - Divider() - - if ddlStatement.isEmpty { - StructureDDLUnavailableView() - } else { - DDLTextView(ddl: ddlStatement, fontSize: $ddlFontSize, databaseType: connection.type) - } - } - } - - // MARK: - DDL Actions - - private func openInEditor() { - guard !ddlStatement.isEmpty else { return } - coordinator?.tabManager.addTab( - initialQuery: ddlStatement, - title: "\(tableName) DDL" - ) - } - - func openTriggerInEditor(_ trigger: TriggerInfo) { - guard !trigger.statement.isEmpty else { return } - coordinator?.tabManager.addTab( - initialQuery: trigger.statement, - title: trigger.name - ) - } - - private func copyDDL() { - ClipboardService.shared.writeText(ddlStatement) - - withMotion { - showCopyConfirmation = true - } - - copyResetTask?.cancel() - copyResetTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(1_500)) - guard !Task.isCancelled else { return } - withMotion { - showCopyConfirmation = false - } - } - } - - private func exportDDL() { - let savePanel = NSSavePanel() - if let sqlType = UTType(filenameExtension: "sql") { - savePanel.allowedContentTypes = [sqlType] - } - savePanel.nameFieldStringValue = "\(tableName).sql" - - guard let window = coordinator?.contentWindow else { return } - savePanel.beginSheetModal(for: window) { response in - guard response == .OK, let url = savePanel.url else { return } - do { - try ddlStatement.write(to: url, atomically: true, encoding: .utf8) - } catch { - Self.logger.error("Failed to export: \(error.publicLogShape, privacy: .public)") - AlertHelper.showErrorSheet( - title: String(localized: "Could not export the schema"), - message: error.localizedDescription, - window: window - ) - } - } - } -} - -internal struct StructureDDLUnavailableView: View { - internal var body: some View { - VStack(spacing: 8) { - Image(systemName: "tray") - .font(.largeTitle) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text(String(localized: "No DDL available")) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .valueStandInBackground() - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+TableComment.swift b/apple/TablePro/Views/Structure/TableStructureView+TableComment.swift deleted file mode 100644 index 0a1fb9e5de..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+TableComment.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// TableStructureView+TableComment.swift -// TablePro -// - -import AppKit -import os -import SwiftUI - -internal extension TableStructureView { - var tableCommentSupport: DatabaseObjectToolEligibility.Support { - DatabaseObjectToolEligibility.Support.of(DatabaseManager.shared.driver(for: connection.id)) - } - - var offersTableComment: Bool { - tableCommentSupport.commentableTypes.contains(objectKind) - } - - var tableCommentField: StructureTableCommentPolicy.Field? { - StructureTableCommentPolicy.resolve( - objectKind: objectKind, - support: tableCommentSupport, - isReadOnly: toolbarState.safeModeLevel.blocksAllWrites || isSessionBound, - load: session.tableComment.erased, - isSaving: structureChangeManager.isHeldForSave - ) - } - - /// One model write per keystroke, so the staged change and its undo run never see a stale value. - private var tableCommentBinding: Binding { - Binding( - get: { structureChangeManager.tableComment.text }, - set: { structureChangeManager.stageTableComment($0) } - ) - } - - @ViewBuilder - var tableCommentBar: some View { - if let field = tableCommentField { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text("Comment:") - .font(.body.weight(.medium)) - - TextField(field.placeholder, text: tableCommentBinding, axis: .vertical) - .lineLimit(1...4) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - .disabled(!field.isEditable) - .focused($isTableCommentFocused) - .onSubmit { structureChangeManager.endTableCommentRun() } - .help(field.unavailableReason ?? "") - .accessibilityLabel(String(localized: "Comment")) - .accessibilityIdentifier("structure-table-comment") - } - .padding() - .paneBarBackground() - - Divider() - } - } - - func loadTableComment() async { - guard offersTableComment else { return } - session.beginTableCommentLoad() - let outcome: MetadataFetchOutcome - do { - outcome = .fetched(try await structureLoader.tableComment()) - } catch { - switch StructureFetchFailure(error, taskIsCancelled: Task.isCancelled) { - case .cancelled: - outcome = .cancelled - case .failed(let message): - Self.logger.error("Failed to load the table comment: \(error.publicLogShape, privacy: .public)") - outcome = .failed(message) - } - } - session.settleTableComment(outcome) - } - - /// A comment changed elsewhere refetches columns identical to the ones loaded, so the columns' - /// own handler never fires and the baseline has to move here. - func onTableCommentChanged() { - rebaselineUnlessEdited() - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView+TableOptions.swift b/apple/TablePro/Views/Structure/TableStructureView+TableOptions.swift deleted file mode 100644 index c79144bf01..0000000000 --- a/apple/TablePro/Views/Structure/TableStructureView+TableOptions.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// TableStructureView+TableOptions.swift -// TablePro -// - -import SwiftUI - -internal extension TableStructureView { - var tableOptionsView: some View { - TableOptionsView( - session: session, - changeManager: structureChangeManager, - isReadOnly: toolbarState.safeModeLevel.blocksAllWrites || isSessionBound, - autoIncrementMaximum: autoIncrementMaximum, - onRetry: { Task { await session.readTableOptions() } }, - onRetryCatalog: { Task { await session.readTableOptionsCatalog() } } - ) - .task { await session.readTableOptionsCatalog() } - } - - // The draft's column, not the loaded one: the counter is set after the column work in the same save. - private var autoIncrementMaximum: UInt64? { - let columns = structureChangeManager.workingColumns - let index = columns.indices.first { index in - columns[index].autoIncrement - && !structureChangeManager.deleteInsertState(for: index, tab: .columns).isDeleted - } - return index.map { AutoIncrementEntry.maximum(for: columns[$0]) } - } - - func loadTableOptionsCatalogIfNeeded() async { - guard session.needsTableOptionsCatalog else { return } - await session.readTableOptionsCatalog() - } - - // Like the comment: options changed elsewhere refetch columns identical to the loaded ones. - func onTableOptionsChanged() { - rebaselineUnlessEdited() - } - - // A retried read can return the options already loaded, which moves only this. - func onAutoIncrementReadChanged(_ read: AutoIncrementReadState) { - guard read == .confirmed else { return } - rebaselineUnlessEdited() - } -} diff --git a/apple/TablePro/Views/Structure/TableStructureView.swift b/apple/TablePro/Views/Structure/TableStructureView.swift index c169d8763e..3ca0325335 100644 --- a/apple/TablePro/Views/Structure/TableStructureView.swift +++ b/apple/TablePro/Views/Structure/TableStructureView.swift @@ -3,252 +3,61 @@ // TablePro // -import AppKit -import Combine -import os import SwiftUI -import TableProPluginKit -import UniformTypeIdentifiers +// The Structure view of a table tab: Definition, Triggers, DDL and, on ClickHouse, Parts. It draws the session and +// hands it the window's state; loading, staging and saving live in the session. struct TableStructureView: View { - static let logger = Logger(subsystem: "com.TablePro", category: "TableStructureView") - static let structurePasteboardType = NSPasteboard.PasteboardType("com.TablePro.structure") - static let structureSourceTypePasteboardType = NSPasteboard.PasteboardType("com.TablePro.structure.database-type") - - static var canPasteStructureRows: Bool { - NSPasteboard.general.data(forType: structurePasteboardType) != nil - } - let tableName: String - let connection: DatabaseConnection - let databaseName: String - let schemaName: String? - + @ObservedObject var session: StructureEditingSession @ObservedObject var toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator? - @Environment(\.appServices) var services - - var scope: DatabaseScope { - DatabaseScope( - connectionId: connection.id, database: databaseName, schema: schemaName, isSessionBound: isSessionBound - ) - } - - var isSessionBound: Bool { session.isSessionBound } - - var structureLoader: TableStructureLoader { - TableStructureLoader(scope: scope, tableName: tableName) - } - - @ObservedObject var session: StructureEditingSession - - var objectKind: TableInfo.TableType { session.objectKind } - - var selectedTab: StructureTab { - get { session.selectedTab } - nonmutating set { session.selectedTab = newValue } - } - - var searchText: String { - get { session.searchText } - nonmutating set { session.searchText = newValue } - } - - var sortState: SortState { - get { session.sortState } - nonmutating set { session.sortState = newValue } - } - - var structureSortDescriptor: StructureSortDescriptor? { - get { session.sortDescriptor } - nonmutating set { session.sortDescriptor = newValue } - } - - var isReloadingAfterSave: Bool { - get { session.isApplying } - nonmutating set { session.isApplying = newValue } - } - - var lastSaveTime: Date? { - get { session.lastAppliedAt } - nonmutating set { session.lastAppliedAt = newValue } - } - - var gridAdapter: StructureGridAdapter { session.gridAdapter } - - var columns: [ColumnInfo] { - get { session.columns } - nonmutating set { session.columns = newValue } - } - - var indexes: [IndexInfo] { - get { session.indexes } - nonmutating set { session.indexes = newValue } - } - - var foreignKeys: [ForeignKeyInfo] { - get { session.foreignKeys } - nonmutating set { session.foreignKeys = newValue } - } - - var checkConstraints: [CheckConstraintInfo] { - get { session.checkConstraints } - nonmutating set { session.checkConstraints = newValue } - } - - var triggers: [TriggerInfo] { - get { session.triggers } - nonmutating set { session.triggers = newValue } - } - - var ddlStatement: String { - get { session.ddlStatement } - nonmutating set { session.ddlStatement = newValue } - } - - var tabData: StructureTabDataState { - get { session.tabData } - nonmutating set { session.tabData = newValue } - } - - // Observed on its own: a change inside it fires its publisher, never the session's. - @ObservedObject var structureChangeManager: StructureChangeManager - - @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) var ddlFontSize: Double = 13 - @State var showCopyConfirmation = false - @State var copyResetTask: Task? - @State var isLoading = true - @State var isInitialLoading = true - @State var errorMessage: String? - @State var partsReloadToken = 0 - @AppStorage("skipSchemaPreview", store: AppStorageEnvironment.shared.defaults) var skipSchemaPreview = false - - @State var actionHandler = StructureViewActionHandler() - @FocusState var isTableCommentFocused: Bool - - init( - tableName: String, - connection: DatabaseConnection, - databaseName: String, - schemaName: String?, - toolbarState: ConnectionToolbarState, - coordinator: MainContentCoordinator?, - session: StructureEditingSession - ) { - self.tableName = tableName - self.connection = connection - self.databaseName = databaseName - self.schemaName = schemaName - self.toolbarState = toolbarState - self.coordinator = coordinator - self.session = session - self.structureChangeManager = session.changeManager - } - var body: some View { VStack(spacing: 0) { - tableCommentBar - tabBar + paneBar Divider() - contentArea + paneContent .frame(maxWidth: .infinity, maxHeight: .infinity) } - .task(loadInitialData) - .onReceive(session.gridModel.$selectionRevision) { _ in - publishFooterCapability() - } - .onChange(of: selectedTab) { newValue in - onSelectedTabChanged(newValue) - publishFooterCapability() - } - .onChange(of: columns) { _ in onColumnsChanged() } - .onChange(of: indexes) { _ in onIndexesChanged() } - .onChange(of: foreignKeys) { _ in onForeignKeysChanged() } - .onChange(of: checkConstraints) { _ in onCheckConstraintsChanged() } - .onChange(of: session.tableComment) { _ in onTableCommentChanged() } - .onChange(of: session.tableOptions) { _ in onTableOptionsChanged() } - .onChange(of: session.autoIncrementRead) { read in onAutoIncrementReadChanged(read) } - .onChange(of: session.tableOptionsCatalog) { _ in gridAdapter.characterSetCatalogDidChange() } - .onChange(of: isTableCommentFocused) { isFocused in - if !isFocused { structureChangeManager.endTableCommentRun() } + .task { await session.load() } + .onChange(of: session.pane) { pane in + Task { await session.loadPaneIfNeeded(pane) } } - .onChange(of: searchText) { _ in gridAdapter.refresh() } - .onChange(of: session.serverSupport) { _ in gridAdapter.refresh() } .onAppear { - coordinator?.toolbarState.hasStructureChanges = structureChangeManager.hasChanges - coordinator?.inspectorRowSource = gridAdapter - - gridAdapter.coordinator = coordinator - gridAdapter.moveRowHandler = { [self] fromIndex, toIndex in - beginColumnReorder(fromIndex: fromIndex, toIndex: toIndex) - } - gridAdapter.refresh() - - actionHandler.previewSQL = { self.generateStructurePreviewSQL() } - actionHandler.copyRows = { self.gridAdapter.copyRows(self.session.gridModel.selectedRowIDs) } - actionHandler.pasteRows = { self.gridAdapter.pasteRows() } - actionHandler.undo = { self.gridAdapter.undo() } - actionHandler.redo = { self.gridAdapter.redo() } - actionHandler.addRow = { self.gridAdapter.addRow() } - actionHandler.removeRow = { self.gridAdapter.deleteRows(self.session.gridModel.selectedRowIDs) } - actionHandler.refresh = { self.onRefreshData() } - coordinator?.structureActions = actionHandler - publishFooterCapability() - } - .onDisappear { - structureChangeManager.endTableCommentRun() - // Guarded by identity: SwiftUI does not order the outgoing view's onDisappear before the - // incoming one's onAppear, so an unguarded clear can nil the wiring another tab installed. - if coordinator?.structureActions === actionHandler { - coordinator?.structureActions = nil - coordinator?.toolbarState.hasStructureChanges = false - } - if coordinator?.inspectorRowSource === gridAdapter { - coordinator?.inspectorRowSource = nil - } + session.coordinator = coordinator + syncReadOnly() } - .onChange(of: structureChangeManager.hasChanges) { newValue in - coordinator?.toolbarState.hasStructureChanges = newValue - gridAdapter.refresh() - if !newValue, session.settleOwedRefetch() { - Task { await loadInitialData() } + .onChange(of: session.hasChanges) { hasChanges in + if !hasChanges, session.settleOwedRefetch() { + Task { await session.load() } } } - .onChange(of: structureChangeManager.isHeldForSave) { _ in - publishFooterCapability() - gridAdapter.refresh() - } - .onChange(of: session.appliedVersion) { _ in - Task { await refreshAfterApply() } - } - // A mutation that leaves hasChanges as it was, such as a second added row, moves only this. - .onChange(of: structureChangeManager.reloadVersion) { _ in - gridAdapter.refresh() - } + .onChange(of: toolbarState.safeModeLevel) { _ in syncReadOnly() } } - // MARK: - Tabs - - private var availableTabs: [StructureTab] { - session.availableTabs + private func syncReadOnly() { + let blocksWrites = toolbarState.safeModeLevel.blocksAllWrites + session.editor.readOnlyReason = blocksWrites + ? String(localized: "Safe Mode is read-only for this connection.") + : nil + session.editor.isReadOnly = blocksWrites } - private var tabBar: some View { + private var paneBar: some View { PaneTabBar( title: String(localized: "Structure"), - selection: $session.selectedTab.committingEdit(of: session.gridModel), + selection: $session.pane, identifier: "structure-tab-picker" ) { - ForEach(availableTabs, id: \.self) { tab in - Text(tabLabel(for: tab)).tag(tab) + ForEach(session.availablePanes, id: \.self) { pane in + Text(pane.displayName).tag(pane) } } - .monospacedDigit() .overlay(alignment: .trailing) { - if structureChangeManager.isHeldForSave { + if session.changeManager.isHeldForSave { HStack(spacing: 6) { ProgressView().controlSize(.small) - Text("Saving Changes…") + Text(String(localized: "Saving Changes…")) .font(.callout) .foregroundStyle(.secondary) } @@ -259,182 +68,67 @@ struct TableStructureView: View { } } - // MARK: - Tab Label with Count Badge - - private func tabLabel(for tab: StructureTab) -> String { - StructureTabDataState.label(for: tab, count: loadedCount(for: tab)) - } - - private func loadedCount(for tab: StructureTab) -> Int? { - guard tabData.hasData(tab) else { return nil } - switch tab { - case .columns: return columns.count - case .indexes: return indexes.count - case .foreignKeys: return foreignKeys.count - case .triggers: return triggers.count - case .checkConstraints: return checkConstraints.count - case .options, .ddl, .parts: return nil - } - } - - // MARK: - Content Area - - @ViewBuilder - private var contentArea: some View { - if let error = errorMessage { - errorView(error) - .gridStandInBackground() - } else { - tabContent - } - } - @ViewBuilder - private var tabContent: some View { - switch selectedTab { - case .columns: - structureGrid - case .indexes: - Group { - if shouldShowIndexesEmptyState { - EmptyStateView.indexes { gridAdapter.addRow() } - .gridStandInBackground() - } else { - structureGrid - } - } - .safeAreaInset(edge: .bottom, spacing: 0) { - indexesTabNotes - } - case .foreignKeys: - if shouldShowForeignKeysEmptyState { - EmptyStateView.foreignKeys { gridAdapter.addRow() } - .gridStandInBackground() - } else { - structureGrid - } - case .checkConstraints: - if shouldShowCheckConstraintsEmptyState { - EmptyStateView.checkConstraints { gridAdapter.addRow() } - .gridStandInBackground() - } else { - structureGrid - } + private var paneContent: some View { + switch session.pane { + case .definition: + StructureDefinitionPage( + editor: session.editor, + loadState: session.loadState, + isLoading: session.isLoadingFirstTime, + notes: [.indexes: indexNotes], + onRetry: { session.retry(StructureLoadState.Item($0)) } + ) case .triggers: TriggerDetailView( - triggers: triggers, - scope: scope, - connection: connection, - tableName: tableName, - isLoading: !tabData.hasData(.triggers), - canEdit: editGate.allowsTriggerEditing, + triggers: session.triggers, + scope: session.scope, + connection: session.connection, + tableName: session.tableName, + isLoading: !session.loadState.hasData(.triggers), + failure: session.loadState.failure(.triggers), + onRetry: { session.retry(.triggers) }, + canEdit: session.editor.context.editGate.allowsTriggerEditing, onOpenInEditor: openTriggerInEditor ) - case .options: - tableOptionsView case .ddl: - ddlView + StructureDDLPane( + ddl: session.ddlStatement, + tableName: session.tableName, + databaseType: session.connection.type, + failure: session.loadState.failure(.ddl), + isLoading: !session.loadState.hasData(.ddl), + coordinator: coordinator, + onRetry: { session.retry(.ddl) } + ) case .parts: ClickHousePartsView( - tableName: tableName, - scope: scope, - connection: connection, - reloadToken: partsReloadToken + tableName: session.tableName, + scope: session.scope, + connection: session.connection, + reloadToken: session.partsReloadToken ) } } - private var indexesTabNotes: some View { - VStack(spacing: 0) { - if let note = InvalidIndexNote(indexes: indexes) { - StructureTabNoteView( - systemImage: note.systemImage, - text: note.text, - identifier: "structure-invalid-index-note" - ) - } - if objectKind == .materializedView, - let note = MaterializedViewConcurrentRefreshNote(state: session.concurrentRefresh) { - StructureTabNoteView( - systemImage: note.systemImage, - text: note.text, - identifier: "structure-concurrent-refresh-note" - ) - } + private var indexNotes: [StructureSectionNote] { + var notes: [StructureSectionNote] = [] + if let note = InvalidIndexNote(indexes: session.indexes) { + notes.append(StructureSectionNote( + systemImage: note.systemImage, text: note.text, identifier: "structure-invalid-index-note" + )) } - } - - private var shouldShowIndexesEmptyState: Bool { - tabData.hasData(.indexes) - && structureChangeManager.workingIndexes.isEmpty - && editGate.allows(.addIndex) - } - - private var shouldShowForeignKeysEmptyState: Bool { - tabData.hasData(.foreignKeys) - && structureChangeManager.workingForeignKeys.isEmpty - && connection.type.supportsForeignKeys - && editGate.allows(.addForeignKey) - } - - private var shouldShowCheckConstraintsEmptyState: Bool { - tabData.hasData(.checkConstraints) - && structureChangeManager.workingCheckConstraints.isEmpty - && editGate.allows(.addCheckConstraint) - } - - // MARK: - Structure Grid - - private var structureGrid: some View { - DataGridView(model: session.gridModel) - .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: 0) { - NativeSearchField(text: $session.searchText, placeholder: String(localized: "Filter")) - .padding(.horizontal, 6) - .padding(.vertical, 4) - Divider() - } - } - } - - // MARK: - Helper Views - - func errorView(_ message: String) -> some View { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) - .accessibilityHidden(true) - RevealedTextView(message) - .foregroundStyle(.secondary) + if session.objectKind == .materializedView, + let note = MaterializedViewConcurrentRefreshNote(state: session.concurrentRefresh) { + notes.append(StructureSectionNote( + systemImage: note.systemImage, text: note.text, identifier: "structure-concurrent-refresh-note" + )) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + return notes } -} -#Preview { - let connection = DatabaseConnection( - name: "Test", - host: "localhost", - port: 3_306, - database: "test", - username: "root", - type: .mysql - ) - return TableStructureView( - tableName: "users", - connection: connection, - databaseName: "test", - schemaName: nil, - toolbarState: ConnectionToolbarState(), - coordinator: nil, - session: StructureEditingSession( - identity: "test.users", - connection: connection, - databaseName: "test", - schemaName: nil, - tableName: "users" - ) - ) - .frame(width: 800, height: 600) + private func openTriggerInEditor(_ trigger: TriggerInfo) { + guard !trigger.statement.isEmpty else { return } + coordinator?.tabManager.addTab(initialQuery: trigger.statement, title: trigger.name) + } } diff --git a/apple/TablePro/Views/Structure/TriggerDetailView.swift b/apple/TablePro/Views/Structure/TriggerDetailView.swift index 7820902422..fb869f481a 100644 --- a/apple/TablePro/Views/Structure/TriggerDetailView.swift +++ b/apple/TablePro/Views/Structure/TriggerDetailView.swift @@ -108,6 +108,8 @@ struct TriggerDetailView: View { let connection: DatabaseConnection let tableName: String let isLoading: Bool + var failure: String? + var onRetry: () -> Void = {} let canEdit: Bool let onOpenInEditor: (TriggerInfo) -> Void @@ -117,7 +119,17 @@ struct TriggerDetailView: View { @State private var actionError: String? var body: some View { - if isLoading { + if let failure, triggers.isEmpty { + UnavailableStateView { + Label(String(localized: "Couldn't Read the Triggers"), systemImage: "exclamationmark.triangle") + } description: { + Text(failure) + } actions: { + Button(String(localized: "Try Again"), action: onRetry) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .gridStandInBackground() + } else if isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .gridStandInBackground() @@ -279,7 +291,7 @@ internal struct TriggerListPane: View { @ObservedObject internal var state: TriggerInspectorState @StateObject private var grid = StructureListGridModel( - accessibilityLabel: StructureTab.triggers.displayName + accessibilityLabel: StructurePane.triggers.displayName ) private var showsEnabled: Bool { triggers.contains { $0.enabled != nil } } diff --git a/apple/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/apple/TableProTests/Core/Database/TriggerInfoMappingTests.swift index 7aeb16248d..c47fc806ea 100644 --- a/apple/TableProTests/Core/Database/TriggerInfoMappingTests.swift +++ b/apple/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -100,15 +100,15 @@ struct TriggerInfoMappingTests { } } -struct StructureTabTriggersTests { - @Test("Triggers tab is part of the canonical tab set") +struct StructurePaneTriggersTests { + @Test("Triggers is one of the Structure view's panes") func triggersInAllCases() { - #expect(StructureTab.allCases.contains(.triggers)) + #expect(StructurePane.allCases.contains(.triggers)) } - @Test("Triggers tab has a localized display name") + @Test("The Triggers pane has a localized display name") func triggersDisplayName() { - #expect(StructureTab.triggers.displayName == "Triggers") + #expect(StructurePane.triggers.displayName == "Triggers") } } @@ -221,13 +221,8 @@ struct TriggerApplyExecutionTests { #expect(stub.executedQueries == ["DROP TRIGGER t", "CREATE TRIGGER t", "RESTORE t"]) } - /// The session driver holds whatever transaction a query tab left open, so a trigger edit - /// with a BEGIN of its own ran inside it and committed or rolled back the tab's work. - /// - /// The gate is supplied rather than taken from `ExecutionGateProvider`. A drop is a - /// `.destructiveQuery`, which the real gate confirms with an `NSAlert`, and an alert raised - /// with no window runs application-modal: on a CI runner nobody answers it, so the whole - /// unit job stops there and is killed by its timeout rather than failing. + // A trigger edit's own BEGIN ran inside the transaction a query tab left open on the session driver. + // The gate is injected: the real one confirms a drop with an NSAlert nobody answers on CI. @Test("Apply and drop run on the pooled connection and leave the session driver alone") func applyAndDropRunOnThePooledConnection() async throws { let connection = TestFixtures.makeConnection(database: "app", type: .postgresql) @@ -241,12 +236,7 @@ struct TriggerApplyExecutionTests { let scope = DatabaseScope(connectionId: connection.id, database: "app", schema: "public") let pooledStub = StubTriggerDriver() pooledStub.transactionalDDL = true - /// The drop statement is built on the driver that will run it, because an engine that - /// qualifies its DDL with the connection's current database would otherwise write the - /// session's database into a statement the pooled connection runs somewhere else. Both - /// stubs can produce it, so the assertions below are what say which one was asked: a - /// regression back to the session driver shows up as a query it executed rather than as a - /// statement nobody could generate. + // Both stubs build the drop, so only the queries each one executed show which driver was asked. pooledStub.dropToReturn = "DROP TRIGGER t" let pooledAdapter = PluginDriverAdapter(connection: connection, pluginDriver: pooledStub) try await pooledAdapter.connect() diff --git a/apple/TableProTests/Core/SchemaTracking/CreateTableCommentCompositionTests.swift b/apple/TableProTests/Core/SchemaTracking/CreateTableCommentCompositionTests.swift index 48c482d890..cc0b03a075 100644 --- a/apple/TableProTests/Core/SchemaTracking/CreateTableCommentCompositionTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/CreateTableCommentCompositionTests.swift @@ -88,7 +88,7 @@ struct CreateTableCommentCompositionTests { ) #expect(composed.issues.map(\.message) == ["This database cannot store a table comment."]) - #expect(composed.issues.first?.tab == .columns) + #expect(composed.issues.first?.section == .columns) } @Test("Every statement the driver creates the table with arrives in order, ahead of the indexes") diff --git a/apple/TableProTests/Core/SchemaTracking/IndexDecliningDriverTests.swift b/apple/TableProTests/Core/SchemaTracking/IndexDecliningDriverTests.swift index 552e7e95e6..e55082428c 100644 --- a/apple/TableProTests/Core/SchemaTracking/IndexDecliningDriverTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/IndexDecliningDriverTests.swift @@ -67,7 +67,7 @@ struct IndexDecliningDriverTests { #expect(composed.statements.count == 1) #expect(composed.issues.map(\.message) == ["Create Table cannot add an index on this database."]) - #expect(composed.issues.first?.tab == .indexes) + #expect(composed.issues.first?.section == .indexes) #expect(composed.issues.first?.row == 0) } } diff --git a/apple/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift b/apple/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift index f92693eab3..22637772ec 100644 --- a/apple/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift @@ -334,7 +334,7 @@ struct SchemaOperationRefusalTests { let composed = CreateTableStatementComposer.compose(plan: plan, driver: legacyDriver(), schema: nil) #expect(composed.statements.isEmpty) #expect(composed.issues.map(\.message) == [Self.generatedReason]) - #expect(composed.issues.first?.tab == .columns) + #expect(composed.issues.first?.section == .columns) } @Test("Create Table flags a refused index on its own row and still creates the table") @@ -351,7 +351,7 @@ struct SchemaOperationRefusalTests { let composed = CreateTableStatementComposer.compose(plan: plan, driver: legacyDriver(), schema: nil) #expect(composed.statements == ["CREATE TABLE orders (...)", "CREATE INDEX ix_btree ON orders"]) #expect(composed.issues.count == 1) - #expect(composed.issues.first?.tab == .indexes) + #expect(composed.issues.first?.section == .indexes) #expect(composed.issues.first?.row == 1) #expect(composed.issues.first?.message == Self.brinReason) } diff --git a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerSaveHoldTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerSaveHoldTests.swift index 16ba8d3a07..3560f1f28c 100644 --- a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerSaveHoldTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerSaveHoldTests.swift @@ -8,10 +8,8 @@ import Foundation import TableProPluginKit import Testing -/// A save writes the edits staged when it was pressed and clears them when it lands. Staging stayed -/// open in between, so on MongoDB, where the save reads every document it changes before writing, -/// an edit made during that read was left out of the script and then cleared with the edits that -/// did run. +// Staging stayed open during a save, so on MongoDB an edit made while the save read the documents was left out +// of the script and then cleared with the edits that ran. @MainActor struct StructureChangeManagerSaveHoldTests { private func makeManager() -> StructureChangeManager { @@ -75,7 +73,7 @@ struct StructureChangeManagerSaveHoldTests { rename(manager, at: 2, to: "mail") manager.deleteColumn(id: manager.workingColumns[2].id) manager.performAsOneUndoStep { manager.deleteColumn(id: manager.workingColumns[0].id) } - manager.undoDelete(for: .columns, at: 2) + manager.undoDelete(section: .columns, id: manager.workingColumns[2].id) manager.stageTableComment("Users of the shop") manager.undo() manager.redo() @@ -120,8 +118,7 @@ struct StructureChangeManagerSaveHoldTests { #expect(manager.getChangesArray().count == 2) } - /// A completion that arrives for a hold that has already ended must not clear what was staged - /// after it. Only the hold a save still owns can clear, and only the edits it took. + // Only the hold a save still owns can clear, and only the edits it took. @Test("An ended hold cannot clear edits staged after it") func endedHoldClearsNothing() throws { let manager = makeManager() diff --git a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift new file mode 100644 index 0000000000..8288461bb6 --- /dev/null +++ b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift @@ -0,0 +1,239 @@ +// +// StructureChangeManagerStagingTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@MainActor +struct StructureChangeManagerStagingTests { + private func loadedManager() -> StructureChangeManager { + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "orders", + 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: "PRIMARY", columns: ["id"], isUnique: true, isPrimary: true, type: "BTREE")], + foreignKeys: [], + primaryKey: ["id"] + ) + return manager + } + + private func column(_ name: String, in manager: StructureChangeManager) -> EditableColumnDefinition { + manager.workingColumns.first { $0.name == name }! + } + + @Test("Making a column part of the key stages the key change the drivers write, not a column change") + func primaryKeyCellStagesKeyChange() { + let manager = loadedManager() + var email = column("email", in: manager) + email.isPrimaryKey = true + manager.updateColumn(id: email.id, with: email) + + let changes = manager.getChangesArray() + #expect(changes.contains(.modifyPrimaryKey(old: ["id"], new: ["id", "email"]))) + #expect(!changes.contains { if case .modifyColumn = $0 { true } else { false } }) + #expect(manager.workingPrimaryKey == ["id", "email"]) + } + + @Test("Taking the only key column out stages an empty key") + func removingKeyColumnStagesEmptyKey() { + let manager = loadedManager() + var id = column("id", in: manager) + id.isPrimaryKey = false + manager.updateColumn(id: id.id, with: id) + + #expect(manager.getChangesArray().contains(.modifyPrimaryKey(old: ["id"], new: []))) + } + + @Test("One undo reverts the key change and the column's flag together") + func primaryKeyChangeUndoesAsOneStep() { + let manager = loadedManager() + var email = column("email", in: manager) + email.isPrimaryKey = true + manager.updateColumn(id: email.id, with: email) + + manager.undo() + + #expect(manager.workingPrimaryKey == ["id"]) + #expect(column("email", in: manager).isPrimaryKey == false) + #expect(!manager.hasChanges) + } + + @Test("A key toggle together with another edit keeps the column change") + func keyToggleKeepsOtherColumnEdits() { + let manager = loadedManager() + var email = column("email", in: manager) + email.isNullable = false + manager.updateColumn(id: email.id, with: email) + email.isPrimaryKey = true + manager.updateColumn(id: email.id, with: email) + + let changes = manager.getChangesArray() + #expect(changes.contains { if case .modifyColumn = $0 { true } else { false } }) + #expect(changes.contains { if case .modifyPrimaryKey = $0 { true } else { false } }) + } + + @Test("Undo Delete on an edited column stages its edit again") + func undoDeleteRestagesEdit() { + let manager = loadedManager() + var email = column("email", in: manager) + email.dataType = "TEXT" + manager.updateColumn(id: email.id, with: email) + manager.deleteColumn(id: email.id) + + manager.undoDelete(section: .columns, id: email.id) + + guard case .modifyColumn(_, let new)? = manager.pendingChanges[.column(email.id)] else { + Issue.record("the edit made before the delete is no longer staged") + return + } + #expect(new.dataType == "TEXT") + } + + @Test("Undoing a delete through the undo stack stages the earlier edit again") + func undoStackDeleteRestagesEdit() { + let manager = loadedManager() + var email = column("email", in: manager) + email.dataType = "TEXT" + manager.updateColumn(id: email.id, with: email) + manager.deleteColumn(id: email.id) + + manager.undo() + + guard case .modifyColumn? = manager.pendingChanges[.column(email.id)] else { + Issue.record("the edit made before the delete is no longer staged") + return + } + } + + @Test("The Edit menu title names the change it undoes") + func undoMenuTitleNamesTheChange() { + let manager = loadedManager() + manager.deleteColumn(id: column("email", in: manager).id) + + #expect(manager.undoMenuItemTitle.contains(String(localized: "Delete Column"))) + } + + @Test("Redoing a key toggle stages only the key change") + func redoKeepsKeyOnlyChangeOffColumns() { + let manager = loadedManager() + var id = column("id", in: manager) + id.isPrimaryKey = false + manager.updateColumn(id: id.id, with: id) + manager.undo() + manager.redo() + + let changes = manager.getChangesArray() + #expect(changes.contains(.modifyPrimaryKey(old: ["id"], new: []))) + #expect(!changes.contains { if case .modifyColumn = $0 { true } else { false } }) + } + + @Test("Renaming a column the staged key names renames it in the key") + func renameFollowsStagedKey() { + let manager = loadedManager() + var email = column("email", in: manager) + email.isPrimaryKey = true + manager.updateColumn(id: email.id, with: email) + email.name = "address" + manager.updateColumn(id: email.id, with: email) + + #expect(manager.workingPrimaryKey == ["id", "address"]) + #expect(manager.getChangesArray().contains(.modifyPrimaryKey(old: ["id"], new: ["id", "address"]))) + #expect(manager.canCommit) + } + + @Test("A rename leaves an unchanged key alone") + func renameWithoutStagedKeyStagesNoKeyChange() { + let manager = loadedManager() + var id = column("id", in: manager) + id.name = "order_id" + manager.updateColumn(id: id.id, with: id) + + #expect(!manager.getChangesArray().contains { if case .modifyPrimaryKey = $0 { true } else { false } }) + } + + @Test("Removing a column the staged key names takes it out of the key") + func deletingStagedKeyMemberUpdatesKey() { + let manager = loadedManager() + manager.addNewColumn() + var added = manager.workingColumns.last! + added.name = "tenant_id" + added.dataType = "INT" + manager.updateColumn(id: added.id, with: added) + added.isPrimaryKey = true + manager.updateColumn(id: added.id, with: added) + #expect(manager.workingPrimaryKey == ["id", "tenant_id"]) + + manager.deleteColumn(id: added.id) + + #expect(manager.workingPrimaryKey == ["id"]) + #expect(!manager.getChangesArray().contains { if case .modifyPrimaryKey = $0 { true } else { false } }) + } + + @Test("Undo Delete on a column the staged key named puts it back in the key") + func undoDeleteRestoresKeyMembership() { + let manager = loadedManager() + var email = column("email", in: manager) + email.isNullable = false + manager.updateColumn(id: email.id, with: email) + email.isPrimaryKey = true + manager.updateColumn(id: email.id, with: email) + manager.deleteColumn(id: email.id) + #expect(manager.workingPrimaryKey == ["id"]) + + manager.undoDelete(section: .columns, id: email.id) + + #expect(manager.workingPrimaryKey == ["id", "email"]) + #expect(manager.getChangesArray().contains(.modifyPrimaryKey(old: ["id"], new: ["id", "email"]))) + } + + @Test("A key change after a rename names the renamed column") + func keyChangeAfterRenameUsesNewNames() { + let manager = loadedManager() + var id = column("id", in: manager) + id.name = "order_id" + manager.updateColumn(id: id.id, with: id) + var email = column("email", in: manager) + email.isPrimaryKey = true + manager.updateColumn(id: email.id, with: email) + + #expect(manager.workingPrimaryKey == ["order_id", "email"]) + #expect(manager.canCommit) + } + + @Test("Undo Delete puts a key column back where it was in the key") + func undoDeleteKeepsKeyOrder() { + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "t", + columns: ["a", "b", "c"].map { + ColumnInfo(name: $0, dataType: "INT", isNullable: false, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil) + }, + indexes: [], + foreignKeys: [], + primaryKey: [] + ) + for name in ["a", "b", "c"] { + var column = manager.workingColumns.first { $0.name == name }! + column.isPrimaryKey = true + manager.updateColumn(id: column.id, with: column) + } + let a = manager.workingColumns.first { $0.name == "a" }! + manager.deleteColumn(id: a.id) + #expect(manager.workingPrimaryKey == ["b", "c"]) + + manager.undoDelete(section: .columns, id: a.id) + + #expect(manager.workingPrimaryKey == ["a", "b", "c"]) + } +} diff --git a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerTableCommentTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerTableCommentTests.swift index f393fe706c..a368e99759 100644 --- a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerTableCommentTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerTableCommentTests.swift @@ -36,21 +36,6 @@ struct StructureChangeManagerTableCommentTests { } } - private func structureAdapter(_ manager: StructureChangeManager, on tab: StructureTab) -> StructureGridAdapter { - let adapter = StructureGridAdapter( - structureChangeManager: manager, - connection: TestFixtures.makeConnection(type: .postgresql), - tableName: "orders", - databaseName: "shop", - schemaName: nil, - objectKind: .table, - isSessionBound: false - ) - adapter.store.switchSubTab(to: tab) - adapter.refresh() - return adapter - } - private func rename(_ manager: StructureChangeManager, at row: Int, to name: String) { var column = manager.workingColumns[row] column.name = name @@ -196,31 +181,11 @@ struct StructureChangeManagerTableCommentTests { #expect(manager.canUndo) } - @Test("Undo on the DDL sub-tab reaches the comment step and nothing behind it") - func undoOnDDLReachesOnlyTheComment() { + @Test("The Edit menu names the comment while its run is open") + func undoTitleNamesTheOpenRun() { let manager = makeManager() - rename(manager, at: 1, to: "amount") manager.stageTableComment("Orders") - let adapter = structureAdapter(manager, on: .ddl) - - adapter.undo() - #expect(manager.tableComment.text == "Customer orders") - - adapter.undo() - #expect(manager.workingColumns[1].name == "amount") - - adapter.redo() - #expect(manager.tableComment.text == "Orders") - } - - @Test("Undo on the Triggers sub-tab leaves a column edit alone") - func undoOnTriggersSkipsColumnEdits() { - let manager = makeManager() - rename(manager, at: 1, to: "amount") - let adapter = structureAdapter(manager, on: .triggers) - - adapter.undo() - #expect(manager.workingColumns[1].name == "amount") + #expect(manager.undoMenuItemTitle.contains(String(localized: "Edit Comment"))) } } diff --git a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerTableOptionsTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerTableOptionsTests.swift index 80422ba881..f78a37302b 100644 --- a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerTableOptionsTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerTableOptionsTests.swift @@ -345,16 +345,4 @@ struct StructureChangeManagerTableOptionsTests { #expect(defaultOnly.validationErrors.isEmpty) #expect(defaultOnly.canCommit) } - - @Test("The Options sub-tab has no rows to mark") - func optionsTabHasNoRowState() { - let manager = makeManager() - manager.deleteColumn(id: manager.workingColumns[1].id) - - let state = manager.deleteInsertState(for: 1, tab: .options) - #expect(!state.isDeleted) - #expect(!state.isInserted) - manager.undoDelete(for: .options, at: 1) - #expect(manager.hasChanges) - } } diff --git a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift index f6e4007a47..cbb89741c5 100644 --- a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerUndoTests.swift @@ -244,10 +244,7 @@ struct StructureChangeManagerUndoTests { #expect(manager.workingColumns.contains(where: { $0.id == newCol.id })) } - /// The other half of the grouping contract, and the one with no test before this. The structure - /// grid deletes a multi-row selection by calling `deleteColumn` once per row, so without an - /// explicit group a five-row delete would take five undos. `performAsOneUndoStep` is what the - /// two grid delegates wrap those loops in. + // The grid deletes a multi-row selection one `deleteColumn` per row; without a group, five rows took five undos. @Test("A batch wrapped in performAsOneUndoStep undoes as one step") @MainActor func batchedDeletesUndoTogether() { let manager = makeManager() @@ -271,6 +268,39 @@ struct StructureChangeManagerUndoTests { #expect(manager.canUndo == false) } + @Test("Duplicating several rows is one undo step") + @MainActor func duplicatedRowsUndoTogether() { + let manager = makeManager() + loadSampleSchema(manager) + let names = manager.workingColumns.map(\.name) + + StructureGridEdits.duplicate(sourceIndices: [2, 1], tab: .columns, manager: manager) + #expect(manager.workingColumns.count == names.count + 2) + #expect(manager.workingColumns.suffix(2).map(\.name) == ["name", "email"]) + + manager.undo() + + #expect(manager.workingColumns.map(\.name) == names) + #expect(manager.hasChanges == false) + #expect(manager.canUndo == false) + } + + @Test("Duplicating a primary index stages a plain copy, and one undo removes it") + @MainActor func duplicatedPrimaryIndexIsNotPrimary() throws { + let manager = makeManager() + loadSampleSchema(manager) + + StructureGridEdits.duplicate(sourceIndices: [0], tab: .indexes, manager: manager) + let copy = try #require(manager.workingIndexes.last) + #expect(manager.workingIndexes.count == 2) + #expect(!copy.isPrimary) + + manager.undo() + + #expect(manager.workingIndexes.count == 1) + #expect(manager.hasChanges == false) + } + // MARK: - Discard Clears Undo @Test("Discard changes clears undo stack") diff --git a/apple/TableProTests/Core/SchemaTracking/StructureChangeNameReuseTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureChangeNameReuseTests.swift index d4e02f007c..63343c06b1 100644 --- a/apple/TableProTests/Core/SchemaTracking/StructureChangeNameReuseTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/StructureChangeNameReuseTests.swift @@ -7,12 +7,8 @@ import Foundation @testable import TablePro import Testing -/// A name belongs to the rows the table keeps after the save. -/// -/// The save drops every index and check constraint before it adds or renames one, so a name a row -/// is being deleted under is free by the time anything takes it. The duplicate check still counted -/// the deleted row, and "delete idx_a, add a new idx_a" was refused as a duplicate of the row on -/// its way out. +// The save drops indexes and check constraints before it adds one, so a deleted row's name is free; the duplicate +// check still counted it and refused "delete idx_a, add a new idx_a". @MainActor struct StructureChangeNameReuseTests { private func manager( @@ -69,8 +65,8 @@ struct StructureChangeNameReuseTests { // MARK: - Indexes - /// Measured on SQLite 3.54, PostgreSQL 17.11 and MariaDB 13.0.2: `CREATE INDEX idx_a` fails while - /// `idx_a` exists, and succeeds straight after `DROP INDEX idx_a` in the same save. + // Measured on SQLite 3.54, PostgreSQL 17.11 and MariaDB 13.0.2: `CREATE INDEX idx_a` fails while + // `idx_a` exists, and succeeds straight after `DROP INDEX idx_a` in the same save. @Test("Deleting an index frees its name for a new one in the same save") func deletingAnIndexFreesItsNameForANewOne() throws { let manager = manager(indexes: [loadedIndex("idx_a", on: "body")]) @@ -93,8 +89,8 @@ struct StructureChangeNameReuseTests { #expect(manager.validationErrors.isEmpty) } - /// The row menu's **Duplicate** stages a copy under the same name, which is the everyday way to - /// build a replacement before the original goes. + // The row menu's **Duplicate** stages a copy under the same name, which is the everyday way to + // build a replacement before the original goes. @Test("Duplicating an index and then deleting the original saves") func duplicatingAnIndexThenDeletingTheOriginalSaves() throws { let manager = manager(indexes: [loadedIndex("idx_a", on: "body")]) @@ -116,7 +112,7 @@ struct StructureChangeNameReuseTests { manager.deleteIndex(id: original.id) #expect(manager.canCommit) - manager.undoDelete(for: .indexes, at: 0) + manager.undoDelete(section: .indexes, id: original.id) #expect(!manager.canCommit) #expect(manager.validationErrors.count == 2) #expect(errors(in: manager) == ["Duplicate index name: idx_a"]) @@ -156,8 +152,8 @@ struct StructureChangeNameReuseTests { // MARK: - Check constraints - /// Measured on SQLite 3.54 and PostgreSQL 17.11: `DROP CONSTRAINT c` then `ADD CONSTRAINT c` - /// saves, and `ADD CONSTRAINT c` alone fails while `c` exists. + // Measured on SQLite 3.54 and PostgreSQL 17.11: `DROP CONSTRAINT c` then `ADD CONSTRAINT c` + // saves, and `ADD CONSTRAINT c` alone fails while `c` exists. @Test("Deleting a check constraint frees its name for a new one in the same save") func deletingACheckConstraintFreesItsName() throws { let manager = manager(checkConstraints: [CheckConstraintInfo(name: "c", expression: "a > 0")]) @@ -192,8 +188,8 @@ struct StructureChangeNameReuseTests { #expect(errors(in: manager) == ["Duplicate constraint name: c"]) } - /// Measured on SQLite 3.54: `ADD CONSTRAINT "C"` beside `c` fails with "constraint C already - /// exists", and MariaDB 13.0.2 refuses it with ERROR 1826. + // Measured on SQLite 3.54: `ADD CONSTRAINT "C"` beside `c` fails with "constraint C already + // exists", and MariaDB 13.0.2 refuses it with ERROR 1826. @Test("A check constraint added beside one whose name differs only in case is a duplicate") func addingAConstraintUnderACaseVariantOfAKeptNameIsADuplicate() { let manager = manager(checkConstraints: [CheckConstraintInfo(name: "c", expression: "a > 0")]) @@ -205,8 +201,8 @@ struct StructureChangeNameReuseTests { // MARK: - Two loaded check constraints under one name - /// SQLite 3.54 accepts `CONSTRAINT c CHECK (a > 0), CONSTRAINT c CHECK (a < 10)` in one - /// `CREATE TABLE`, and the Structure tab lists both. + // SQLite 3.54 accepts `CONSTRAINT c CHECK (a > 0), CONSTRAINT c CHECK (a < 10)` in one + // `CREATE TABLE`, and the Structure tab lists both. private func tableWithTwoConstraintsNamedC(secondName: String = "c") -> StructureChangeManager { manager(checkConstraints: [ CheckConstraintInfo(name: "c", expression: "a > 0"), @@ -225,8 +221,8 @@ struct StructureChangeNameReuseTests { #expect(manager.validationErrors.isEmpty) } - /// Measured on SQLite 3.54: `DROP CONSTRAINT c` removes the first `c` in the table's text, - /// whichever row was picked. + // Measured on SQLite 3.54: `DROP CONSTRAINT c` removes the first `c` in the table's text, + // whichever row was picked. @Test("Deleting one of two check constraints under one name is refused") func droppingOneOfTwoSameNamedConstraintsIsRefused() throws { let manager = tableWithTwoConstraintsNamedC() @@ -261,7 +257,7 @@ struct StructureChangeNameReuseTests { #expect(manager.validationErrors.count == 1) } - /// Measured on SQLite 3.54: `DROP CONSTRAINT "C"` on a table holding `c` and `C` drops `c`. + // Measured on SQLite 3.54: `DROP CONSTRAINT "C"` on a table holding `c` and `C` drops `c`. @Test("Check constraint names that differ only in case are one name") func caseVariantConstraintNamesAreShared() throws { let manager = tableWithTwoConstraintsNamedC(secondName: "C") @@ -284,8 +280,8 @@ struct StructureChangeNameReuseTests { #expect(manager.validationErrors.isEmpty) } - /// Measured on SQLite 3.54: after `DROP CONSTRAINT c` twice and `ADD CONSTRAINT c`, the add of - /// `C` fails with "constraint C already exists". + // Measured on SQLite 3.54: after `DROP CONSTRAINT c` twice and `ADD CONSTRAINT c`, the add of + // `C` fails with "constraint C already exists". @Test("Replacing both check constraints under a shared name with c and C is a duplicate") func replacingBothWithCaseVariantsIsADuplicate() { let manager = tableWithTwoConstraintsNamedC() @@ -299,8 +295,8 @@ struct StructureChangeNameReuseTests { #expect(errors(in: manager) == ["Duplicate constraint name: c", "Duplicate constraint name: C"]) } - /// SQLite has no `RENAME CONSTRAINT`, so each rename is a drop by the shared name and an add of - /// the row's own definition. Whichever `c` each drop takes, both go and both come back right. + // SQLite has no `RENAME CONSTRAINT`, so each rename is a drop by the shared name and an add of + // the row's own definition. Whichever `c` each drop takes, both go and both come back right. @Test("Renaming every check constraint under a shared name saves") func renamingBothSameNamedConstraintsSaves() throws { let manager = tableWithTwoConstraintsNamedC() @@ -314,8 +310,8 @@ struct StructureChangeNameReuseTests { #expect(manager.validationErrors.isEmpty) } - /// Measured on SQLite 3.54: `DROP CONSTRAINT c; DROP CONSTRAINT c; ADD CONSTRAINT c CHECK (a < 20)` - /// leaves exactly the rewritten constraint. + // Measured on SQLite 3.54: `DROP CONSTRAINT c; DROP CONSTRAINT c; ADD CONSTRAINT c CHECK (a < 20)` + // leaves exactly the rewritten constraint. @Test("Deleting one check constraint under a shared name and rewriting the other saves") func deletingOneAndRewritingTheOtherSaves() throws { let manager = tableWithTwoConstraintsNamedC() @@ -328,8 +324,8 @@ struct StructureChangeNameReuseTests { #expect(manager.validationErrors.isEmpty) } - /// Both would be added back as `c`, and the second `ADD CONSTRAINT c` fails on SQLite 3.54 with - /// "constraint c already exists". + // Both would be added back as `c`, and the second `ADD CONSTRAINT c` fails on SQLite 3.54 with + // "constraint c already exists". @Test("Rewriting both check constraints under their shared name is a duplicate") func rewritingBothUnderTheSharedNameIsADuplicate() throws { let manager = tableWithTwoConstraintsNamedC() diff --git a/apple/TableProTests/Core/SchemaTracking/StructureEditingSessionTableOptionsTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureEditingSessionTableOptionsTests.swift index c5937e68a0..550dafb911 100644 --- a/apple/TableProTests/Core/SchemaTracking/StructureEditingSessionTableOptionsTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/StructureEditingSessionTableOptionsTests.swift @@ -164,12 +164,4 @@ struct StructureEditingSessionTableOptionsTests { #expect(session.tableOptionsCatalog == .loaded(Self.catalog)) } - - @Test("The Options sub-tab shows no grid") - func optionsTabHasNoGrid() { - let session = makeSession() - session.selectedTab = .options - - #expect(session.activeGridModel == nil) - } } diff --git a/apple/TableProTests/Core/SchemaTracking/TableCommentStatementTests.swift b/apple/TableProTests/Core/SchemaTracking/TableCommentStatementTests.swift index 5fa16cdebe..9ac049ba09 100644 --- a/apple/TableProTests/Core/SchemaTracking/TableCommentStatementTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/TableCommentStatementTests.swift @@ -117,7 +117,7 @@ struct TableCommentStatementTests { _ = try generator.generate(changes: [.modifyTableComment(old: nil, new: "Orders")]) Issue.record("Expected the save to be refused") } catch { - #expect(error.localizedDescription.contains("Change comment on table")) + #expect(error.localizedDescription.contains(String(localized: "Change the table comment"))) } } diff --git a/apple/TableProTests/Helpers/TestFixtures.swift b/apple/TableProTests/Helpers/TestFixtures.swift index d36bec3889..c982ab1466 100644 --- a/apple/TableProTests/Helpers/TestFixtures.swift +++ b/apple/TableProTests/Helpers/TestFixtures.swift @@ -276,3 +276,12 @@ enum TestFixtures { ) } } + +@MainActor +extension StructureEditingSession { + // What a finished load leaves behind, for a test that stages straight into the change manager. + func markBaselineAdopted() { + hasBaseline = true + editor.isAwaitingBaseline = false + } +} diff --git a/apple/TableProTests/Models/InspectorFieldLayoutTests.swift b/apple/TableProTests/Models/InspectorFieldLayoutTests.swift deleted file mode 100644 index 99cdc123a1..0000000000 --- a/apple/TableProTests/Models/InspectorFieldLayoutTests.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// InspectorFieldLayoutTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -@MainActor -struct InspectorFieldLayoutTests { - private static let everyKind: [FieldEditorKind] = [ - .singleLine, - .boolean, - .schemaText, - .typePicker, - .enumPicker(values: ["a", "b"]), - .setPicker(values: ["a", "b"]), - .arrayElements(element: .scalar, values: []), - .arrayElements(element: .json, values: []), - .valuePicker(options: []), - .multiLine, - .json, - .phpSerialized, - .blobHex, - .image(.raster("public.png")), - .geometry(GeometryFieldDescriptor(textEditor: .multiLine, source: .spatialColumn)), - .geometry(GeometryFieldDescriptor(textEditor: .json, source: .jsonColumn)), - .geometry(GeometryFieldDescriptor(textEditor: .hex, source: .binary)) - ] - - /// A column name is user data of no bounded length, so no label lane can hold every one of them - /// and a lane wide enough to try takes the width the value needs. Measured at the pane's 270pt - /// minimum, a side-by-side data row truncates the name *and* the value; stacking shows both. - @Test("Every data field stacks, whatever editor it resolves to") - func dataFieldsAlwaysStack() { - for kind in Self.everyKind { - #expect( - InspectorFieldLayout.resolve(for: kind, isSchemaField: false) == .stacked, - "\(kind) should stack on a data row" - ) - } - } - - /// A schema label comes from a closed authored vocabulary whose widest member renders at 66.4pt, - /// so the lane holds it and the row stays one line high. - @Test("A schema scalar takes one line") - func schemaScalarsAreInline() { - #expect(InspectorFieldLayout.resolve(for: .singleLine, isSchemaField: true) == .inline) - #expect(InspectorFieldLayout.resolve(for: .boolean, isSchemaField: true) == .inline) - #expect(InspectorFieldLayout.resolve(for: .schemaText, isSchemaField: true) == .inline) - #expect(InspectorFieldLayout.resolve(for: .typePicker, isSchemaField: true) == .inline) - #expect(InspectorFieldLayout.resolve(for: .enumPicker(values: ["a"]), isSchemaField: true) == .inline) - #expect(InspectorFieldLayout.resolve(for: .setPicker(values: ["a"]), isSchemaField: true) == .inline) - #expect( - InspectorFieldLayout.resolve(for: .arrayElements(element: .json, values: []), isSchemaField: true) - == .inline - ) - } - - /// The editors that need the pane's width still take it, on a schema row as on a data row. - @Test("A schema editor that needs room spans the width") - func schemaLargeEditorsAreStacked() { - #expect(InspectorFieldLayout.resolve(for: .multiLine, isSchemaField: true) == .stacked) - #expect(InspectorFieldLayout.resolve(for: .json, isSchemaField: true) == .stacked) - #expect(InspectorFieldLayout.resolve(for: .phpSerialized, isSchemaField: true) == .stacked) - #expect(InspectorFieldLayout.resolve(for: .blobHex, isSchemaField: true) == .stacked) - #expect(InspectorFieldLayout.resolve(for: .image(.raster("public.png")), isSchemaField: true) == .stacked) - } - - /// A schema row never resolves to a geometry field, and the switch still has to place it: a map - /// needs the pane's width wherever it is shown. - @Test("A geometry field spans the width on either kind of row") - func geometryFieldsAreStacked() { - let kinds: [FieldEditorKind] = [ - .geometry(GeometryFieldDescriptor(textEditor: .multiLine, source: .spatialColumn)), - .geometry(GeometryFieldDescriptor(textEditor: .json, source: .jsonColumn)), - .geometry(GeometryFieldDescriptor(textEditor: .hex, source: .binary)) - ] - for kind in kinds { - #expect(InspectorFieldLayout.resolve(for: kind, isSchemaField: false) == .stacked) - #expect(InspectorFieldLayout.resolve(for: kind, isSchemaField: true) == .stacked) - } - } - - @Test("A geometry value reaches the stacked shape through its editor") - func aGeometryValueStacksThroughItsEditor() { - let kind = FieldEditorResolver.resolve( - for: .spatial(rawType: "POINT"), - isLongText: false, - originalValue: "POINT(1 2)" - ) - #expect(kind == .geometry(GeometryFieldDescriptor(textEditor: .multiLine, source: .spatialColumn))) - #expect(InspectorFieldLayout.resolve(for: kind, isSchemaField: false) == .stacked) - } - - /// `.enumPicker` is the kind for a data row's ENUM column *and* for a structure row's dropdown - /// (On Delete, Unique, Nullable), so the editor kind alone cannot say which shape a row takes. - /// Resolving on the kind by itself is what put a structure dropdown and an ENUM value in the - /// same geometry. - @Test("One editor kind resolves differently by provenance") - func provenanceDecidesWhereTheKindIsShared() { - let dropdown = FieldEditorKind.enumPicker(values: ["CASCADE", "RESTRICT"]) - #expect(InspectorFieldLayout.resolve(for: dropdown, isSchemaField: true) == .inline) - #expect(InspectorFieldLayout.resolve(for: dropdown, isSchemaField: false) == .stacked) - } - - /// A long value still reaches its own editor through `FieldEditorResolver`; the layout no longer - /// asks the value a second time, so the two can never drift apart. - @Test("A long value resolves to the multi-line editor") - func aLongValueBecomesMultiLineThroughItsEditor() { - let long = String(repeating: "x", count: FieldEditorResolver.multiLineValueThreshold + 1) - let kind = FieldEditorResolver.resolve(for: .text(rawType: "TEXT"), isLongText: false, originalValue: long) - #expect(kind == .multiLine) - #expect(InspectorFieldLayout.resolve(for: kind, isSchemaField: false) == .stacked) - } - - /// A short data value gets the same shape as a long one. Choosing per row from the value's own - /// length would reflow a field from one shape to the other while the user typed into it. - @Test("A short data value keeps the same shape as a long one") - func aShortValueKeepsTheStackedShape() { - let kind = FieldEditorResolver.resolve(for: .text(rawType: "TEXT"), isLongText: false, originalValue: "ok") - #expect(kind == .singleLine) - #expect(InspectorFieldLayout.resolve(for: kind, isSchemaField: false) == .stacked) - } -} diff --git a/apple/TableProTests/Models/InspectorSubjectTests.swift b/apple/TableProTests/Models/InspectorSubjectTests.swift index 252a8ed4b3..e922c364a1 100644 --- a/apple/TableProTests/Models/InspectorSubjectTests.swift +++ b/apple/TableProTests/Models/InspectorSubjectTests.swift @@ -19,7 +19,7 @@ struct InspectorSubjectTests { #expect(subject.subtitle?.contains("500") == true) } - /// A result of several thousand rows reads as a quantity, the way the grid's own counter does. + // A result of several thousand rows reads as a quantity, the way the grid's own counter does. @Test("A large row count is grouped") func largeCountsAreGrouped() { let subject = InspectorSubject.tableRow( @@ -44,20 +44,39 @@ struct InspectorSubjectTests { #expect(subject.subtitle?.contains("3") == true) } - /// A schema grid's selection is a column definition, not a row of a result: it has no position - /// and no identity, so a subject that assumed one would render "Row 0 of 0" over a good column. - @Test("A column definition names the column, not a row") + @Test("A column definition names the column and its table") func columnDefinitionNamesTheColumn() { - let subject = InspectorSubject.columnDefinition(column: "user_id", table: "public.users") + let subject = InspectorSubject.definition(name: "user_id", section: .columns, table: "users") #expect(subject.title == "user_id") - #expect(subject.subtitle?.contains("public.users") == true) + #expect(subject.subtitle == String(format: String(localized: "Column of %@"), "users")) } - @Test("A column with no known table still names itself") - func columnWithoutTable() { - let subject = InspectorSubject.columnDefinition(column: "user_id", table: nil) + @Test("An index names itself as an index, not a column") + func indexIsNotAColumn() { + let subject = InspectorSubject.definition(name: "idx_email", section: .indexes, table: "users") + #expect(subject.title == "idx_email") + #expect(subject.subtitle == String(format: String(localized: "Index of %@"), "users")) + } + + @Test("Foreign keys and check constraints say what they are") + func otherDefinitionKinds() { + let foreignKey = InspectorSubject.definition(name: "fk_user", section: .foreignKeys, table: "orders") + #expect(foreignKey.subtitle == String(format: String(localized: "Foreign Key of %@"), "orders")) + let check = InspectorSubject.definition(name: "positive_total", section: .checkConstraints, table: "orders") + #expect(check.subtitle == String(format: String(localized: "Check Constraint of %@"), "orders")) + } + + @Test("A definition with no name yet reads as new") + func unnamedDefinitionIsNew() { + #expect(InspectorSubject.definition(name: "", section: .columns, table: "t").title == String(localized: "New Column")) + #expect(InspectorSubject.definition(name: "", section: .indexes, table: "t").title == String(localized: "New Index")) + } + + @Test("A definition with no known table still names its kind") + func definitionWithoutTable() { + let subject = InspectorSubject.definition(name: "user_id", section: .foreignKeys, table: nil) #expect(subject.title == "user_id") - #expect(subject.subtitle != nil) + #expect(subject.subtitle == String(localized: "Foreign Key")) } @Test("A table with no row selected names the table") diff --git a/apple/TableProTests/Models/MultiRowEditStateTests.swift b/apple/TableProTests/Models/MultiRowEditStateTests.swift index 311c93b45d..ce162e198a 100644 --- a/apple/TableProTests/Models/MultiRowEditStateTests.swift +++ b/apple/TableProTests/Models/MultiRowEditStateTests.swift @@ -1029,104 +1029,6 @@ struct MultiRowEditStateTests { } } - @MainActor @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 { $0.isSchemaField }) - #expect(sut.fields[2].hasCommittedEdit) - #expect(sut.fields[0].hasCommittedEdit == false) - #expect(sut.rowIDs.isEmpty) - } - - @Test("A field the owning grid locks is shown without an editor") - func lockedSchemaFieldIsReadOnly() { - let sut = MultiRowEditState() - sut.configure( - schemaFields: [ - InspectorRowField(name: "Name", value: "email", editor: .schemaText), - InspectorRowField(name: "Type", value: "text", editor: .typePicker, isEditable: false) - ], - displayRow: 0 - ) - - #expect(sut.fields[0].isServerOwned == false) - #expect(sut.fields[1].isServerOwned) - #expect(InspectorFieldListView.isFieldEditable(sut.fields[0], kind: .schemaText, rowIsEditable: true)) - #expect(!InspectorFieldListView.isFieldEditable(sut.fields[1], kind: .typePicker, rowIsEditable: true)) - } - - @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) - } - } - // MARK: - Binary columns @MainActor @Suite("Binary columns") diff --git a/apple/TableProTests/Models/PendingChangeKindTests.swift b/apple/TableProTests/Models/PendingChangeKindTests.swift index a3efc3eeb9..f1fad5ca9b 100644 --- a/apple/TableProTests/Models/PendingChangeKindTests.swift +++ b/apple/TableProTests/Models/PendingChangeKindTests.swift @@ -166,4 +166,17 @@ struct PendingChangeKindTests { ) == nil ) } + + // A session previews only from its Structure view; lit over ALTERs staged behind Data, Preview showed nothing. + @Test("Preview SQL needs statements it can show") + func previewNeedsStatementsItCanShow() { + #expect(!PendingChangeKind.offersPreview(of: .structure, hasDataChanges: false, previewsSchemaChanges: false)) + #expect(PendingChangeKind.offersPreview(of: .structure, hasDataChanges: false, previewsSchemaChanges: true)) + #expect(PendingChangeKind.offersPreview(of: .structure, hasDataChanges: true, previewsSchemaChanges: false)) + #expect(PendingChangeKind.offersPreview(of: .data, hasDataChanges: true, previewsSchemaChanges: false)) + #expect(PendingChangeKind.offersPreview(of: .createTable, hasDataChanges: false, previewsSchemaChanges: false)) + #expect(!PendingChangeKind.offersPreview(of: .file, hasDataChanges: false, previewsSchemaChanges: false)) + #expect(!PendingChangeKind.offersPreview(of: .principals, hasDataChanges: false, previewsSchemaChanges: false)) + #expect(!PendingChangeKind.offersPreview(of: nil, hasDataChanges: false, previewsSchemaChanges: false)) + } } diff --git a/apple/TableProTests/Models/ResultStatusModelTests.swift b/apple/TableProTests/Models/ResultStatusModelTests.swift index a4f5471df5..f5a1cc3f23 100644 --- a/apple/TableProTests/Models/ResultStatusModelTests.swift +++ b/apple/TableProTests/Models/ResultStatusModelTests.swift @@ -17,7 +17,6 @@ struct ResultStatusModelTests { isValueFiltered: Bool = false, hasColumns: Bool? = nil, hasTableName: Bool = true, - hasStructureActions: Bool = false, pagination: PaginationState = PaginationState(), statusMessage: String? = nil ) -> StatusBarSnapshot { @@ -35,7 +34,6 @@ struct ResultStatusModelTests { hasTableName: hasTableName, hasColumns: hasColumns ?? (rowCount > 0) ), - hasStructureActions: hasStructureActions, pagination: pagination, statusMessage: statusMessage ) @@ -396,18 +394,6 @@ struct ResultStatusModelTests { #expect(result.controls.showsColumns) } - /// The pair belongs to the structure list, so it appears only while that list is the content and - /// only once the structure editor has said what it can do. - @Test("The structure pair appears only in Structure mode, and only when published") - func structureActionsFollowTheMode() { - let published = makeSnapshot(rowCount: 9, hasStructureActions: true) - #expect(model(published, viewMode: .structure).controls.showsStructureActions) - #expect(!model(published, viewMode: .data).controls.showsStructureActions) - - let unpublished = makeSnapshot(rowCount: 9, hasStructureActions: false) - #expect(!model(unpublished, viewMode: .structure).controls.showsStructureActions) - } - @Test("A status message rides with the readout") func statusMessageFollowsReadout() { let withReadout = model(makeSnapshot(rowCount: 5, statusMessage: "OK")) @@ -470,7 +456,6 @@ struct ResultStatusModelTests { controls.showsFilters, controls.showsPagination, controls.showsFetchAll, - controls.showsStructureActions, controls.showsSelectionSummary, ] } diff --git a/apple/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift b/apple/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift index dbabf4b9b5..77ec1727e8 100644 --- a/apple/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift +++ b/apple/TableProTests/Models/Schema/CreateTableDraftBuilderTests.swift @@ -47,10 +47,15 @@ struct CreateTableDraftBuilderTests { ) } + private func check(_ name: String, _ expression: String) -> EditableCheckConstraintDefinition { + EditableCheckConstraintDefinition(id: UUID(), name: name, expression: expression, columns: [], isValidated: true) + } + private func plan( columns: [EditableColumnDefinition] = [], indexes: [EditableIndexDefinition] = [], foreignKeys: [EditableForeignKeyDefinition] = [], + checkConstraints: [EditableCheckConstraintDefinition] = [], databaseType: DatabaseType = .sqlite, tableName: String = "child" ) -> CreateTablePlan { @@ -60,6 +65,7 @@ struct CreateTableDraftBuilderTests { columns: columns.isEmpty ? [column("id"), column("parent_id")] : columns, indexes: indexes, foreignKeys: foreignKeys, + checkConstraints: checkConstraints, dialect: ForeignKeyDialect.forType(databaseType), includesEngineOptions: false ) @@ -118,7 +124,7 @@ struct CreateTableDraftBuilderTests { func unnamedIndexIsReported() { let result = plan(indexes: [index(name: "")]) #expect(result.indexes.isEmpty) - #expect(result.issues.contains { $0.tab == .indexes }) + #expect(result.issues.contains { $0.section == .indexes }) #expect(!result.isReadyToCreate) } @@ -158,14 +164,14 @@ struct CreateTableDraftBuilderTests { @Test("a foreign key with no referenced table is reported") func missingReferencedTableIsReported() { let result = plan(foreignKeys: [foreignKey(referencedTable: "")]) - #expect(result.issues.contains { $0.tab == .foreignKeys }) + #expect(result.issues.contains { $0.section == .foreignKeys }) #expect(result.definition?.foreignKeys.isEmpty == true) } @Test("a foreign key naming a column the table lacks is reported") func unknownColumnIsReported() { let result = plan(foreignKeys: [foreignKey(columns: ["nope"])]) - #expect(result.issues.contains { $0.tab == .foreignKeys }) + #expect(result.issues.contains { $0.section == .foreignKeys }) } @Test("mismatched composite column counts are reported") @@ -174,13 +180,13 @@ struct CreateTableDraftBuilderTests { columns: [column("id"), column("a"), column("b")], foreignKeys: [foreignKey(columns: ["a", "b"], referencedColumns: ["id"])] ) - #expect(result.issues.contains { $0.tab == .foreignKeys }) + #expect(result.issues.contains { $0.section == .foreignKeys }) } @Test("a duplicate column name is reported") func duplicateColumnIsReported() { let result = plan(columns: [column("id"), column("id")]) - #expect(result.issues.contains { $0.tab == .columns }) + #expect(result.issues.contains { $0.section == .columns }) } @Test("a table with no name is reported") @@ -203,13 +209,13 @@ struct CreateTableDraftBuilderTests { @Test("MySQL reports a foreign key with no referenced columns") func mysqlRequiresReferencedColumns() { let result = plan(foreignKeys: [foreignKey(referencedColumns: [])], databaseType: .mysql) - #expect(result.issues.contains { $0.tab == .foreignKeys }) + #expect(result.issues.contains { $0.section == .foreignKeys }) } @Test("DuckDB reports ON DELETE CASCADE") func duckdbRejectsCascade() { let result = plan(foreignKeys: [foreignKey(onDelete: .cascade)], databaseType: .duckdb) - #expect(result.issues.contains { $0.tab == .foreignKeys }) + #expect(result.issues.contains { $0.section == .foreignKeys }) } @Test("SQLite accepts ON DELETE CASCADE") @@ -221,7 +227,7 @@ struct CreateTableDraftBuilderTests { @Test("SQLite reports a referenced table in another schema") func sqliteRejectsQualifiedReference() { let result = plan(foreignKeys: [foreignKey(referencedSchema: "other")], databaseType: .sqlite) - #expect(result.issues.contains { $0.tab == .foreignKeys }) + #expect(result.issues.contains { $0.section == .foreignKeys }) } /// Every foreign key starts at `.noAction`, and Oracle, Dameng and Teradata declare an empty @@ -430,4 +436,50 @@ struct CreateTableDraftBuilderTests { #expect(seeded.collation == "latin1_bin") #expect(seeded.engine == "MyISAM") } + + // MARK: - Check constraints + + @Test("A named check with an expression is planned, trimmed, after the CREATE") + func completeCheckIsPlanned() { + let result = plan(checkConstraints: [check(" positive_id ", " id > 0\n")]) + #expect(result.issues.isEmpty) + #expect(result.checkConstraints.map { $0.name } == ["positive_id"]) + #expect(result.checkConstraints.map { $0.expression } == ["id > 0"]) + } + + @Test("A blank check row is ignored") + func blankCheckIsIgnored() { + let result = plan(checkConstraints: [check("", " ")]) + #expect(result.issues.isEmpty) + #expect(result.checkConstraints.isEmpty) + } + + @Test("A begun check names what it lacks on its own row", arguments: [("", "id > 0"), ("positive_id", "")]) + func incompleteCheckIsReported(name: String, expression: String) { + let result = plan(checkConstraints: [check("", ""), check(name, expression)]) + #expect(result.checkConstraints.isEmpty) + #expect(result.issues.map { $0.section } == [.checkConstraints]) + #expect(result.issues.first?.row == 1) + #expect(!result.isReadyToCreate) + } + + @Test("A second check with the same name is flagged on its own row") + func duplicateCheckNameIsReported() { + let result = plan(checkConstraints: [check("positive", "id > 0"), check("", ""), check("positive", "id < 9")]) + #expect(result.issues.count == 1) + #expect(result.issues.first?.section == .checkConstraints) + #expect(result.issues.first?.row == 2) + } + + @Test("Planned indexes and checks keep the working rows they came from") + func plannedRowsMapToWorkingRows() { + let result = plan( + indexes: [index(name: "", columns: []), index(name: "idx_parent")], + checkConstraints: [check("", ""), check("", ""), check("positive", "id > 0")] + ) + #expect(result.indexRows == [1]) + #expect(result.indexRow(at: 0) == 1) + #expect(result.checkConstraintRows == [2]) + #expect(result.checkConstraintRow(at: 0) == 2) + } } diff --git a/apple/TableProTests/Models/Schema/CreateTableFormStateTests.swift b/apple/TableProTests/Models/Schema/CreateTableFormStateTests.swift index dc691bfe1d..f5ee9187f0 100644 --- a/apple/TableProTests/Models/Schema/CreateTableFormStateTests.swift +++ b/apple/TableProTests/Models/Schema/CreateTableFormStateTests.swift @@ -409,9 +409,17 @@ struct CreateTableFormStateTests { @MainActor struct CreateTableDraftFormTests { + private func makeDraft() -> CreateTableDraft { + let connection = TestFixtures.makeConnection(type: .postgresql) + return CreateTableDraft( + connection: connection, + scope: DatabaseScope(connectionId: connection.id, database: "shop", schema: nil) + ) + } + @Test("A draft resolves its form once and keeps it") func resolvesFormOnce() { - let draft = CreateTableDraft() + let draft = makeDraft() #expect(!draft.hasResolvedForm) draft.resolveForm(from: FormFixture.spec) @@ -425,7 +433,7 @@ struct CreateTableDraftFormTests { @Test("Renaming the table clears what the driver said about the last attempt") func renameClearsSubmissionError() { - let draft = CreateTableDraft() + let draft = makeDraft() draft.resolveForm(from: FormFixture.spec) draft.tableName = "a" draft.form?.recordSubmissionError(PluginCreateTableFormError(message: "A table name is too short")) @@ -437,19 +445,35 @@ struct CreateTableDraftFormTests { #expect(draft.form?.submissionError == nil) } - @Test("A driver without a form leaves the draft on the column grid") + @Test("A driver without a form leaves the draft on the definition, seeded with one blank column") func noSpecMeansGrid() { - let draft = CreateTableDraft() + let draft = makeDraft() draft.resolveForm(from: nil) #expect(draft.hasResolvedForm) #expect(draft.form == nil) + #expect(draft.changeManager.workingColumns.count == 1) + #expect(draft.editor.adapter(for: .columns).visibleRowCount == 1) + #expect(!draft.holdsWork) + } + + @Test("A driver's form takes no definition commands and seeds no column") + func formTakesNoDefinitionCommands() { + let draft = makeDraft() + draft.resolveForm(from: FormFixture.spec) + + draft.addRow() + + #expect(!draft.canAddRow) + #expect(!draft.canUndo) + #expect(draft.changeManager.workingColumns.isEmpty) + #expect(draft.activeGridModel == nil) } @Test("An edited form counts as work worth protecting") func editedFormHoldsWork() { - let draft = CreateTableDraft() + let draft = makeDraft() draft.resolveForm(from: FormFixture.spec) #expect(!draft.holdsWork) diff --git a/apple/TableProTests/Views/DataGrid/DataGridFittingHeightTests.swift b/apple/TableProTests/Views/DataGrid/DataGridFittingHeightTests.swift new file mode 100644 index 0000000000..6793212aac --- /dev/null +++ b/apple/TableProTests/Views/DataGrid/DataGridFittingHeightTests.swift @@ -0,0 +1,336 @@ +// +// DataGridFittingHeightTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +private final class RecordingPageScrollView: NSScrollView { + private(set) var wheelEvents: [NSEvent] = [] + + override func scrollWheel(with event: NSEvent) { + wheelEvents.append(event) + } +} + +@MainActor +private final class FlippedPageDocument: NSView { + override var isFlipped: Bool { true } +} + +// A fitsRows grid laid on a page that scrolls vertically, sized the way DataGridView sizes it. +@MainActor +private final class PagedGrid { + let grid: HostedDataGrid + let page = RecordingPageScrollView(frame: .zero) + private let document = FlippedPageDocument(frame: .zero) + private let gridTop: CGFloat + + init(rows: TableRows, gridTop: CGFloat = 0) { + var configuration = DataGridConfiguration() + configuration.fitsRows = true + grid = HostedDataGrid(rows: rows, configuration: configuration) + self.gridTop = gridTop + page.hasVerticalScroller = true + page.documentView = document + let gridView = grid.controller.view + gridView.removeFromSuperview() + gridView.autoresizingMask = [] + document.addSubview(gridView) + grid.window.contentView = page + fit() + } + + // What SwiftUI does with the published height, applied now. + func fit() { + for _ in 0..<2 { + let height = grid.controller.measuredFittingHeight + grid.controller.view.frame = NSRect(x: 0, y: gridTop, width: page.bounds.width, height: height) + document.frame = NSRect(x: 0, y: 0, width: page.bounds.width, height: gridTop + height + page.bounds.height) + grid.layout() + } + } + + func rowRectOnPage(_ row: Int) -> NSRect { + document.convert(grid.main.rect(ofRow: row), from: grid.main) + } + + func showsOnPage(row: Int) -> Bool { + let rect = rowRectOnPage(row) + let visible = page.documentVisibleRect + return rect.minY >= visible.minY - 0.5 && rect.maxY <= visible.maxY + 0.5 + } + + var gridOffset: CGFloat { + grid.mainScrollView.contentView.bounds.origin.y + grid.controller.headerInset + } + + func close() { + grid.close() + } +} + +@MainActor +struct DataGridFittingHeightTests { + private static var fitsRows: DataGridConfiguration { + var configuration = DataGridConfiguration() + configuration.fitsRows = true + return configuration + } + + private func nextMainTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + + private struct WheelStep { + var phase: NSEvent.Phase = [] + var momentum: NSEvent.Phase = [] + var dx: CGFloat = 0 + var dy: CGFloat = 0 + } + + private static func routedAxes(_ steps: [WheelStep]) -> [DataGridWheelRouting.Axis] { + var routing = DataGridWheelRouting() + return steps.map { routing.axis(phase: $0.phase, momentumPhase: $0.momentum, deltaX: $0.dx, deltaY: $0.dy) } + } + + private func wheel(dx: Int32 = 0, dy: Int32 = 0, phase: CGScrollPhase? = nil, momentum: CGMomentumScrollPhase? = nil) throws -> NSEvent { + let event = try #require(CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: dy, wheel2: dx, wheel3: 0)) + if let phase { + event.setIntegerValueField(.scrollWheelEventScrollPhase, value: Int64(phase.rawValue)) + } + if let momentum { + event.setIntegerValueField(.scrollWheelEventMomentumPhase, value: Int64(momentum.rawValue)) + } + return try #require(NSEvent(cgEvent: event)) + } + + // MARK: - Height + + @Test("A grid is as tall as its header and rows") + func heightIsHeaderPlusRows() { + let height = DataGridMetrics.fittingHeight(headerHeight: 28, rowCount: 5, rowPitch: 24, horizontalScrollerHeight: 0) + + #expect(height == 148) + } + + @Test("A legacy horizontal scroller adds its height below the rows") + func legacyScrollerAddsItsHeight() { + let height = DataGridMetrics.fittingHeight(headerHeight: 28, rowCount: 5, rowPitch: 24, horizontalScrollerHeight: 15) + + #expect(height == 163) + } + + @Test("A grid with no rows is as tall as its header") + func emptyGridIsItsHeader() { + #expect(DataGridMetrics.fittingHeight(headerHeight: 28, rowCount: 0, rowPitch: 24, horizontalScrollerHeight: 0) == 28) + #expect(DataGridMetrics.fittingHeight(headerHeight: 28, rowCount: -1, rowPitch: 24, horizontalScrollerHeight: 0) == 28) + } + + @Test("A grid scrolls vertically itself unless it is told to fit its rows") + func fitsRowsIsOffByDefault() { + #expect(!DataGridConfiguration().fitsRows) + } + + @Test("A grid that fits its rows publishes their height and follows inserts and removes") + func publishedHeightFollowsTheRows() async throws { + let grid = HostedDataGrid(rows: GridFixtureRows.people, configuration: Self.fitsRows) + defer { grid.close() } + let pitch = grid.main.rowHeight + grid.main.intercellSpacing.height + let header = try #require(grid.main.headerView).frame.height + + try await waitUntil { grid.model.fittingHeight == header + 5 * pitch + grid.mainScrollView.legacyHorizontalScrollerHeight } + let fiveRows = grid.model.fittingHeight + + grid.model.apply(grid.owner.tableRows.appendInsertedRow(values: [.text("5"), .text("fay"), .text("29")])) + try await waitUntil { grid.model.fittingHeight == fiveRows + pitch } + + grid.model.apply(grid.owner.tableRows.remove(rowIDs: [.existing(0), .existing(1)])) + try await waitUntil { grid.model.fittingHeight == fiveRows - pitch } + } + + @Test("A grid that fits its rows drops its vertical scroller and bounce and keeps its horizontal scroll") + func fitsRowsScrollsOnlySideways() { + let grid = HostedDataGrid(rows: GridFixtureRows.people, configuration: Self.fitsRows) + defer { grid.close() } + + #expect(!grid.mainScrollView.hasVerticalScroller) + #expect(grid.mainScrollView.verticalScrollElasticity == .none) + #expect(grid.mainScrollView.hasHorizontalScroller) + #expect(grid.mainScrollView.horizontalScrollElasticity != .none) + } + + @Test("A grid that scrolls itself keeps its scrollers and never publishes a height") + func defaultGridIsUnchanged() async { + let grid = HostedDataGrid(rows: GridFixtureRows.people) + defer { grid.close() } + + await nextMainTurn() + + #expect(grid.mainScrollView.hasVerticalScroller) + #expect(grid.mainScrollView.verticalScrollElasticity == .automatic) + #expect(grid.model.fittingHeight == 0) + } + + @Test("Turning fitsRows on for a mounted grid removes its vertical scroller and publishes its height") + func turningFitsRowsOnApplies() async throws { + let grid = HostedDataGrid(rows: GridFixtureRows.people) + defer { grid.close() } + + grid.model.configuration.fitsRows = true + + #expect(!grid.mainScrollView.hasVerticalScroller) + try await waitUntil { grid.model.fittingHeight == grid.controller.measuredFittingHeight } + #expect(grid.model.fittingHeight > 0) + } + + // MARK: - Wheel axis + + @Test("A mouse wheel decides its axis notch by notch") + func mouseWheelDecidesPerEvent() { + let axes = Self.routedAxes([ + WheelStep(dy: -10), + WheelStep(dx: 12, dy: 1), + WheelStep(dx: 1, dy: 6), + ]) + + #expect(axes == [.vertical, .horizontal, .vertical]) + } + + @Test("A sideways swipe stays sideways through its later moves and momentum") + func horizontalGestureKeepsItsAxis() { + let axes = Self.routedAxes([ + WheelStep(phase: .began, dx: 9, dy: 2), + WheelStep(phase: .changed, dx: 1, dy: 9), + WheelStep(phase: .ended), + WheelStep(momentum: .began, dx: 1, dy: 9), + WheelStep(momentum: .changed, dy: 9), + WheelStep(momentum: .ended), + ]) + + #expect(axes == Array(repeating: .horizontal, count: 6)) + } + + @Test("A vertical swipe stays vertical through its later moves") + func verticalGestureKeepsItsAxis() { + let axes = Self.routedAxes([ + WheelStep(phase: .began, dx: 1, dy: 8), + WheelStep(phase: .changed, dx: 9, dy: 1), + WheelStep(momentum: .began, dx: 9), + ]) + + #expect(axes == [.vertical, .vertical, .vertical]) + } + + @Test("Each swipe decides again, and a resting touch leaves the decision to the first move") + func eachGestureDecidesAgain() { + let axes = Self.routedAxes([ + WheelStep(phase: .began, dx: 1, dy: 8), + WheelStep(phase: .ended), + WheelStep(phase: .mayBegin), + WheelStep(phase: .began), + WheelStep(phase: .changed, dx: 7, dy: 1), + WheelStep(phase: .changed, dy: 7), + ]) + + #expect(axes == [.vertical, .vertical, .vertical, .vertical, .horizontal, .horizontal]) + } + + // MARK: - Page + + @Test("A vertical wheel over a grid that fits its rows scrolls the page, a sideways one the grid") + func verticalWheelGoesToThePage() throws { + let paged = PagedGrid(rows: GridFixtureRows.wide(rows: 30, columns: 3)) + defer { paged.close() } + + paged.grid.mainScrollView.scrollWheel(with: try wheel(dy: -10)) + #expect(paged.page.wheelEvents.count == 1) + + paged.grid.mainScrollView.scrollWheel(with: try wheel(dx: -12, dy: 1)) + #expect(paged.page.wheelEvents.count == 1) + + paged.grid.frozenScrollView.scrollWheel(with: try wheel(dy: 10)) + #expect(paged.page.wheelEvents.count == 2) + } + + @Test("A swipe goes to one owner for its whole length") + func gestureIsNotSplit() throws { + let paged = PagedGrid(rows: GridFixtureRows.wide(rows: 30, columns: 3)) + defer { paged.close() } + let scrollView = paged.grid.mainScrollView + + scrollView.scrollWheel(with: try wheel(dx: -9, dy: 2, phase: .began)) + scrollView.scrollWheel(with: try wheel(dx: -1, dy: 9, phase: .changed)) + scrollView.scrollWheel(with: try wheel(phase: .ended)) + scrollView.scrollWheel(with: try wheel(dx: -1, dy: 9, momentum: .begin)) + #expect(paged.page.wheelEvents.isEmpty) + + scrollView.scrollWheel(with: try wheel(dx: 1, dy: -8, phase: .began)) + scrollView.scrollWheel(with: try wheel(dx: -9, dy: 1, phase: .changed)) + scrollView.scrollWheel(with: try wheel(dx: -9, dy: 1, momentum: .continuous)) + #expect(paged.page.wheelEvents.count == 3) + } + + @Test("Revealing a row scrolls the page to it and leaves the grid at its first row") + func revealScrollsThePage() { + let paged = PagedGrid(rows: GridFixtureRows.wide(rows: 30, columns: 3)) + defer { paged.close() } + #expect(!paged.showsOnPage(row: 29)) + + paged.grid.model.reveal(rowID: .existing(29)) + + #expect(paged.showsOnPage(row: 29)) + #expect(paged.gridOffset == 0) + } + + // AppKit asks the table to keep a header-spanning rect in view while the pane resizes; that moved the page before. + @Test("A resize's own scrollToVisible on the table leaves the page where it is") + func appKitScrollToVisibleLeavesThePage() { + let paged = PagedGrid(rows: GridFixtureRows.wide(rows: 30, columns: 3), gridTop: 80) + defer { paged.close() } + let before = paged.page.documentVisibleRect.minY + + _ = paged.grid.main.scrollToVisible(NSRect(x: 0, y: -28, width: 600, height: 292)) + + #expect(paged.page.documentVisibleRect.minY == before) + } + + @Test("A row the arrow keys move to is brought onto the page") + func arrowKeyScrollsThePage() throws { + let paged = PagedGrid(rows: GridFixtureRows.wide(rows: 30, columns: 3)) + defer { paged.close() } + paged.grid.focus() + paged.grid.model.selectCell(GridCoord(row: 25, displayColumn: 0)) + #expect(!paged.showsOnPage(row: 26)) + + let downArrow = try #require(UnicodeScalar(NSDownArrowFunctionKey)) + try #require(paged.grid.press(String(Character(downArrow)), keyCode: KeyCode.downArrow.rawValue)) + + #expect(paged.grid.main.selectedRow == 26) + #expect(paged.showsOnPage(row: 26)) + #expect(paged.gridOffset == 0) + } + + @Test("A row the grid just took is revealed once the page has grown to it") + func revealWaitsForTheGridToGrow() async throws { + let paged = PagedGrid(rows: GridFixtureRows.wide(rows: 30, columns: 3)) + defer { paged.close() } + + paged.grid.model.apply(paged.grid.owner.tableRows.appendInsertedRow(values: Array(repeating: PluginCellValue.text("new"), count: 3))) + let inserted = try #require(paged.grid.model.rowID(at: 30)) + paged.grid.model.reveal(rowID: inserted) + #expect(paged.gridOffset == 0) + + paged.fit() + + try await waitUntil { paged.showsOnPage(row: 30) } + #expect(paged.gridOffset == 0) + } +} diff --git a/apple/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/apple/TableProTests/Views/Main/CommandActionsDispatchTests.swift index ccc647cc31..0e44f56a86 100644 --- a/apple/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/apple/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -116,111 +116,81 @@ extension GlobalStateSuites { #expect(coordinator.tabManager.selectedTab?.content.query == "SELECT 2") } - // MARK: - copySelectedRows (structure mode) + // MARK: - Structure mode - @Test("copySelectedRows in structure mode calls structureActions.copyRows") - func copySelectedRows_structureMode_callsStructureActions() { - let (actions, coordinator) = makeSUT() + private func showStructure( + of coordinator: MainContentCoordinator + ) throws -> StructureEditingSession { coordinator.tabManager.addTab(databaseName: "testdb") - - // Enable structure mode on the selected tab - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure - } - - // Install a spy handler - let handler = StructureViewActionHandler() - var copyRowsCalled = false - handler.copyRows = { copyRowsCalled = true } - coordinator.structureActions = handler - - actions.copySelectedRows() - - #expect(copyRowsCalled) + let index = try #require(coordinator.tabManager.selectedTabIndex) + coordinator.tabManager.tabs[index].display.resultsViewMode = .structure + let session = TestFixtures.makeStructureSession() + session.changeManager.loadSchema( + tableName: "users", columns: [], indexes: [], foreignKeys: [], primaryKey: [] + ) + session.markBaselineAdopted() + coordinator.structureSessions[coordinator.tabManager.tabs[index].id] = session + return session } - // MARK: - pasteRows (structure mode) - - @Test("pasteRows in structure mode calls structureActions.pasteRows") - func pasteRows_structureMode_callsStructureActions() { + @Test("Add Row in Structure stages a column on the tab's session") + func addNewRow_structureMode_stagesOnTheSession() throws { let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") - - // Enable structure mode on the selected tab - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure - } - - // Install a spy handler - let handler = StructureViewActionHandler() - var pasteRowsCalled = false - handler.pasteRows = { pasteRowsCalled = true } - coordinator.structureActions = handler + let session = try showStructure(of: coordinator) + #expect(actions.selectionOwner == .schemaGrid) + #expect(actions.isCurrentTabEditable) + #expect(actions.isCurrentTabSchemaResolved) - actions.pasteRows() + actions.addNewRow() - #expect(pasteRowsCalled) + #expect(session.changeManager.workingColumns.count == 1) + #expect(session.hasChanges) } - // MARK: - addNewRow (structure mode) - - @Test("addNewRow in structure mode calls structureActions.addRow") - func addNewRow_structureMode_callsStructureActions() { + // Cmd+Shift+D reached only the data grid, so it did nothing in Structure. + @Test("Duplicate Row in Structure duplicates the selected column") + func duplicateRow_structureMode_duplicatesTheSelection() throws { let (actions, coordinator) = makeSUT() - coordinator.tabManager.addTab(databaseName: "testdb") + let session = try showStructure(of: coordinator) + actions.addNewRow() + try #require(session.editor.focusedSection == .columns) - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure - } + actions.duplicateRow() - let handler = StructureViewActionHandler() - var addRowCalled = false - handler.addRow = { addRowCalled = true } - coordinator.structureActions = handler + #expect(session.changeManager.workingColumns.count == 2) + } + @Test("Delete in Structure removes the selected column") + func deleteSelectedRows_structureMode_removesTheSelection() throws { + let (actions, coordinator) = makeSUT() + let session = try showStructure(of: coordinator) actions.addNewRow() + try #require(actions.hasRowSelection) - #expect(addRowCalled) - } + actions.deleteSelectedRows() - // MARK: - deleteSelectedRows (structure mode) + #expect(session.changeManager.workingColumns.isEmpty) + } - @Test("deleteSelectedRows in structure mode calls structureActions.removeRow") - func deleteSelectedRows_structureMode_callsStructureActions() { + @Test("A Structure view with no session takes no row commands") + func structureModeWithoutASession_ownsNoSelection() throws { let (actions, coordinator) = makeSUT() coordinator.tabManager.addTab(databaseName: "testdb") + let index = try #require(coordinator.tabManager.selectedTabIndex) + coordinator.tabManager.tabs[index].display.resultsViewMode = .structure - if let idx = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[idx].display.resultsViewMode = .structure - } - - let handler = StructureViewActionHandler() - var removeRowCalled = false - handler.removeRow = { removeRowCalled = true } - coordinator.structureActions = handler - - actions.deleteSelectedRows() - - #expect(removeRowCalled) + #expect(actions.selectionOwner == .none) + #expect(!actions.isCurrentTabEditable) } // MARK: - saveChanges (createTable tab) - // The structure arm below would otherwise apply the staged edits of whatever session the tab id carries. - @Test("saveChanges dispatches createTableActions when the selected tab is createTable") - func saveChanges_createTableTab_callsCreateTableAction() { + // The structure arm would otherwise apply the staged edits of whatever session the tab id carries. + @Test("saveChanges on a createTable tab never applies a structure session's edits") + func saveChanges_createTableTab_leavesStructureSessionAlone() throws { let (actions, coordinator) = makeSUT() coordinator.tabManager.addCreateTableTab(databaseName: "testdb") - guard let id = coordinator.tabManager.selectedTabId else { - Issue.record("expected a selected tab") - return - } - - let createHandler = CreateTableActionHandler() - var createCalled = false - createHandler.createTable = { createCalled = true } - coordinator.createTableActions = createHandler - + let id = try #require(coordinator.tabManager.selectedTabId) let session = TestFixtures.makeStructureSession() coordinator.structureSessions[id] = session session.changeManager.loadSchema( @@ -230,25 +200,31 @@ extension GlobalStateSuites { actions.saveChanges() - #expect(createCalled) #expect(session.appliedVersion == 0) #expect(session.changeManager.hasChanges) } - @Test("saveChanges without createTableActions is a no-op for a createTable tab") - func saveChanges_createTableTab_withoutAction_doesNothing() { + // Leaving Structure for Data used to dim Save while the session still held its ALTERs. + @Test("From the Data view, Save reaches staged structure edits only when no grid edit is ahead of them") + func stagedStructureSession_followsTheDataView() throws { let (actions, coordinator) = makeSUT() - coordinator.tabManager.addCreateTableTab(databaseName: "testdb") - guard let id = coordinator.tabManager.selectedTabId else { - Issue.record("expected a selected tab") - return - } - let session = TestFixtures.makeStructureSession() - coordinator.structureSessions[id] = session + let session = try showStructure(of: coordinator) + session.changeManager.addNewColumn() + let index = try #require(coordinator.tabManager.selectedTabIndex) + coordinator.tabManager.tabs[index].display.resultsViewMode = .data - actions.saveChanges() + #expect(actions.stagedStructureSession === session) - #expect(session.appliedVersion == 0) + coordinator.changeManager.configureForTable( + tableName: "users", + columns: ["id"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [] + ) + coordinator.changeManager.recordRowDeletion(rowID: .existing(0), originalRow: [.text("1")]) + + #expect(actions.stagedStructureSession == nil) } // MARK: - Row selection resolution diff --git a/apple/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift b/apple/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift index 6c0c822cd1..b1056c17ac 100644 --- a/apple/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift +++ b/apple/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import TableProTestSupport import Testing @testable import TablePro @@ -298,27 +299,21 @@ struct MainContentCoordinatorRefreshTests { } } - @Test("Refresh in structure view dispatches to the structure refresh handler") - func refreshInStructureViewDispatchesToHandler() { + @Test("Refresh in structure view reloads the tab's structure session, not its rows") + func refreshInStructureViewReloadsTheSession() async throws { let (coordinator, tabManager) = makeCoordinator() let tabId = addTableTab(to: tabManager) - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - Issue.record("expected tab to exist") - return - } + let idx = try #require(tabManager.tabs.firstIndex(where: { $0.id == tabId })) tabManager.tabs[idx].display.resultsViewMode = .structure - - let handler = StructureViewActionHandler() - var refreshCalled = false - handler.refresh = { refreshCalled = true } - coordinator.structureActions = handler + let session = TestFixtures.makeStructureSession() + coordinator.structureSessions[tabId] = session let initialEpoch = coordinator.tabExecution.contentEpoch(for: tabId) coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) - #expect(refreshCalled == true) #expect(coordinator.tabExecution.contentEpoch(for: tabId) == initialEpoch) #expect(coordinator.queryTasks.hasTask(for: tabId) == false) + try await waitUntil { session.partsReloadToken == 1 } } @Test("requestRefresh fires immediately and coalesces a rapid second call") diff --git a/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index 589d44e259..e043f49c1f 100644 --- a/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/apple/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -28,7 +28,6 @@ struct ResultStatusBarLayoutTests { viewMode: ResultsViewMode, pagination: PaginationState = PaginationState(), statusMessage: String? = nil, - structureFooter: StructureFooterCapability = StructureFooterCapability(), selectionSummary: SelectionSummaryState? = nil, lastTiming: PluginQueryTiming? = nil ) -> ResultStatusBar { @@ -44,7 +43,6 @@ struct ResultStatusBarLayoutTests { hasTableName: tabType == .table, hasColumns: hasColumns ), - hasStructureActions: structureFooter.isActive, pagination: pagination, statusMessage: statusMessage ) @@ -82,7 +80,6 @@ struct ResultStatusBarLayoutTests { onGoToPage: { _ in }, onRequestExactCount: {} ), - structureFooter: structureFooter, execution: ExecutionReadout( tabId: UUID(), execution: TabExecutionRegistry(), @@ -99,9 +96,7 @@ struct ResultStatusBarLayoutTests { onCloseResultSet: { _ in }, onCloseOtherResultSets: { _ in }, onToggleFilters: {}, - onFetchAll: {}, - onStructureAdd: {}, - onStructureRemove: {} + onFetchAll: {} ) } @@ -290,13 +285,7 @@ struct ResultStatusBarLayoutTests { rowCount: 1_000, hasColumns: true, tabType: .table, - viewMode: .structure, - structureFooter: StructureFooterCapability( - canAdd: true, - canRemove: true, - addLabel: "Add Column", - removeLabel: "Remove Column" - ) + viewMode: .structure ) let host = NSHostingView(rootView: bar) host.sizingOptions = [] @@ -357,7 +346,6 @@ struct ResultStatusBarLayoutTests { onGoToPage: { _ in }, onRequestExactCount: {} ), - structureFooter: StructureFooterCapability(), execution: ExecutionReadout(tabId: tab.id, execution: registry, lastTiming: timing, onCancel: {}), selectionSummary: SelectionSummaryState(), isRefreshingSchema: false, @@ -369,9 +357,7 @@ struct ResultStatusBarLayoutTests { onCloseResultSet: { _ in }, onCloseOtherResultSets: { _ in }, onToggleFilters: {}, - onFetchAll: {}, - onStructureAdd: {}, - onStructureRemove: {} + onFetchAll: {} ) } @@ -438,13 +424,7 @@ struct ResultStatusBarLayoutTests { rowCount: 1_000, hasColumns: true, tabType: .table, - viewMode: .structure, - structureFooter: StructureFooterCapability( - canAdd: true, - canRemove: true, - addLabel: "Add Column", - removeLabel: "Remove Column" - ) + viewMode: .structure ) expectFills(bar, at: width) } diff --git a/apple/TableProTests/Views/Main/StructureActionHandlerTests.swift b/apple/TableProTests/Views/Main/StructureActionHandlerTests.swift deleted file mode 100644 index be1632f54d..0000000000 --- a/apple/TableProTests/Views/Main/StructureActionHandlerTests.swift +++ /dev/null @@ -1,220 +0,0 @@ -// -// StructureActionHandlerTests.swift -// TableProTests -// -// Tests for StructureViewActionHandler closure dispatch and coordinator integration. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@MainActor -struct StructureActionHandlerTests { - // MARK: - Helpers - - private func makeCoordinator() -> MainContentCoordinator { - let connection = TestFixtures.makeConnection() - let state = SessionStateFactory.create(connection: connection, payload: nil) - return state.coordinator - } - - // MARK: - Individual Closure Dispatch - - @Test("refresh closure fires when invoked") - func refresh_fires() { - let handler = StructureViewActionHandler() - var count = 0 - handler.refresh = { count += 1 } - - handler.refresh?() - - #expect(count == 1) - } - - @Test("previewSQL closure fires when invoked") - func previewSQL_fires() { - let handler = StructureViewActionHandler() - var count = 0 - handler.previewSQL = { count += 1 } - - handler.previewSQL?() - - #expect(count == 1) - } - - @Test("copyRows closure fires when invoked") - func copyRows_fires() { - let handler = StructureViewActionHandler() - var count = 0 - handler.copyRows = { count += 1 } - - handler.copyRows?() - - #expect(count == 1) - } - - @Test("pasteRows closure fires when invoked") - func pasteRows_fires() { - let handler = StructureViewActionHandler() - var count = 0 - handler.pasteRows = { count += 1 } - - handler.pasteRows?() - - #expect(count == 1) - } - - @Test("undo closure fires when invoked") - func undo_fires() { - let handler = StructureViewActionHandler() - var count = 0 - handler.undo = { count += 1 } - - handler.undo?() - - #expect(count == 1) - } - - @Test("redo closure fires when invoked") - func redo_fires() { - let handler = StructureViewActionHandler() - var count = 0 - handler.redo = { count += 1 } - - handler.redo?() - - #expect(count == 1) - } - - @Test("addRow closure fires when invoked") - func addRow_fires() { - let handler = StructureViewActionHandler() - var count = 0 - handler.addRow = { count += 1 } - - handler.addRow?() - - #expect(count == 1) - } - - @Test("removeRow closure fires when invoked") - func removeRow_fires() { - let handler = StructureViewActionHandler() - var count = 0 - handler.removeRow = { count += 1 } - - handler.removeRow?() - - #expect(count == 1) - } - - // MARK: - All Closures Fire Independently - - @Test("all closures fire independently without cross-talk") - func allClosures_fireIndependently() { - let handler = StructureViewActionHandler() - var counts = [String: Int]() - - handler.refresh = { counts["refresh", default: 0] += 1 } - handler.previewSQL = { counts["previewSQL", default: 0] += 1 } - handler.copyRows = { counts["copyRows", default: 0] += 1 } - handler.pasteRows = { counts["pasteRows", default: 0] += 1 } - handler.undo = { counts["undo", default: 0] += 1 } - handler.redo = { counts["redo", default: 0] += 1 } - handler.addRow = { counts["addRow", default: 0] += 1 } - handler.removeRow = { counts["removeRow", default: 0] += 1 } - - handler.refresh?() - handler.previewSQL?() - handler.copyRows?() - handler.pasteRows?() - handler.undo?() - handler.redo?() - handler.addRow?() - handler.removeRow?() - - #expect(counts["refresh"] == 1) - #expect(counts["previewSQL"] == 1) - #expect(counts["copyRows"] == 1) - #expect(counts["pasteRows"] == 1) - #expect(counts["undo"] == 1) - #expect(counts["redo"] == 1) - #expect(counts["addRow"] == 1) - #expect(counts["removeRow"] == 1) - } - - // MARK: - Coordinator Integration - - @Test("coordinator.structureActions dispatches refresh closure") - func coordinator_dispatchesSaveChanges() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } - - let handler = StructureViewActionHandler() - var count = 0 - handler.refresh = { count += 1 } - coordinator.structureActions = handler - - coordinator.structureActions?.refresh?() - - #expect(count == 1) - } - - @Test("coordinator.structureActions dispatches all closures") - func coordinator_dispatchesAllClosures() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } - - let handler = StructureViewActionHandler() - var counts = [String: Int]() - - handler.refresh = { counts["refresh", default: 0] += 1 } - handler.previewSQL = { counts["previewSQL", default: 0] += 1 } - handler.copyRows = { counts["copyRows", default: 0] += 1 } - handler.pasteRows = { counts["pasteRows", default: 0] += 1 } - handler.undo = { counts["undo", default: 0] += 1 } - handler.redo = { counts["redo", default: 0] += 1 } - - coordinator.structureActions = handler - - coordinator.structureActions?.refresh?() - coordinator.structureActions?.previewSQL?() - coordinator.structureActions?.copyRows?() - coordinator.structureActions?.pasteRows?() - coordinator.structureActions?.undo?() - coordinator.structureActions?.redo?() - - #expect(counts["refresh"] == 1) - #expect(counts["previewSQL"] == 1) - #expect(counts["copyRows"] == 1) - #expect(counts["pasteRows"] == 1) - #expect(counts["undo"] == 1) - #expect(counts["redo"] == 1) - } - - // MARK: - Weak Reference Nil-Out - - @Test("closures no longer fire after coordinator.structureActions is set to nil") - func coordinatorNilOut_closuresNoLongerFire() { - let coordinator = makeCoordinator() - defer { coordinator.teardown() } - - let handler = StructureViewActionHandler() - var count = 0 - handler.refresh = { count += 1 } - coordinator.structureActions = handler - - // Verify it works before nil-out - coordinator.structureActions?.refresh?() - #expect(count == 1) - - // Nil out the weak reference - coordinator.structureActions = nil - - // Calling through coordinator should be a no-op now - coordinator.structureActions?.refresh?() - #expect(count == 1) - } -} diff --git a/apple/TableProTests/Views/Main/StructureCommandRoutingTests.swift b/apple/TableProTests/Views/Main/StructureCommandRoutingTests.swift new file mode 100644 index 0000000000..f1298e52f6 --- /dev/null +++ b/apple/TableProTests/Views/Main/StructureCommandRoutingTests.swift @@ -0,0 +1,187 @@ +// +// StructureCommandRoutingTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct StructureCommandRoutingTests { + private func makeSUT() -> (MainContentCommandActions, MainContentCoordinator) { + let connection = TestFixtures.makeConnection() + let coordinator = SessionStateFactory.create(connection: connection, payload: nil).coordinator + var selectedTables: Set = [] + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] + let actions = MainContentCommandActions( + coordinator: coordinator, + connection: connection, + selectedTables: Binding(get: { selectedTables }, set: { selectedTables = $0 }), + pendingTruncates: Binding(get: { pendingTruncates }, set: { pendingTruncates = $0 }), + pendingDeletes: Binding(get: { pendingDeletes }, set: { pendingDeletes = $0 }), + tableOperationOptions: Binding(get: { tableOperationOptions }, set: { tableOperationOptions = $0 }), + trailingPaneState: TrailingPaneState() + ) + return (actions, coordinator) + } + + private func showStructure(of coordinator: MainContentCoordinator) throws -> StructureEditingSession { + coordinator.tabManager.addTab(databaseName: "testdb") + let index = try #require(coordinator.tabManager.selectedTabIndex) + coordinator.tabManager.tabs[index].display.resultsViewMode = .structure + let session = TestFixtures.makeStructureSession() + session.changeManager.loadSchema( + tableName: "users", columns: [], indexes: [], foreignKeys: [], primaryKey: [] + ) + coordinator.structureSessions[coordinator.tabManager.tabs[index].id] = session + return session + } + + private func showCreateTable(in coordinator: MainContentCoordinator) throws -> CreateTableDraft { + coordinator.tabManager.addCreateTableTab(databaseName: "testdb") + let id = try #require(coordinator.tabManager.selectedTabId) + let draft = CreateTableDraft(connection: TestFixtures.makeConnection(), scope: nil) + draft.resolveForm(from: nil) + coordinator.createTableDrafts[id] = draft + return draft + } + + private func stageCellEdit(on coordinator: MainContentCoordinator) { + coordinator.changeManager.configureForTable( + tableName: "users", + columns: ["id", "name"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [] + ) + coordinator.changeManager.recordRowDeletion(rowID: .existing(0), originalRow: [.text("1"), .text("ada")]) + } + + // MARK: - Undo + + // The menu used to stay lit in Structure whenever the view was mounted, with nothing to undo. + @Test("Undo in Structure is dim and plainly titled with nothing staged") + func structureUndoIsDimWithNothingStaged() throws { + let (actions, coordinator) = makeSUT() + _ = try showStructure(of: coordinator) + + #expect(!actions.canUndo) + #expect(!actions.canRedo) + #expect(actions.resolvedUndoTitle == String(localized: "Undo")) + #expect(actions.resolvedRedoTitle == String(localized: "Redo")) + } + + @Test("Undo in Structure is named and run by the session's own history") + func structureUndoFollowsTheSession() throws { + let (actions, coordinator) = makeSUT() + let session = try showStructure(of: coordinator) + session.changeManager.addNewColumn() + + #expect(actions.canUndo) + #expect(actions.resolvedUndoTitle == session.undoMenuItemTitle) + #expect(actions.resolvedUndoTitle != String(localized: "Undo")) + + actions.undoChange() + + #expect(!session.hasChanges) + #expect(!actions.canUndo) + #expect(actions.canRedo) + #expect(actions.resolvedRedoTitle == session.redoMenuItemTitle) + + actions.redoChange() + + #expect(session.hasChanges) + } + + @Test("A session behind the Data view takes no Undo") + func sessionBehindTheDataViewKeepsItsHistory() throws { + let (actions, coordinator) = makeSUT() + let session = try showStructure(of: coordinator) + session.changeManager.addNewColumn() + let index = try #require(coordinator.tabManager.selectedTabIndex) + coordinator.tabManager.tabs[index].display.resultsViewMode = .data + + actions.undoChange() + + #expect(session.hasChanges) + } + + // A Create Table tab keeps its view mode at Data, so Undo read the window's history, which holds none of the draft. + @Test("Undo on a Create Table tab is enabled, named and run by the draft") + func createTableUndoFollowsTheDraft() throws { + let (actions, coordinator) = makeSUT() + let draft = try showCreateTable(in: coordinator) + draft.changeManager.addNewColumn() + + #expect(actions.canUndo == draft.canUndo) + #expect(actions.canUndo) + #expect(actions.resolvedUndoTitle == draft.undoMenuItemTitle) + + actions.undoChange() + + #expect(actions.canRedo) + #expect(actions.resolvedRedoTitle == draft.redoMenuItemTitle) + } + + // MARK: - Discard Changes + + @Test("Discard Changes follows the session's staged edits") + func discardFollowsStagedStructureEdits() throws { + let (actions, coordinator) = makeSUT() + let session = try showStructure(of: coordinator) + #expect(!actions.canDiscardChanges) + + session.changeManager.addNewColumn() + #expect(actions.canDiscardChanges) + + session.discardChanges() + #expect(!actions.canDiscardChanges) + } + + @Test("Discard Changes clears grid edits") + func discardClearsGridEdits() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(databaseName: "testdb") + #expect(!actions.canDiscardChanges) + + stageCellEdit(on: coordinator) + #expect(actions.canDiscardChanges) + + actions.discardDataWork() + + #expect(!coordinator.changeManager.hasChanges) + #expect(!actions.canDiscardChanges) + } + + @Test("Discard Changes is not offered on a Create Table tab") + func discardIsNotOfferedForCreateTable() throws { + let (actions, coordinator) = makeSUT() + let draft = try showCreateTable(in: coordinator) + draft.tableName = "invoices" + + #expect(draft.holdsWork) + #expect(!actions.canDiscardChanges) + } + + @Test("Discard Changes needs a connection and something to discard") + func discardValidation() { + let selector = #selector(MainSplitViewController.discardChanges(_:)) + var context = MenuValidationContext() + #expect(MainSplitViewController.resolvedEnablement(selector, context: context) == false) + + context.isConnected = true + #expect(MainSplitViewController.resolvedEnablement(selector, context: context) == false) + + context.canDiscardChanges = true + #expect(MainSplitViewController.resolvedEnablement(selector, context: context) == true) + + context.isAgentMode = true + #expect(MainSplitViewController.resolvedEnablement(selector, context: context) == false) + } +} diff --git a/apple/TableProTests/Views/Main/TabCloseProtectionTests.swift b/apple/TableProTests/Views/Main/TabCloseProtectionTests.swift index ae9ea440f1..5a6d165dc8 100644 --- a/apple/TableProTests/Views/Main/TabCloseProtectionTests.swift +++ b/apple/TableProTests/Views/Main/TabCloseProtectionTests.swift @@ -228,7 +228,7 @@ struct TabCloseProtectionTests { var tab = QueryTab(query: "") tab.tabType = .createTable - let draft = CreateTableDraft() + let draft = CreateTableDraft(connection: TestFixtures.makeConnection(), scope: nil) draft.changeManager.addNewColumn() coordinator.createTableDrafts[tab.id] = draft @@ -244,7 +244,7 @@ struct TabCloseProtectionTests { var tab = QueryTab(query: "") tab.tabType = .createTable - let draft = CreateTableDraft() + let draft = CreateTableDraft(connection: TestFixtures.makeConnection(), scope: nil) draft.tableName = "invoices" coordinator.createTableDrafts[tab.id] = draft @@ -260,7 +260,7 @@ struct TabCloseProtectionTests { var tab = QueryTab(query: "") tab.tabType = .createTable - let draft = CreateTableDraft() + let draft = CreateTableDraft(connection: TestFixtures.makeConnection(), scope: nil) draft.changeManager.addNewColumn() coordinator.createTableDrafts[tab.id] = draft @@ -382,7 +382,7 @@ struct TabCloseProtectionTests { return } coordinator.structureSessions[id] = TestFixtures.makeStructureSession() - coordinator.createTableDrafts[id] = CreateTableDraft() + coordinator.createTableDrafts[id] = CreateTableDraft(connection: TestFixtures.makeConnection(), scope: nil) coordinator.closeTabsByUser(ids: [id]) diff --git a/apple/TableProTests/Views/Main/TriggerStructTests.swift b/apple/TableProTests/Views/Main/TriggerStructTests.swift index 0609029a78..fc31f9b8fc 100644 --- a/apple/TableProTests/Views/Main/TriggerStructTests.swift +++ b/apple/TableProTests/Views/Main/TriggerStructTests.swift @@ -79,6 +79,15 @@ struct InspectorTriggerTests { func differentGridDisplayRevision() { #expect(trigger(gridDisplayRevision: 0) != trigger(gridDisplayRevision: 1)) } + + // A Structure view's session appears after its view first mounts, and the inspector has to pick it up. + @Test("A structure editor appearing produces unequal triggers") + func structureEditorAppearing() { + let editor = NSObject() + var withEditor = trigger(resultsViewMode: .structure) + withEditor.structureEditor = ObjectIdentifier(editor) + #expect(trigger(resultsViewMode: .structure) != withEditor) + } } // MARK: - PendingChangeTrigger Tests diff --git a/apple/TableProTests/Views/Main/UserTypeTabStateTests.swift b/apple/TableProTests/Views/Main/UserTypeTabStateTests.swift index c0cf1eb172..f78aa251c2 100644 --- a/apple/TableProTests/Views/Main/UserTypeTabStateTests.swift +++ b/apple/TableProTests/Views/Main/UserTypeTabStateTests.swift @@ -30,7 +30,6 @@ struct UserTypeTabStateTests { model.stage(.addEnumLabel("glad", placement: nil)) fixture.coordinator.publishUserTypeEditorState(tabId: tab.id, hasChanges: model.hasChanges) - fixture.coordinator.toolbarState.hasStructureChanges = false #expect(fixture.coordinator.toolbarState.hasTypeChanges) } diff --git a/apple/TableProTests/Views/RowInspector/InspectorPopOutWindowTests.swift b/apple/TableProTests/Views/RowInspector/InspectorPopOutWindowTests.swift index 1d60b3681c..14bbd4b405 100644 --- a/apple/TableProTests/Views/RowInspector/InspectorPopOutWindowTests.swift +++ b/apple/TableProTests/Views/RowInspector/InspectorPopOutWindowTests.swift @@ -33,6 +33,5 @@ struct InspectorPopOutWindowTests { #expect(InspectorPopOutWindow.resolve(for: .binaryValue(.protobuf)) == .binary(.protobuf)) #expect(InspectorPopOutWindow.resolve(for: .multiLine) == .text) #expect(InspectorPopOutWindow.resolve(for: .singleLine) == .text) - #expect(InspectorPopOutWindow.resolve(for: .schemaText) == .text) } } diff --git a/apple/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift b/apple/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift index b60f847094..dae89e5df5 100644 --- a/apple/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift +++ b/apple/TableProTests/Views/RowInspector/InspectorValueThemeTests.swift @@ -33,7 +33,7 @@ struct InspectorValueThemeTests { } private func field(_ value: String, kind: FieldEditorKind, state: FieldValueState? = nil) -> FieldEditorContent { - FieldEditorContent(context: context(value, state: state), kind: kind, databaseType: .sqlite) + FieldEditorContent(context: context(value, state: state), kind: kind) } private func pixel(of view: some View, size: CGSize, x: Int, fromBottom: Int? = nil) throws -> NSColor { @@ -85,7 +85,7 @@ struct InspectorValueThemeTests { valueState: .value(value.wrappedValue), isReadOnly: false ) - return FieldEditorContent(context: context, kind: kind, databaseType: .sqlite) + return FieldEditorContent(context: context, kind: kind) } private func editableField(_ value: String, kind: FieldEditorKind = .singleLine) -> FieldEditorContent { diff --git a/apple/TableProTests/Views/RowInspector/SplitItemGlassBackgroundTests.swift b/apple/TableProTests/Views/RowInspector/SplitItemGlassBackgroundTests.swift index 807aa7c5af..be514ed669 100644 --- a/apple/TableProTests/Views/RowInspector/SplitItemGlassBackgroundTests.swift +++ b/apple/TableProTests/Views/RowInspector/SplitItemGlassBackgroundTests.swift @@ -31,7 +31,7 @@ struct SplitItemGlassBackgroundTests { columnTypes: [.integer(rawType: "INTEGER"), .text(rawType: "TEXT")], externallyModifiedColumns: [] ) - return InspectorFieldListView(editState: state, isEditable: true, databaseType: .sqlite, userDefinedTypeScope: nil) + return InspectorFieldListView(editState: state, isEditable: true, databaseType: .sqlite) } @Test("A plain list keeps its material where split items are not glass and drops it where they are") diff --git a/apple/TableProTests/Views/Structure/CreateTableDraftCommandTests.swift b/apple/TableProTests/Views/Structure/CreateTableDraftCommandTests.swift new file mode 100644 index 0000000000..3b8e0e5329 --- /dev/null +++ b/apple/TableProTests/Views/Structure/CreateTableDraftCommandTests.swift @@ -0,0 +1,145 @@ +// +// CreateTableDraftCommandTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct CreateTableDraftCommandTests { + private func makeDraft(database: String = "app", schema: String? = "sales") -> CreateTableDraft { + let connection = TestFixtures.makeConnection(database: "postgres", type: .postgresql) + let draft = CreateTableDraft( + connection: connection, + scope: DatabaseScope(connectionId: connection.id, database: database, schema: schema) + ) + draft.resolveForm(from: nil) + return draft + } + + @Test("Add Row adds a column while nothing is selected") + func addRowDefaultsToColumns() { + let draft = makeDraft() + + #expect(draft.canAddRow) + draft.addRow() + + #expect(draft.changeManager.workingColumns.count == 2) + #expect(draft.editor.inspectorTarget == .entity(.columns, draft.changeManager.workingColumns[1].id)) + } + + @Test("Add Row adds to the section holding the selection") + func addRowFollowsTheFocusedSection() { + let draft = makeDraft() + draft.editor.add(.indexes) + + draft.addRow() + + #expect(draft.changeManager.workingIndexes.count == 2) + #expect(draft.changeManager.workingColumns.count == 1) + #expect(draft.commandSection == .indexes) + } + + @Test("Add Row does nothing while the DDL is on screen") + func addRowNeedsTheDefinition() { + let draft = makeDraft() + draft.pane = .ddl + + draft.addRow() + + #expect(!draft.canAddRow) + #expect(draft.changeManager.workingColumns.count == 1) + } + + @Test("Remove deletes the selected definition, and Undo brings it back") + func removeThenUndo() { + let draft = makeDraft() + draft.addRow() + let added = draft.changeManager.workingColumns[1].id + + #expect(draft.canRemoveRows) + draft.removeSelectedRows() + #expect(draft.changeManager.workingColumns.map { $0.id }.contains(added) == false) + + #expect(draft.canUndo) + draft.undo() + #expect(draft.changeManager.workingColumns.map { $0.id }.contains(added)) + #expect(draft.editor.adapter(for: .columns).visibleRowCount == 2) + } + + @Test("Undo takes back an added row and Redo restores it") + func undoThenRedo() { + let draft = makeDraft() + draft.editor.add(.checkConstraints) + #expect(draft.changeManager.workingCheckConstraints.count == 1) + + draft.undo() + #expect(draft.changeManager.workingCheckConstraints.isEmpty) + #expect(draft.editor.adapter(for: .checkConstraints).visibleRowCount == 0) + + #expect(draft.canRedo) + draft.redo() + #expect(draft.changeManager.workingCheckConstraints.count == 1) + } + + @Test("A typed check counts as work worth protecting") + func checkConstraintHoldsWork() { + let draft = makeDraft() + draft.editor.add(.checkConstraints) + #expect(!draft.holdsWork) + + draft.changeManager.workingCheckConstraints[0].expression = "total > 0" + + #expect(draft.holdsWork) + #expect(draft.hasChanges) + } + + @Test("An issue on a row reveals that row in its section") + func revealingAnIssueSelectsItsRow() throws { + let draft = makeDraft() + draft.tableName = "orders" + draft.changeManager.workingColumns[0].name = "id" + draft.editor.refresh() + draft.pane = .ddl + + let issue = try #require(draft.issues.first { $0.row == 0 }) + draft.reveal(issue) + + #expect(issue.section == .columns) + #expect(draft.pane == .definition) + #expect(draft.editor.inspectorTarget == .entity(.columns, draft.changeManager.workingColumns[0].id)) + } + + @Test("A named table with a typed column is ready to create before the composition lands") + func readyFromThePlan() { + let draft = makeDraft() + #expect(!draft.isReadyToCreate) + + draft.tableName = "orders" + draft.changeManager.workingColumns[0].name = "id" + draft.changeManager.workingColumns[0].dataType = "INT" + + #expect(draft.issues.isEmpty) + #expect(draft.isReadyToCreate) + } + + // The sidebar can browse another database while the tab creates its table in its own (#10 in the verify lane). + @Test("The editor works on the tab's database and schema, never the sidebar's") + func editorUsesTheTabScope() { + let draft = makeDraft(database: "app", schema: "sales") + let scope = DatabaseScope(connectionId: draft.connection.id, database: "app", schema: "sales") + + #expect(draft.editor.context.isCreatingTable) + #expect(draft.editor.context.scope == scope) + #expect(draft.editor.referenceMenus.schemaName == "sales") + #expect(draft.editor.adapter(for: .columns).model.configuration.traits.connectionScope?.database == "app") + + draft.editor.add(.foreignKeys) + _ = draft.editor.adapter(for: .foreignKeys).menuOptions(sourceIndex: 0, field: 1) + + #expect(draft.editor.referenceMenus.origin == scope) + } +} diff --git a/apple/TableProTests/Views/Structure/CreateTableDraftCompositionTests.swift b/apple/TableProTests/Views/Structure/CreateTableDraftCompositionTests.swift index 42c28febea..1ee6ef2e42 100644 --- a/apple/TableProTests/Views/Structure/CreateTableDraftCompositionTests.swift +++ b/apple/TableProTests/Views/Structure/CreateTableDraftCompositionTests.swift @@ -8,14 +8,16 @@ import Testing private final class CompositionDriver: PluginDriverStub, @unchecked Sendable { private(set) var createTableRequests = 0 private var schema: String? + private let addsCheckConstraints: Bool var supportsSchemas: Bool { true } var supportsTransactions: Bool { false } var serverVersion: String? { nil } var currentSchema: String? { schema } - init(currentSchema: String?) { + init(currentSchema: String?, addsCheckConstraints: Bool = true) { self.schema = currentSchema + self.addsCheckConstraints = addsCheckConstraints } func execute(query: String) async throws -> PluginQueryResult { @@ -37,14 +39,23 @@ private final class CompositionDriver: PluginDriverStub, @unchecked Sendable { func objectCommentStatement(name: String, objectType: String, schema: String?, comment: String?) -> String? { "COMMENT ON \(objectType) \"\(schema ?? "")\".\"\(name)\" IS '\(comment ?? "")'" } + + func generateAddCheckConstraintSQL(table: String, constraint: PluginCheckConstraintDefinition) -> String? { + guard addsCheckConstraints else { return nil } + return "ALTER TABLE \"\(schema ?? "")\".\"\(table)\" ADD CONSTRAINT \"\(constraint.name)\" CHECK (\(constraint.expression))" + } } @Suite("Create Table draft composition", .serialized) @MainActor struct CreateTableDraftCompositionTests { - private static func inject(type: DatabaseType, sessionSchema: String) -> (DatabaseConnection, CompositionDriver) { + private static func inject( + type: DatabaseType, + sessionSchema: String, + addsCheckConstraints: Bool = true + ) -> (DatabaseConnection, CompositionDriver) { let connection = TestFixtures.makeConnection(database: "shop", type: type) - let driver = CompositionDriver(currentSchema: sessionSchema) + let driver = CompositionDriver(currentSchema: sessionSchema, addsCheckConstraints: addsCheckConstraints) var session = ConnectionSession( connection: connection, driver: PluginDriverAdapter(connection: connection, pluginDriver: driver) @@ -71,8 +82,12 @@ struct CreateTableDraftCompositionTests { DatabaseManager.shared.removeSession(for: connection.id) } - private static func makeDraft(tableName: String) -> CreateTableDraft { - let draft = CreateTableDraft() + private static func makeDraft( + tableName: String, + connection: DatabaseConnection, + scope: DatabaseScope + ) -> CreateTableDraft { + let draft = CreateTableDraft(connection: connection, scope: scope) draft.tableName = tableName var column = EditableColumnDefinition.placeholder() column.name = "id" @@ -87,9 +102,9 @@ struct CreateTableDraftCompositionTests { defer { Self.tearDown(connection) } let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") _ = try await Self.seedPooledDriver(connection, scope: scope) - let draft = Self.makeDraft(tableName: "sales") + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) - await draft.recompose(databaseType: .postgresql, scope: scope) + await draft.recompose() #expect(draft.composed?.statements == ["CREATE TABLE \"reporting\".\"sales\" (\"id\" INT)"]) #expect(draft.compositionFailure == nil) @@ -101,8 +116,8 @@ struct CreateTableDraftCompositionTests { let (connection, _) = Self.inject(type: .pglite, sessionSchema: "reporting") defer { Self.tearDown(connection) } let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") - let draft = Self.makeDraft(tableName: "sales") - await draft.recompose(databaseType: .pglite, scope: scope) + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) + await draft.recompose() #expect(draft.composed?.tableName == "sales") let acquired = AsyncGate() @@ -117,7 +132,7 @@ struct CreateTableDraftCompositionTests { draft.tableName = "invoices" let recomposition = Task { @MainActor in - await draft.recompose(databaseType: .pglite, scope: scope) + await draft.recompose() } _ = try? await waitUntil { DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) >= 1 } @@ -136,12 +151,12 @@ struct CreateTableDraftCompositionTests { let (connection, _) = Self.inject(type: .pglite, sessionSchema: "reporting") defer { Self.tearDown(connection) } let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") - let draft = Self.makeDraft(tableName: "sales") - await draft.recompose(databaseType: .pglite, scope: scope) + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) + await draft.recompose() DatabaseManager.shared.removeSession(for: connection.id) draft.tableName = "invoices" - await draft.recompose(databaseType: .pglite, scope: scope) + await draft.recompose() #expect(draft.composed?.statements == ["CREATE TABLE \"reporting\".\"sales\" (\"id\" INT)"]) #expect(draft.compositionFailure != nil) @@ -152,31 +167,30 @@ struct CreateTableDraftCompositionTests { let (connection, driver) = Self.inject(type: .pglite, sessionSchema: "reporting") defer { Self.tearDown(connection) } let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") - let draft = Self.makeDraft(tableName: "sales") + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) - await draft.recompose(databaseType: .pglite, scope: scope) - await draft.recompose(databaseType: .pglite, scope: scope) + await draft.recompose() + await draft.recompose() #expect(driver.createTableRequests == 1) } @Test( "A grid edit reaches whoever observes the draft, so the preview and Create Table follow it", - arguments: [StructureTab.columns, StructureTab.indexes, StructureTab.foreignKeys] + arguments: StructureSection.allCases ) - func gridEditPublishesTheDraft(tab: StructureTab) throws { + func gridEditPublishesTheDraft(section: StructureSection) throws { let connection = TestFixtures.makeConnection(type: .postgresql) let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") - let draft = Self.makeDraft(tableName: "sales") + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) draft.changeManager.addNewIndex() draft.changeManager.addNewForeignKey() - let adapter = draft.gridAdapter(for: connection) - adapter.select(tab) - adapter.refresh() - let model = adapter.model + draft.changeManager.addNewCheckConstraint() + draft.editor.refresh() + let model = draft.editor.adapter(for: section).model let rowID = try #require(model.rowID(at: 0)) let name = try #require(model.columns.column(at: 0)) - let keyBefore = draft.compositionKey(scope: scope) + let keyBefore = draft.compositionKey var published = 0 let observation = draft.objectWillChange.sink { published += 1 } @@ -188,7 +202,7 @@ struct CreateTableDraftCompositionTests { #expect(committed) #expect(published > 0) - #expect(draft.compositionKey(scope: scope) != keyBefore) + #expect(draft.compositionKey != keyBefore) } @Test("A typed comment is composed after the CREATE, on the tab's schema") @@ -197,10 +211,10 @@ struct CreateTableDraftCompositionTests { defer { Self.tearDown(connection) } let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") _ = try await Self.seedPooledDriver(connection, scope: scope) - let draft = Self.makeDraft(tableName: "sales") + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) draft.tableOptions.comment = "Daily sales" - await draft.recompose(databaseType: .postgresql, scope: scope) + await draft.recompose() #expect(draft.composed?.statements == [ "CREATE TABLE \"reporting\".\"sales\" (\"id\" INT)", @@ -213,27 +227,95 @@ struct CreateTableDraftCompositionTests { let (connection, driver) = Self.inject(type: .pglite, sessionSchema: "reporting") defer { Self.tearDown(connection) } let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") - let draft = Self.makeDraft(tableName: "sales") - await draft.recompose(databaseType: .pglite, scope: scope) - let keyBefore = draft.compositionKey(scope: scope) + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) + await draft.recompose() + let keyBefore = draft.compositionKey draft.tableOptions.comment = "Daily sales" - await draft.recompose(databaseType: .pglite, scope: scope) + await draft.recompose() - #expect(draft.compositionKey(scope: scope) != keyBefore) + #expect(draft.compositionKey != keyBefore) #expect(driver.createTableRequests == 2) #expect(draft.composed?.statements.last == "COMMENT ON TABLE \"reporting\".\"sales\" IS 'Daily sales'") } @Test("A draft with nothing to create names what is missing without a connection") func emptyDraftNeedsNoConnection() async { - let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) - let draft = CreateTableDraft() + let connection = TestFixtures.makeConnection(type: .postgresql) + let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: nil) + let draft = CreateTableDraft(connection: connection, scope: scope) - await draft.recompose(databaseType: .postgresql, scope: scope) + await draft.recompose() #expect(draft.composed?.statements.isEmpty == true) #expect(draft.composed?.issues.isEmpty == false) #expect(draft.compositionFailure == nil) } + + private static func check(_ name: String, _ expression: String) -> EditableCheckConstraintDefinition { + var constraint = EditableCheckConstraintDefinition.placeholder() + constraint.name = name + constraint.expression = expression + return constraint + } + + @Test("A check constraint is added right after the CREATE, on the tab's schema") + func checkConstraintIsComposed() async throws { + let (connection, _) = Self.inject(type: .postgresql, sessionSchema: "public") + defer { Self.tearDown(connection) } + let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") + _ = try await Self.seedPooledDriver(connection, scope: scope) + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) + draft.changeManager.workingCheckConstraints = [Self.check("positive_id", "id > 0")] + draft.tableOptions.comment = "Daily sales" + + await draft.recompose() + + #expect(draft.composed?.statements == [ + "CREATE TABLE \"reporting\".\"sales\" (\"id\" INT)", + "ALTER TABLE \"reporting\".\"sales\" ADD CONSTRAINT \"positive_id\" CHECK (id > 0)", + "COMMENT ON TABLE \"reporting\".\"sales\" IS 'Daily sales'" + ]) + #expect(draft.composed?.issues.isEmpty == true) + } + + @Test("A check the driver cannot add is named on its own row, never dropped") + func refusedCheckConstraintIsAnIssue() async throws { + let (connection, _) = Self.inject(type: .pglite, sessionSchema: "reporting", addsCheckConstraints: false) + defer { Self.tearDown(connection) } + let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) + draft.changeManager.workingCheckConstraints = [ + EditableCheckConstraintDefinition.placeholder(), + Self.check("positive_id", "id > 0") + ] + + await draft.recompose() + + let composed = try #require(draft.composed) + #expect(composed.statements == ["CREATE TABLE \"reporting\".\"sales\" (\"id\" INT)"]) + let issue = try #require(composed.issues.first) + #expect(issue.section == .checkConstraints) + #expect(issue.row == 1) + #expect(draft.entityID(for: issue) == draft.changeManager.workingCheckConstraints[1].id) + } + + @Test("An index the driver cannot add is named on its working row, past a blank one above it") + func refusedIndexNamesItsWorkingRow() async throws { + let (connection, _) = Self.inject(type: .pglite, sessionSchema: "reporting") + defer { Self.tearDown(connection) } + let scope = DatabaseScope(connectionId: connection.id, database: "shop", schema: "reporting") + let draft = Self.makeDraft(tableName: "sales", connection: connection, scope: scope) + var index = EditableIndexDefinition.placeholder() + index.name = "sales_id" + index.columns = ["id"] + draft.changeManager.workingIndexes = [EditableIndexDefinition.placeholder(), index] + + await draft.recompose() + + let composed = try #require(draft.composed) + let issue = try #require(composed.issues.first { $0.section == .indexes }) + #expect(issue.row == 1) + #expect(draft.entityID(for: issue) == index.id) + } } diff --git a/apple/TableProTests/Views/Structure/StructureAvailabilityTests.swift b/apple/TableProTests/Views/Structure/StructureAvailabilityTests.swift new file mode 100644 index 0000000000..55605cb377 --- /dev/null +++ b/apple/TableProTests/Views/Structure/StructureAvailabilityTests.swift @@ -0,0 +1,136 @@ +// +// StructureAvailabilityTests.swift +// TableProTests +// +// Which sections the Definition page lists and which panes the Structure view offers. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct StructureAvailabilityTests { + private static let legacyMySQL = StructureServerSupport( + unsupportedColumnFields: [], + unsupportedIndexTypes: [], + checkConstraintRefusal: "Check constraints need MySQL 8.0.16 or later." + ) + + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeEditor( + _ type: DatabaseType, + support: StructureServerSupport = .unrestricted + ) -> StructureDefinitionEditor { + let editor = StructureDefinitionEditor( + manager: StructureChangeManager(), + context: .existingTable( + connection: TestFixtures.makeConnection(type: type), + databaseName: "db", + schemaName: nil, + tableName: "t", + objectKind: .table, + isSessionBound: false + ), + defaults: suite.defaults + ) + editor.serverSupport = support + return editor + } + + private func makeSession(_ type: DatabaseType) -> StructureEditingSession { + StructureEditingSession( + identity: "db.t", + connection: TestFixtures.makeConnection(database: "db", type: type), + databaseName: "db", + schemaName: nil, + tableName: "t" + ) + } + + // MARK: - Sections + + @Test("A MySQL server that discards a CHECK clause is not offered the section") + func legacyServerHidesConstraints() { + let editor = makeEditor(.mysql, support: Self.legacyMySQL) + #expect(!editor.offers(.checkConstraints)) + #expect(editor.sections == [.columns, .indexes, .foreignKeys]) + } + + @Test("A server that keeps them is offered the section, on every engine that has them") + func modernServerShowsConstraints() { + #expect(makeEditor(.mysql).offers(.checkConstraints)) + #expect(makeEditor(.postgresql).offers(.checkConstraints)) + } + + @Test("An engine with no check constraints at all leaves the section out either way") + func engineWithoutChecksStaysHidden() { + for support in [StructureServerSupport.unrestricted, Self.legacyMySQL] { + #expect(!makeEditor(.redis, support: support).offers(.checkConstraints)) + } + } + + @Test("Foreign Keys follows the engine") + func foreignKeysFollowTheEngine() { + #expect(makeEditor(.postgresql).offers(.foreignKeys)) + #expect(!makeEditor(.clickhouse).offers(.foreignKeys)) + } + + @Test("Columns and Indexes are listed on every engine, first and in that order") + func columnsAndIndexesEverywhere() { + for type in [DatabaseType.mysql, .postgresql, .sqlite, .redis, .clickhouse] { + let sections = makeEditor(type).sections + #expect(Array(sections.prefix(2)) == [.columns, .indexes], "\(type.rawValue)") + } + #expect(makeEditor(.postgresql).sections == [.columns, .indexes, .foreignKeys, .checkConstraints]) + } + + @Test("A server that withdraws check constraints later takes the section off the page") + func withdrawingSupportUpdatesTheSections() { + let editor = makeEditor(.mysql) + #expect(editor.sections.contains(.checkConstraints)) + + editor.serverSupport = Self.legacyMySQL + + #expect(!editor.sections.contains(.checkConstraints)) + } + + // MARK: - Panes + + @Test("Definition and DDL are offered on every engine, Definition first") + func definitionAndDDLEverywhere() { + for type in [DatabaseType.mysql, .postgresql, .redis, .clickhouse] { + let panes = makeSession(type).availablePanes + #expect(panes.first == .definition, "\(type.rawValue)") + #expect(panes.contains(.ddl), "\(type.rawValue)") + } + } + + @Test("Triggers follows the engine") + func triggersFollowTheEngine() { + #expect(makeSession(.mysql).availablePanes.contains(.triggers)) + #expect(makeSession(.postgresql).availablePanes.contains(.triggers)) + #expect(!makeSession(.redis).availablePanes.contains(.triggers)) + } + + @Test("Parts is ClickHouse only, and the server refusal does not reach it") + func partsIsClickHouseOnly() { + let clickhouse = makeSession(.clickhouse) + #expect(clickhouse.availablePanes.contains(.parts)) + #expect(!makeSession(.mysql).availablePanes.contains(.parts)) + + clickhouse.serverSupport = Self.legacyMySQL + #expect(clickhouse.availablePanes.contains(.parts)) + } + + @Test("Panes keep one order: Definition, Triggers, DDL, Parts") + func panesKeepTheirOrder() { + #expect(makeSession(.mysql).availablePanes == [.definition, .triggers, .ddl]) + #expect(makeSession(.clickhouse).availablePanes == [.definition, .ddl, .parts]) + } +} diff --git a/apple/TableProTests/Views/Structure/StructureCodeStandInThemeTests.swift b/apple/TableProTests/Views/Structure/StructureCodeStandInThemeTests.swift index 82cb841267..05b0702b84 100644 --- a/apple/TableProTests/Views/Structure/StructureCodeStandInThemeTests.swift +++ b/apple/TableProTests/Views/Structure/StructureCodeStandInThemeTests.swift @@ -38,7 +38,7 @@ struct StructureCodeStandInThemeTests { @Test("No DDL available stands in for the DDL view on the editor background") func missingDDL() throws { - try expectEditorStandIn(StructureDDLUnavailableView()) + try expectEditorStandIn(StructureDDLStandIn()) } @Test("The Create Table SQL Preview placeholder sits on the editor background") diff --git a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift new file mode 100644 index 0000000000..48d5b2977e --- /dev/null +++ b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift @@ -0,0 +1,230 @@ +// +// StructureDefinitionEditorTests.swift +// TableProTests +// + +import AppKit +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@MainActor +struct StructureDefinitionEditorTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeEditor(type: DatabaseType = .postgresql) -> StructureDefinitionEditor { + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "orders", + columns: [ + ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "customer_id", dataType: "INT", isNullable: false, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "status", dataType: "TEXT", isNullable: true, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil) + ], + indexes: [IndexInfo(name: "orders_pkey", columns: ["id"], isUnique: true, isPrimary: true, type: "BTREE")], + foreignKeys: [], + primaryKey: ["id"] + ) + let editor = StructureDefinitionEditor( + manager: manager, + context: .existingTable( + connection: TestFixtures.makeConnection(type: type), + databaseName: "shop", + schemaName: "public", + tableName: "orders", + objectKind: .table, + isSessionBound: false + ), + defaults: suite.defaults + ) + editor.refresh() + return editor + } + + private func id(of name: String, in editor: StructureDefinitionEditor) -> UUID { + editor.manager.workingColumns.first { $0.name == name }!.id + } + + @Test("Nothing selected puts the table in the inspector") + func startsOnTable() { + #expect(makeEditor().inspectorTarget == .table) + } + + @Test("Selecting a column names it for the inspector") + func selectingColumnTargetsIt() { + let editor = makeEditor() + let status = id(of: "status", in: editor) + editor.adapter(for: .columns).select(entity: status) + + #expect(editor.inspectorTarget == .entity(.columns, status)) + #expect(editor.focusedSection == .columns) + } + + @Test("Selecting in one section clears the others") + func selectionIsExclusiveAcrossSections() { + let editor = makeEditor() + editor.adapter(for: .columns).select(entity: id(of: "status", in: editor)) + let index = editor.manager.workingIndexes[0].id + editor.adapter(for: .indexes).select(entity: index) + + #expect(editor.adapter(for: .columns).model.selection.isEmpty) + #expect(editor.inspectorTarget == .entity(.indexes, index)) + } + + @Test("A row keeps its identity when a sort reorders the section") + func rowIdentitySurvivesSort() { + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + let status = id(of: "status", in: editor) + adapter.select(entity: status) + + adapter.sortDidChange(SortState(columns: [SortColumn(columnIndex: 0, direction: .descending)], source: .user)) + + #expect(adapter.selectedEntityIDs == [status]) + #expect(editor.inspectorTarget == .entity(.columns, status)) + } + + @Test("Removing a new column above keeps the selection on the same definition") + func rowIdentitySurvivesRemovalAbove() { + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + adapter.addRow() + let added = editor.manager.workingColumns.last!.id + let moved = editor.manager.workingColumns.removeLast() + editor.manager.workingColumns.insert(moved, at: 0) + editor.refresh() + let status = id(of: "status", in: editor) + adapter.select(entity: status) + + editor.manager.deleteColumn(id: added) + editor.refresh() + + #expect(adapter.selectedEntityIDs == [status]) + } + + @Test("Adding a column selects it, so the inspector opens on it") + func addingRevealsTheNewRow() { + let editor = makeEditor() + editor.add(.columns) + let added = editor.manager.workingColumns.last!.id + + #expect(editor.inspectorTarget == .entity(.columns, added)) + } + + @Test("A collapsed section stays collapsed for the next table") + func collapsedSectionsPersist() { + let editor = makeEditor() + editor.setExpanded(.indexes, false) + + #expect(!makeEditor().isExpanded(.indexes)) + } + + @Test("A filter shows a collapsed section's matches") + func filterExpandsCollapsedSections() { + let editor = makeEditor() + editor.setExpanded(.columns, false) + editor.searchText = "stat" + + #expect(editor.isExpanded(.columns)) + #expect(editor.adapter(for: .columns).visibleRowCount == 1) + } + + @Test("Counts leave out definitions marked for deletion") + func countsSkipDeletions() { + let editor = makeEditor() + editor.manager.deleteColumn(id: id(of: "status", in: editor)) + + #expect(editor.count(of: .columns) == 2) + } + + @Test("MySQL tables offer no index condition, since their DDL drops it") + func mysqlHasNoIndexCondition() { + let editor = makeEditor(type: .mysql) + #expect(!editor.adapter(for: .indexes).provider.columns.contains(String(localized: "Condition"))) + #expect(makeEditor().adapter(for: .indexes).provider.columns.contains(String(localized: "Condition"))) + } + + @Test("Text typed in an inspector field is committed before a command runs") + func pendingFieldDraftCommitsFirst() { + let editor = makeEditor() + let status = id(of: "status", in: editor) + editor.noteFieldDraft("state") { text in + editor.commitInspectorField(section: .columns, id: status, field: 0, value: text) + } + #expect(editor.hasPendingFieldDraft) + + editor.commitPendingEdits() + + #expect(!editor.hasPendingFieldDraft) + #expect(editor.manager.workingColumns.contains { $0.name == "state" }) + } + + @Test("Nothing can be edited while the table is read again after a save") + func awaitingBaselineBlocksEdits() { + let editor = makeEditor() + editor.isAwaitingBaseline = true + + #expect(!editor.canAdd(.columns)) + editor.add(.columns) + #expect(editor.manager.workingColumns.count == 3) + let status = id(of: "status", in: editor) + #expect(editor.inspectorForm(for: .columns, id: status)?.isEditable == false) + } + + @Test("Several rows selected says how many instead of picking one") + func severalSelected() { + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + adapter.model.selectRows([ + adapter.rowID(forEntity: id(of: "id", in: editor)), + adapter.rowID(forEntity: id(of: "status", in: editor)) + ]) + + #expect(editor.inspectorTarget == .several(.columns, 2)) + } +} + +@MainActor +struct StructureDefinitionPasteTests { + @Test("Pasting several columns is one undo step") + func pasteIsOneUndoStep() throws { + let suite = try EphemeralDefaults() + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "t", + columns: [ + ColumnInfo(name: "a", dataType: "INT", isNullable: true, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "b", dataType: "INT", isNullable: true, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil) + ], + indexes: [], + foreignKeys: [], + primaryKey: [] + ) + let editor = StructureDefinitionEditor( + manager: manager, + context: .newTable(connection: TestFixtures.makeConnection(type: .postgresql), databaseName: "db", schemaName: nil), + defaults: suite.defaults + ) + editor.refresh() + let adapter = editor.adapter(for: .columns) + let pasteboard = NSPasteboard(name: NSPasteboard.Name("structure-paste-\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + adapter.copyRows(manager.workingColumns.map { adapter.rowID(forEntity: $0.id) }, to: pasteboard) + + adapter.pasteRows(from: pasteboard) + #expect(manager.workingColumns.count == 4) + + manager.undo() + #expect(manager.workingColumns.count == 2) + } +} diff --git a/apple/TableProTests/Views/Structure/StructureEditGateTests.swift b/apple/TableProTests/Views/Structure/StructureEditGateTests.swift index 8db3e43eff..8b9617dccb 100644 --- a/apple/TableProTests/Views/Structure/StructureEditGateTests.swift +++ b/apple/TableProTests/Views/Structure/StructureEditGateTests.swift @@ -119,7 +119,6 @@ struct StructureEditGateTests { #expect(gate(.materializedView).locksField(at: 0, on: .foreignKeys, orderedFields: [])) #expect(gate(.materializedView).locksField(at: 0, on: .checkConstraints, orderedFields: [])) #expect(!gate(.table).locksField(at: 0, on: .indexes, orderedFields: [])) - #expect(!gate(.table).locksField(at: 0, on: .ddl, orderedFields: [])) } @Test("The Columns list locks field by field") diff --git a/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift b/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift index 286b5b4328..c67641e12a 100644 --- a/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift +++ b/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift @@ -38,6 +38,45 @@ private final class StructureSessionDriver: PluginDriverStub, @unchecked Sendabl } } +// Records the reads a load makes, in order. +private final class StructureLoadDriver: PluginDriverStub, @unchecked Sendable { + private let lock = NSLock() + private var log: [String] = [] + + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + var reads: [String] { lock.withLock { log } } + + private func record(_ read: String) { + lock.withLock { log.append(read) } + } + + func switchDatabase(to database: String) async throws {} + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + record("columns") + return [PluginColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true)] + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + record("indexes") + return [] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + record("foreignKeys") + return [] + } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { + record("ddl") + return "CREATE TABLE `\(table)` (`id` INT)" + } +} + @Suite("Structure editing session", .serialized) @MainActor struct StructureEditingSessionTests { @@ -64,6 +103,7 @@ struct StructureEditingSessionTests { foreignKeys: [], primaryKey: [] ) + session.markBaselineAdopted() manager.addNewColumn() guard var column = manager.workingColumns.last else { return } column.name = "notes" @@ -90,68 +130,85 @@ struct StructureEditingSessionTests { } // A grid owner seeded once into a view's `@State` kept writing into the first session to mount. - @Test("A session's grid adapter writes into that session's change manager") - func gridAdapterBelongsToItsSession() { + @Test("A session's editor and its section grids write into that session's change manager") + func editorBelongsToItsSession() { let connection = TestFixtures.makeConnection() let first = Self.makeSession(connection: connection) let second = Self.makeSession(connection: connection) - #expect(first.gridAdapter !== second.gridAdapter) - #expect(first.gridModel !== second.gridModel) - #expect(first.gridAdapter.structureChangeManager === first.changeManager) - #expect(second.gridAdapter.structureChangeManager === second.changeManager) - #expect(first.gridAdapter.session === first) - #expect(first.gridAdapter.store.session === first) + #expect(first.editor !== second.editor) + #expect(first.editor.manager === first.changeManager) + #expect(second.editor.manager === second.changeManager) + for section in StructureSection.allCases { + let adapter = first.editor.adapter(for: section) + #expect(adapter !== second.editor.adapter(for: section), "\(section.rawValue)") + #expect(adapter.model !== second.editor.adapter(for: section).model, "\(section.rawValue)") + #expect(adapter.manager === first.changeManager, "\(section.rawValue)") + #expect(adapter.editor === first.editor, "\(section.rawValue)") + } } - @Test("A session's grid model survives the view: the adapter, not a mount, owns it") + @Test("A section's grid model survives the view: the editor, not a mount, owns it") func gridModelOutlivesTheView() { let session = Self.makeSession(connection: TestFixtures.makeConnection()) Self.stageAColumn(on: session) - session.gridAdapter.refresh() - let model = session.gridModel + session.editor.refresh() + let adapter = session.editor.adapter(for: .columns) + let model = adapter.model #expect(!model.isMounted) #expect(model.rowCount == 1) - #expect(session.gridModel === model) - #expect(model.dataSource === session.gridAdapter) + #expect(session.editor.adapter(for: .columns).model === model) + #expect(model.dataSource === adapter) } - @Test("The session answers the grid on screen only while a sub-tab shows one") - func activeGridModelFollowsTheSubTab() { + @Test("The session answers the grid holding the selection, and no grid on a pane that draws none") + func activeGridModelFollowsThePane() { let session = Self.makeSession(connection: TestFixtures.makeConnection()) + session.changeManager.loadSchema( + tableName: "users", + columns: [ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true)], + indexes: [IndexInfo(name: "ix_id", columns: ["id"], isUnique: false, isPrimary: false, type: "BTREE")], + foreignKeys: [], + primaryKey: ["id"] + ) + session.markBaselineAdopted() + session.editor.refresh() + let indexes = session.editor.adapter(for: .indexes) - for tab in [StructureTab.columns, .indexes, .foreignKeys, .checkConstraints] { - session.selectedTab = tab - #expect(session.activeGridModel === session.gridModel, "\(tab.rawValue)") - } - for tab in [StructureTab.options, .ddl, .parts, .triggers] { - session.selectedTab = tab - #expect(session.activeGridModel == nil, "\(tab.rawValue)") + #expect(session.activeGridModel === session.editor.adapter(for: .columns).model) + + indexes.select(entity: session.changeManager.workingIndexes[0].id) + #expect(session.activeGridModel === indexes.model) + + for pane in [StructurePane.triggers, .ddl, .parts] { + session.pane = pane + #expect(session.activeGridModel == nil, "\(pane.rawValue)") } } - // Sharing the place moved one tab to Indexes when the other did, and lost the sub-tab on a Data trip. + // Sharing the place moved one tab to Indexes when the other did, and lost the pane on a Data trip. @Test("Each session keeps its own place in the editor") func editorPlaceIsPerSession() { let connection = TestFixtures.makeConnection() let first = Self.makeSession(connection: connection) let second = Self.makeSession(connection: connection) - first.selectedTab = .indexes - first.searchText = "created" + first.pane = .ddl + first.editor.searchText = "created" - #expect(second.selectedTab == .columns) - #expect(second.searchText.isEmpty) + #expect(second.pane == .definition) + #expect(second.editor.searchText.isEmpty) } - // MARK: - Sub-tab switch + // MARK: - Open edits - // The switch used to commit an open edit into the incoming sub-tab's rows. - @Test("An edit open when the sub-tab changes commits into the column it was typed on") - func openEditCommitsIntoItsOwnSubTab() throws { + // A switch used to commit an open edit into the incoming sub-tab's rows; each section now owns its grid. + @Test("An edit open when Undo runs lands in the column it was typed on, and Undo takes that edit back") + func openEditCommitsIntoItsOwnSection() throws { let session = Self.makeSession(connection: TestFixtures.makeConnection(database: "testdb", type: .mysql)) - session.changeManager.loadSchema( + let manager = session.changeManager + manager.loadSchema( tableName: "users", columns: ["id", "email"].map { ColumnInfo(name: $0, dataType: "VARCHAR(64)", isNullable: true, isPrimaryKey: false) @@ -162,8 +219,13 @@ struct StructureEditingSessionTests { foreignKeys: [], primaryKey: [] ) - session.gridAdapter.refresh() - let controller = DataGridViewController(model: session.gridModel) + session.markBaselineAdopted() + var index = manager.workingIndexes[0] + index.name = "ix_a" + manager.updateIndex(id: index.id, with: index) + session.editor.refresh() + let model = session.editor.adapter(for: .columns).model + let controller = DataGridViewController(model: model) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), styleMask: [.titled], @@ -180,12 +242,97 @@ struct StructureEditingSessionTests { let editor = try #require(controller.editingState.editingCellView.field.currentEditor() as? NSTextView) editor.string = "mail" - session.selectedTab = .indexes - session.gridAdapter.refresh() + session.undo() + + #expect(manager.workingColumns.map(\.name) == ["id", "email"]) + #expect(manager.workingIndexes.map(\.name) == ["ix_a", "ix_email"]) + #expect(!model.isEditing) + #expect(session.canRedo) + + session.redo() + #expect(manager.workingColumns.map(\.name) == ["id", "mail"]) + } + + // MARK: - Row commands + + @Test("Row commands do nothing on a pane that shows no grid", arguments: [StructurePane.triggers, .ddl, .parts]) + func rowCommandsNeedTheDefinitionPane(pane: StructurePane) { + let session = Self.makeSession(connection: TestFixtures.makeConnection()) + Self.stageAColumn(on: session) + session.editor.refresh() + session.editor.adapter(for: .columns).select(entity: session.changeManager.workingColumns[0].id) + let staged = session.changeManager.getChangesArray() + let columns = session.changeManager.workingColumns + session.pane = pane + + #expect(!session.canAddRow) + #expect(!session.canRemoveRows) + session.addRow() + session.removeSelectedRows() + session.duplicateSelectedRows() + + #expect(session.changeManager.getChangesArray() == staged) + #expect(session.changeManager.workingColumns == columns) + } - #expect(session.changeManager.workingColumns.map(\.name) == ["id", "mail"]) - #expect(session.changeManager.workingIndexes.map(\.name) == ["ix_id", "ix_email"]) - #expect(!session.gridModel.isEditing) + @Test("Row commands wait for the definitions the table was read with") + func rowCommandsWaitForTheBaseline() { + let session = Self.makeSession(connection: TestFixtures.makeConnection()) + #expect(!session.canAddRow) + session.addRow() + #expect(session.changeManager.workingColumns.isEmpty) + } + + @Test("Add and Remove act on the section holding the selection") + func rowCommandsFollowTheFocusedSection() { + let session = Self.makeSession(connection: TestFixtures.makeConnection()) + session.changeManager.loadSchema( + tableName: "users", + columns: [ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true)], + indexes: [IndexInfo(name: "ix_id", columns: ["id"], isUnique: false, isPrimary: false, type: "BTREE")], + foreignKeys: [], + primaryKey: ["id"] + ) + session.markBaselineAdopted() + session.editor.refresh() + session.editor.adapter(for: .indexes).select(entity: session.changeManager.workingIndexes[0].id) + + #expect(session.canAddRow) + session.addRow() + + #expect(session.changeManager.workingIndexes.count == 2) + #expect(session.changeManager.workingColumns.count == 1) + } + + // Cmd+Shift+D on several rows used to register one undo per row. + @Test("Duplicating several selected rows is one undo step") + func duplicateIsOneUndoStep() { + let session = Self.makeSession(connection: TestFixtures.makeConnection()) + let manager = session.changeManager + manager.loadSchema( + tableName: "users", + columns: ["id", "email", "created_at"].map { + ColumnInfo(name: $0, dataType: "VARCHAR(64)", isNullable: true, isPrimaryKey: false) + }, + indexes: [], + foreignKeys: [], + primaryKey: [] + ) + session.markBaselineAdopted() + session.editor.refresh() + let adapter = session.editor.adapter(for: .columns) + let selected = manager.workingColumns.prefix(2).map { adapter.rowID(forEntity: $0.id) } + adapter.model.selectRows(selected) + + session.duplicateSelectedRows() + #expect(manager.workingColumns.count == 5) + #expect(manager.workingColumns.suffix(2).map(\.name) == ["id", "email"]) + + session.undo() + + #expect(manager.workingColumns.map(\.name) == ["id", "email", "created_at"]) + #expect(!session.hasChanges) + #expect(!session.canUndo) } // MARK: - Applying without a mounted view @@ -382,77 +529,109 @@ struct StructureEditingSessionTests { return driver } - // A withdrawn tab cannot stay selected: no segment would match it. - @Test("A session on Constraints moves to Columns when the server withdraws the tab") - func withdrawnTabMovesSelection() { - let connection = DatabaseConnection(name: "MySQL", type: .mysql) - let session = Self.makeSession(connection: connection) - session.selectedTab = .checkConstraints - #expect(session.availableTabs.contains(.checkConstraints)) + private static let legacyMySQL = StructureServerSupport( + unsupportedColumnFields: [], + unsupportedIndexTypes: [], + checkConstraintRefusal: "Check constraints need MySQL 8.0.16 or later." + ) - session.serverSupport = StructureServerSupport( - unsupportedColumnFields: [], - unsupportedIndexTypes: [], - checkConstraintRefusal: "Check constraints need MySQL 8.0.16 or later." - ) + @Test("A server that withdraws check constraints takes the section off the page and leaves the pane alone") + func withdrawnSectionLeavesThePage() { + let session = Self.makeSession(connection: DatabaseConnection(name: "MySQL", type: .mysql)) + session.pane = .ddl + #expect(session.editor.sections.contains(.checkConstraints)) + + session.serverSupport = Self.legacyMySQL - #expect(!session.availableTabs.contains(.checkConstraints)) - #expect(session.selectedTab == .columns) + #expect(!session.editor.offers(.checkConstraints)) + #expect(!session.editor.sections.contains(.checkConstraints)) + #expect(session.editor.serverSupport == Self.legacyMySQL) + #expect(session.pane == .ddl) } - @Test("A server change that withdraws nothing leaves the selection alone") - func unchangedSupportKeepsSelection() { - let connection = DatabaseConnection(name: "MySQL", type: .mysql) - let session = Self.makeSession(connection: connection) - session.selectedTab = .indexes + // A pane no segment matches cannot stay selected. + @Test("A pane the engine does not offer falls back to Definition when the server's support settles") + func unofferedPaneFallsBackToDefinition() { + let session = Self.makeSession(connection: DatabaseConnection(name: "MySQL", type: .mysql)) + session.pane = .parts + #expect(!session.availablePanes.contains(.parts)) + + session.serverSupport = StructureServerSupport(unsupportedColumnFields: [.generated], unsupportedIndexTypes: []) + + #expect(session.pane == .definition) + } + + @Test("A server change that withdraws nothing leaves the pane and the sections alone") + func unchangedSupportKeepsThePane() { + let session = Self.makeSession(connection: DatabaseConnection(name: "MySQL", type: .mysql)) + session.pane = .triggers session.serverSupport = StructureServerSupport( unsupportedColumnFields: [.generated], unsupportedIndexTypes: ["BRIN"] ) - #expect(session.selectedTab == .indexes) - #expect(session.availableTabs.contains(.checkConstraints)) + #expect(session.pane == .triggers) + #expect(session.editor.offers(.checkConstraints)) } - @Test("A session built for a server with no check constraints never offers the tab") + @Test("A session built for a server with no check constraints never offers the section") func sessionSeededWithServerSupport() { - let connection = DatabaseConnection(name: "MySQL", type: .mysql) let session = StructureEditingSession( identity: "testdb.users", - connection: connection, + connection: DatabaseConnection(name: "MySQL", type: .mysql), databaseName: "testdb", schemaName: nil, tableName: "users", - serverSupport: StructureServerSupport( - unsupportedColumnFields: [], - unsupportedIndexTypes: [], - checkConstraintRefusal: "Check constraints need MySQL 8.0.16 or later." - ) + serverSupport: Self.legacyMySQL ) - #expect(!session.availableTabs.contains(.checkConstraints)) - #expect(session.selectedTab == .columns) + #expect(!session.editor.offers(.checkConstraints)) + #expect(session.editor.sections == [.columns, .indexes, .foreignKeys]) + #expect(session.pane == .definition) } - // A stale session left on DDL or Triggers mounts without a selection change, so nothing else fetches that sub-tab. - @Test("A mount fetches the sub-tab the user left selected, once, after the ones it baselines from") - func mountFetchesTheSelectedSubTab() { - let session = Self.makeSession(connection: TestFixtures.makeConnection()) - #expect(session.tabsFetchedOnMount.first == .columns) - #expect(!session.tabsFetchedOnMount.contains(.ddl)) + // A stale session left on DDL or Triggers mounts without a pane change, so nothing else fetches that pane. + @Test("A load fetches the pane the user left selected, once, after the definitions it baselines from") + func loadFetchesTheSelectedPane() async throws { + let connection = TestFixtures.makeConnection(database: "testdb") + var connectionSession = ConnectionSession( + connection: connection, + driver: PluginDriverAdapter(connection: connection, pluginDriver: StructureLoadDriver()) + ) + connectionSession.browseDatabase = "testdb" + DatabaseManager.shared.injectSession(connectionSession, for: connection.id) - session.selectedTab = .ddl - session.tabData.markFetched(.ddl) - session.hasLoaded = true - session.markStructureStale() + let session = Self.makeSession(connection: connection) + let driver = StructureLoadDriver() + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: driver) + try await adapter.connect() + MetadataConnectionPool.shared.injectEntry(adapter, scope: session.scope) + defer { + MetadataConnectionPool.shared.closeAll(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } - #expect(!session.hasLoaded) - #expect(session.tabData.needsFetch(.ddl)) - #expect(session.tabsFetchedOnMount.last == .ddl) + await session.load() + #expect(session.hasLoaded) + #expect(driver.reads.first == "columns") + #expect(!driver.reads.contains("ddl")) + #expect(session.changeManager.workingColumns.map(\.name) == ["id"]) - session.selectedTab = .indexes - #expect(session.tabsFetchedOnMount.filter { $0 == .indexes }.count == 1) - #expect(!session.tabsFetchedOnMount.contains(.ddl)) + session.markStructureStale() + #expect(!session.hasLoaded) + session.pane = .ddl + let before = driver.reads.count + await session.load() + + let reload = Array(driver.reads.dropFirst(before)) + #expect(reload.first == "columns") + #expect(reload.last == "ddl") + #expect(reload.filter { $0 == "ddl" }.count == 1) + #expect(session.loadState.hasData(.ddl)) + #expect(!session.loadState.needsFetch(.ddl)) + + await session.load() + #expect(driver.reads.filter { $0 == "ddl" }.count == 1) } // A refetch with edits staged would re-baseline and drop them, and dropping the refetch left an undone session showing old columns. @@ -460,14 +639,14 @@ struct StructureEditingSessionTests { func aChangeOwedWhileEditingIsFetchedOnceTheEditsAreGone() { let session = Self.makeSession(connection: TestFixtures.makeConnection()) Self.stageAColumn(on: session) - session.tabData.markFetched(.columns) + session.loadState.markFetched(.columns) session.hasLoaded = true session.markStructureStale() #expect(session.changeManager.hasChanges, "The staged edit survives the change") #expect(session.hasLoaded) - #expect(!session.tabData.needsFetch(.columns)) + #expect(!session.loadState.needsFetch(.columns)) #expect(!session.settleOwedRefetch(), "Nothing is fetched while the edit is still staged") while session.changeManager.canUndo { @@ -477,7 +656,7 @@ struct StructureEditingSessionTests { #expect(session.settleOwedRefetch()) #expect(!session.hasLoaded) - #expect(session.tabData.needsFetch(.columns)) + #expect(session.loadState.needsFetch(.columns)) #expect(!session.settleOwedRefetch()) } @@ -530,6 +709,7 @@ struct StructureEditingSessionTests { primaryKey: ["id"], tableComment: "Customer orders" ) + session.markBaselineAdopted() session.changeManager.stageTableComment("Paid orders") session.markStructureStale() diff --git a/apple/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift b/apple/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift index 0d6d731b6e..2caa8bb017 100644 --- a/apple/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift +++ b/apple/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift @@ -243,7 +243,7 @@ struct StructureEditingSupportFieldDiffTests { } } -// MARK: - undoDelete(for:at:) +// MARK: - undoDelete(section:id:) @MainActor struct StructureChangeManagerUndoDeleteTests { @@ -272,7 +272,7 @@ struct StructureChangeManagerUndoDeleteTests { manager.deleteColumn(id: emailColumn.id) #expect(manager.deleteInsertState(for: 1, tab: .columns).isDeleted) - manager.undoDelete(for: .columns, at: 1) + manager.undoDelete(section: .columns, id: emailColumn.id) #expect(!manager.deleteInsertState(for: 1, tab: .columns).isDeleted) #expect(manager.pendingChanges[.column(emailColumn.id)] == nil) } @@ -285,29 +285,44 @@ struct StructureChangeManagerUndoDeleteTests { manager.updateColumn(id: renamed.id, with: renamed) let beforeChanges = manager.pendingChanges - manager.undoDelete(for: .columns, at: 1) + manager.undoDelete(section: .columns, id: renamed.id) #expect(manager.pendingChanges == beforeChanges) } - @Test("undoDelete bounds-checks the row index") - func undoDeleteOutOfRange() { + @Test("undoDelete ignores an id no definition has") + func undoDeleteUnknownID() { let manager = makeManagerWithSchema() - manager.undoDelete(for: .columns, at: 99) - manager.undoDelete(for: .indexes, at: 0) - manager.undoDelete(for: .foreignKeys, at: 0) + manager.undoDelete(section: .columns, id: UUID()) + manager.undoDelete(section: .indexes, id: UUID()) + manager.undoDelete(section: .foreignKeys, id: UUID()) + manager.undoDelete(section: .checkConstraints, id: UUID()) #expect(manager.pendingChanges.isEmpty) } - @Test("undoDelete on .ddl / .parts tabs is a no-op") - func undoDeleteDDLAndParts() { + @Test("undoDelete names a definition in its own section only") + func undoDeleteStaysInItsSection() { let manager = makeManagerWithSchema() let emailColumn = manager.workingColumns[1] manager.deleteColumn(id: emailColumn.id) - manager.undoDelete(for: .ddl, at: 1) - manager.undoDelete(for: .parts, at: 1) + manager.undoDelete(section: .indexes, id: emailColumn.id) + manager.undoDelete(section: .checkConstraints, id: emailColumn.id) #expect(manager.deleteInsertState(for: 1, tab: .columns).isDeleted) } + + @Test("undoDelete leaves the undo stack alone, and the delete it skipped undoes to nothing") + func undoDeleteLeavesTheStack() { + let manager = makeManagerWithSchema() + let emailColumn = manager.workingColumns[1] + manager.deleteColumn(id: emailColumn.id) + + manager.undoDelete(section: .columns, id: emailColumn.id) + #expect(manager.canUndo) + + manager.undo() + #expect(!manager.deleteInsertState(for: 1, tab: .columns).isDeleted) + #expect(!manager.hasChanges) + } } diff --git a/apple/TableProTests/Views/Structure/StructureFooterPolicyTests.swift b/apple/TableProTests/Views/Structure/StructureFooterPolicyTests.swift index d2c3e38272..42ad20122a 100644 --- a/apple/TableProTests/Views/Structure/StructureFooterPolicyTests.swift +++ b/apple/TableProTests/Views/Structure/StructureFooterPolicyTests.swift @@ -5,134 +5,147 @@ import Foundation @testable import TablePro +import TableProPluginKit import Testing -/// The "+" and "-" under a structure list used to take their label, their enabled state and their -/// tooltip from three different switches, and only the Foreign Keys one asked about the object at -/// all. So a view offered an enabled "Add Column" over a statement PostgreSQL always refuses, with -/// nothing to explain it. (#2726) +// The +/- beneath each section takes its label from the policy and its state and help from the editor, so a view +// that PostgreSQL refuses an added column on dims the pair and says why (#2726). +@MainActor struct StructureFooterPolicyTests { - private func resolve( - tab: StructureTab, + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeEditor( kind: TableInfo.TableType, - matrix: StructureObjectEditMatrix = .postgreSQL, - canEditSchema: Bool = true, - hasSelection: Bool = true, - isSaving: Bool = false - ) -> StructureFooterCapability { - StructureFooterPolicy.resolve( - tab: tab, - canEditSchema: canEditSchema, - hasSelection: hasSelection, - isSaving: isSaving, - resolve: { operation in - StructureEditEligibility.resolve( - operation, - on: kind, - matrix: matrix, - engineAllows: true, - engineName: "PostgreSQL", - canEditSchema: canEditSchema - ) - } + type: DatabaseType = .postgresql, + isSessionBound: Bool = false + ) -> StructureDefinitionEditor { + let manager = StructureChangeManager() + manager.loadSchema( + tableName: "t", + columns: [ColumnInfo(name: "id", dataType: "integer", isNullable: false, isPrimaryKey: true)], + indexes: [IndexInfo(name: "t_pkey", columns: ["id"], isUnique: true, isPrimary: true, type: "BTREE")], + foreignKeys: [], + primaryKey: ["id"] ) + let editor = StructureDefinitionEditor( + manager: manager, + context: .existingTable( + connection: TestFixtures.makeConnection(type: type), + databaseName: "db", + schemaName: "public", + tableName: "t", + objectKind: kind, + isSessionBound: isSessionBound + ), + defaults: suite.defaults + ) + editor.refresh() + return editor } - @Test("A table offers both buttons on all four editable tabs") + // Selects the section's first row, staging a placeholder where the table has none. + private func selectFirst(_ section: StructureSection, in editor: StructureDefinitionEditor) { + if StructureGridEdits.entityIDs(in: section, manager: editor.manager).isEmpty { + StructureGridEdits.addRow(on: section, manager: editor.manager) + editor.refresh() + } + let id = StructureGridEdits.entityIDs(in: section, manager: editor.manager)[0] + editor.adapter(for: section).select(entity: id) + } + + @Test("A table offers both buttons in all four sections") func tableOffersEverything() { - for tab in [StructureTab.columns, .indexes, .foreignKeys, .checkConstraints] { - let capability = resolve(tab: tab, kind: .table) - #expect(capability.canAdd, "\(tab.rawValue)") - #expect(capability.canRemove, "\(tab.rawValue)") - #expect(capability.unavailableReason == nil, "\(tab.rawValue)") + let editor = makeEditor(kind: .table) + for section in StructureSection.allCases { + selectFirst(section, in: editor) + #expect(editor.canAdd(section), "\(section.rawValue)") + #expect(editor.canRemoveSelection(in: section), "\(section.rawValue)") + #expect(editor.refusal(adding: section) == nil, "\(section.rawValue)") } } - /// A save holds what is staged from the press until it ends, so the pair cannot stage more - /// meanwhile, and says why rather than going grey in silence. - @Test("A save in progress dims both buttons on every editable tab and says why") - func savingDimsThePair() { - for tab in [StructureTab.columns, .indexes, .foreignKeys, .checkConstraints] { - let capability = resolve(tab: tab, kind: .table, isSaving: true) - #expect(!capability.canAdd, "\(tab.rawValue)") - #expect(!capability.canRemove, "\(tab.rawValue)") - #expect(capability.isActive, "\(tab.rawValue)") - #expect(capability.unavailableReason == StructureFooterPolicy.savingReason, "\(tab.rawValue)") + // A save holds what is staged from the press until it ends, so the pair cannot stage more meanwhile. + @Test("A save in progress dims both buttons in every section and says why") + func savingDimsThePair() throws { + let editor = makeEditor(kind: .table) + selectFirst(.columns, in: editor) + editor.manager.stageTableComment("Orders") + _ = try #require(editor.manager.holdForSave()) + editor.refresh() + #expect(!editor.adapter(for: .columns).model.selection.isEmpty) + + for section in StructureSection.allCases { + #expect(!editor.canAdd(section), "\(section.rawValue)") + #expect(!editor.canRemoveSelection(in: section), "\(section.rawValue)") + #expect(editor.refusal(adding: section) == StructureFooterPolicy.savingReason, "\(section.rawValue)") } - #expect(!resolve(tab: .ddl, kind: .table, isSaving: true).isActive) } - /// Shown and dimmed, not hidden. The pair disappearing would read as "this tab has no columns", - /// and the tooltip is the only place the refusal can be stated. - @Test("A view keeps the Columns pair on screen, dimmed, with a reason") + // Dimmed, not hidden: the pair disappearing would read as "this table has no columns". + @Test("A view keeps the Columns pair, dimmed, with a reason") func viewDimsTheColumnsPair() { - let capability = resolve(tab: .columns, kind: .view) - #expect(!capability.canAdd) - #expect(!capability.canRemove) - #expect(capability.isActive) - #expect(capability.addLabel.isEmpty == false) - #expect(capability.unavailableReason?.isEmpty == false) + let editor = makeEditor(kind: .view) + selectFirst(.columns, in: editor) + + #expect(!editor.canAdd(.columns)) + #expect(!editor.canRemoveSelection(in: .columns)) + #expect(!StructureFooterPolicy.labels(for: .columns).add.isEmpty) + #expect(editor.refusal(adding: .columns)?.isEmpty == false) } @Test("A materialized view offers an index and refuses a column") - func materializedViewSplitsByTab() { - let indexes = resolve(tab: .indexes, kind: .materializedView) - #expect(indexes.canAdd) - #expect(indexes.canRemove) - #expect(indexes.unavailableReason == nil) - - let columns = resolve(tab: .columns, kind: .materializedView) - #expect(!columns.canAdd) - #expect(columns.unavailableReason?.isEmpty == false) + func materializedViewSplitsBySection() { + let editor = makeEditor(kind: .materializedView) + selectFirst(.indexes, in: editor) + #expect(editor.canAdd(.indexes)) + #expect(editor.canRemoveSelection(in: .indexes)) + #expect(editor.refusal(adding: .indexes) == nil) + + #expect(!editor.canAdd(.columns)) + #expect(editor.refusal(adding: .columns)?.isEmpty == false) } @Test("A foreign table offers a check constraint and refuses an index") - func foreignTableSplitsByTab() { - let checks = resolve(tab: .checkConstraints, kind: .foreignTable) - #expect(checks.canAdd) + func foreignTableSplitsBySection() { + let editor = makeEditor(kind: .foreignTable) + #expect(editor.canAdd(.checkConstraints)) - let indexes = resolve(tab: .indexes, kind: .foreignTable) - #expect(!indexes.canAdd) - #expect(indexes.unavailableReason?.isEmpty == false) + #expect(!editor.canAdd(.indexes)) + #expect(editor.refusal(adding: .indexes)?.isEmpty == false) } @Test("A foreign table refuses a foreign key, which is the constraint PostgreSQL rejects on one") func foreignTableRefusesForeignKeys() { - let keys = resolve(tab: .foreignKeys, kind: .foreignTable) - #expect(!keys.canAdd) - #expect(keys.unavailableReason?.isEmpty == false) + let editor = makeEditor(kind: .foreignTable) + #expect(!editor.canAdd(.foreignKeys)) + #expect(editor.refusal(adding: .foreignKeys)?.isEmpty == false) } - /// Today's behaviour, preserved on purpose: an engine that cannot edit structure at all hides the - /// pair rather than showing two dead buttons on every tab. - @Test("An engine that cannot edit structure hides the pair entirely") - func readOnlyEngineHidesThePair() { - let capability = resolve(tab: .columns, kind: .table, canEditSchema: false) - #expect(!capability.isActive) - #expect(capability.addLabel.isEmpty) - #expect(!capability.canAdd) - #expect(!capability.canRemove) + // The pair stays on screen beneath every section; a table whose structure cannot be edited dims it. + @Test("A table the connection cannot edit offers neither button") + func uneditableTableDimsThePair() { + let editor = makeEditor(kind: .table, isSessionBound: true) + for section in StructureSection.allCases { + selectFirst(section, in: editor) + #expect(!editor.canAdd(section), "\(section.rawValue)") + #expect(!editor.canRemoveSelection(in: section), "\(section.rawValue)") + } } @Test("Nothing selected dims Remove without dimming Add and without inventing a reason") func emptySelectionOnlyDimsRemove() { - let capability = resolve(tab: .columns, kind: .table, hasSelection: false) - #expect(capability.canAdd) - #expect(!capability.canRemove) - #expect(capability.unavailableReason == nil) - } - - @Test("The tabs with nothing to add have no operation and no pair") - func nonEditableTabsHaveNoPair() { - for tab in [StructureTab.options, .ddl, .parts, .triggers] { - #expect(StructureFooterPolicy.operation(forAdding: tab) == nil, "\(tab.rawValue)") - #expect(StructureFooterPolicy.operation(forRemoving: tab) == nil, "\(tab.rawValue)") - #expect(!resolve(tab: tab, kind: .table).isActive, "\(tab.rawValue)") - } + let editor = makeEditor(kind: .table) + #expect(editor.canAdd(.columns)) + #expect(!editor.canRemoveSelection(in: .columns)) + #expect(editor.refusal(adding: .columns) == nil) } - @Test("Each editable tab names the add and the drop of its own object") - func operationsMatchTheirTabs() { + @Test("Each section names the add and the drop of its own object") + func operationsMatchTheirSections() { #expect(StructureFooterPolicy.operation(forAdding: .columns) == .addColumn) #expect(StructureFooterPolicy.operation(forRemoving: .columns) == .dropColumn) #expect(StructureFooterPolicy.operation(forAdding: .indexes) == .addIndex) @@ -143,13 +156,25 @@ struct StructureFooterPolicyTests { #expect(StructureFooterPolicy.operation(forRemoving: .checkConstraints) == .dropCheckConstraint) } - /// An engine nobody has measured falls back to tables only, so every non-table dims rather than - /// offering an edit its server may refuse. + @Test("Each section labels both buttons, and no two sections share a label") + func everySectionHasItsOwnLabels() { + let labels = StructureSection.allCases.map { StructureFooterPolicy.labels(for: $0) } + for (section, label) in zip(StructureSection.allCases, labels) { + #expect(!label.add.isEmpty, "\(section.rawValue)") + #expect(!label.remove.isEmpty, "\(section.rawValue)") + #expect(label.add != label.remove, "\(section.rawValue)") + } + let adds = Set(labels.map { $0.add }) + let removes = Set(labels.map { $0.remove }) + #expect(adds.count == StructureSection.allCases.count) + #expect(removes.count == StructureSection.allCases.count) + } + + // An engine nobody has measured falls back to tables only, so every non-table dims. @Test("An uncurated engine dims the pair on a view") func uncuratedEngineDimsNonTables() { - let capability = resolve(tab: .columns, kind: .view, matrix: .tablesOnly) - #expect(!capability.canAdd) - #expect(capability.isActive) - #expect(capability.unavailableReason?.isEmpty == false) + let editor = makeEditor(kind: .view, type: .mysql) + #expect(!editor.canAdd(.columns)) + #expect(editor.refusal(adding: .columns)?.isEmpty == false) } } diff --git a/apple/TableProTests/Views/Structure/StructureGridAdapterAddRowTests.swift b/apple/TableProTests/Views/Structure/StructureGridAdapterAddRowTests.swift deleted file mode 100644 index a013912f7e..0000000000 --- a/apple/TableProTests/Views/Structure/StructureGridAdapterAddRowTests.swift +++ /dev/null @@ -1,204 +0,0 @@ -// -// StructureGridAdapterAddRowTests.swift -// TableProTests -// -// Add and Delete per sub-tab, the contract the footer +/- buttons, Cmd+Shift+N and the row menus share. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@MainActor -struct StructureGridAdapterAddRowTests { - private func makeAdapter( - selectedTab: StructureTab = .columns, - type: DatabaseType = .mysql, - objectKind: TableInfo.TableType = .table, - manager: StructureChangeManager = StructureChangeManager() - ) -> (StructureGridAdapter, StructureChangeManager) { - let adapter = StructureGridAdapter( - structureChangeManager: manager, - connection: TestFixtures.makeConnection(type: type), - tableName: "t", - databaseName: "db", - schemaName: nil, - objectKind: objectKind, - isSessionBound: false - ) - adapter.store.switchSubTab(to: selectedTab) - adapter.refresh() - return (adapter, manager) - } - - private func row(_ sourceIndex: Int) -> RowID { - StructureRowProvider.rowID(forSourceIndex: sourceIndex) - } - - private func counts(_ manager: StructureChangeManager) -> [Int] { - [manager.workingColumns.count, manager.workingIndexes.count, manager.workingForeignKeys.count] - } - - @Test("Columns sub-tab: Add Row appends a placeholder column") - func columnsTabAddsColumn() { - let (adapter, manager) = makeAdapter(selectedTab: .columns) - let before = manager.workingColumns.count - adapter.gridAddRow() - #expect(manager.workingColumns.count == before + 1) - } - - @Test("Indexes sub-tab: Add Row appends a placeholder index") - func indexesTabAddsIndex() { - let (adapter, manager) = makeAdapter(selectedTab: .indexes) - let before = manager.workingIndexes.count - adapter.gridAddRow() - #expect(manager.workingIndexes.count == before + 1) - } - - @Test("Foreign keys sub-tab: Add Row appends a placeholder foreign key") - func foreignKeysTabAddsForeignKey() { - let (adapter, manager) = makeAdapter(selectedTab: .foreignKeys) - let before = manager.workingForeignKeys.count - adapter.gridAddRow() - #expect(manager.workingForeignKeys.count == before + 1) - } - - @Test("Add Row does nothing on a sub-tab without a grid", arguments: [StructureTab.options, .ddl, .parts, .triggers]) - func gridlessTabAddsNothing(tab: StructureTab) { - let (adapter, manager) = makeAdapter(selectedTab: tab) - let before = counts(manager) - adapter.gridAddRow() - #expect(counts(manager) == before) - } - - @Test("Delete does nothing on a sub-tab without a grid", arguments: [StructureTab.options, .ddl, .parts, .triggers]) - func gridlessTabDeletesNothing(tab: StructureTab) { - let (adapter, manager) = makeAdapter(selectedTab: tab) - let before = counts(manager) - adapter.gridDeleteRows([row(0)]) - #expect(counts(manager) == before) - } - - // SQLite's curated snapshot overrides neither supportsAddIndex nor supportsDropIndex, so both default to true. - @Test("Indexes sub-tab on SQLite: Add Row appends an index") - func sqliteIndexesAddsAnIndex() { - let (adapter, manager) = makeAdapter(selectedTab: .indexes, type: .sqlite) - let before = manager.workingIndexes.count - adapter.gridAddRow() - #expect(manager.workingIndexes.count == before + 1) - } - - @Test("Columns sub-tab: Delete removes the column the row names") - func columnsTabDeleteRemovesColumn() { - let (adapter, manager) = makeAdapter(selectedTab: .columns) - adapter.gridAddRow() - let after = manager.workingColumns.count - #expect(after > 0) - adapter.gridDeleteRows([row(after - 1)]) - #expect(manager.workingColumns.count == after - 1) - } - - @Test("Indexes sub-tab: Delete removes the index the row names") - func indexesTabDeleteRemovesIndex() { - let (adapter, manager) = makeAdapter(selectedTab: .indexes) - adapter.gridAddRow() - let after = manager.workingIndexes.count - #expect(after > 0) - adapter.gridDeleteRows([row(after - 1)]) - #expect(manager.workingIndexes.count == after - 1) - } - - @Test("Foreign keys sub-tab: Delete removes the foreign key the row names") - func foreignKeysTabDeleteRemovesForeignKey() { - let (adapter, manager) = makeAdapter(selectedTab: .foreignKeys) - adapter.gridAddRow() - let after = manager.workingForeignKeys.count - #expect(after > 0) - adapter.gridDeleteRows([row(after - 1)]) - #expect(manager.workingForeignKeys.count == after - 1) - } - - @Test("Indexes sub-tab on SQLite: Delete removes an index") - func sqliteIndexesDeletesAnIndex() { - let manager = StructureChangeManager() - manager.addIndex(.placeholder()) - let (adapter, _) = makeAdapter(selectedTab: .indexes, type: .sqlite, manager: manager) - let before = manager.workingIndexes.count - - adapter.gridDeleteRows([row(before - 1)]) - - #expect(manager.workingIndexes.count == before - 1) - } - - @Test("Deleting the last row selects the row above it") - func deletingTheLastRowSelectsTheOneAbove() { - let manager = StructureChangeManager() - for _ in 0..<3 { - manager.addColumn(.placeholder()) - } - let (adapter, _) = makeAdapter(selectedTab: .columns, manager: manager) - adapter.model.selectRows([row(2)]) - - adapter.gridDeleteRows([row(2)]) - - #expect(manager.workingColumns.count == 2) - #expect(adapter.model.selectedRowIDs == [row(1)]) - } - - // MARK: - Per object kind - - // Cmd+Shift+N, the row menus and the footer all reach the adapter, so it refuses for itself (#2726). - @Test("A PostgreSQL view refuses an added column, whatever reaches the adapter") - func viewRefusesAnAddedColumn() { - let (adapter, manager) = makeAdapter(selectedTab: .columns, type: .postgresql, objectKind: .view) - adapter.gridAddRow() - #expect(manager.workingColumns.isEmpty) - #expect(!adapter.model.configuration.commands.contains(.addRow)) - } - - @Test("A PostgreSQL view refuses an added index and a materialized view accepts one") - func indexesFollowTheObjectKind() { - let (view, viewManager) = makeAdapter(selectedTab: .indexes, type: .postgresql, objectKind: .view) - view.gridAddRow() - #expect(viewManager.workingIndexes.isEmpty) - - let (matview, matviewManager) = makeAdapter( - selectedTab: .indexes, type: .postgresql, objectKind: .materializedView - ) - matview.gridAddRow() - #expect(matviewManager.workingIndexes.count == 1) - } - - @Test("A PostgreSQL materialized view refuses a foreign key") - func materializedViewRefusesAForeignKey() { - let (adapter, manager) = makeAdapter( - selectedTab: .foreignKeys, type: .postgresql, objectKind: .materializedView - ) - adapter.gridAddRow() - #expect(manager.workingForeignKeys.isEmpty) - } - - @Test("A view refuses to drop a column its own grid is showing") - func viewRefusesADroppedColumn() { - let manager = StructureChangeManager() - manager.addColumn(.placeholder()) - #expect(manager.workingColumns.count == 1) - let (view, _) = makeAdapter(selectedTab: .columns, type: .postgresql, objectKind: .view, manager: manager) - - view.gridDeleteRows([row(0)]) - - #expect(manager.workingColumns.count == 1) - #expect(!view.model.configuration.commands.contains(.deleteRows)) - } - - @Test("A system table refuses every add", arguments: [StructureTab.columns, .indexes, .foreignKeys, .checkConstraints]) - func systemTableRefusesEveryAdd(tab: StructureTab) { - let (adapter, manager) = makeAdapter(selectedTab: tab, type: .postgresql, objectKind: .systemTable) - adapter.gridAddRow() - #expect(manager.workingColumns.isEmpty) - #expect(manager.workingIndexes.isEmpty) - #expect(manager.workingForeignKeys.isEmpty) - #expect(manager.workingCheckConstraints.isEmpty) - } -} diff --git a/apple/TableProTests/Views/Structure/StructureGridAdapterInspectorTests.swift b/apple/TableProTests/Views/Structure/StructureGridAdapterInspectorTests.swift deleted file mode 100644 index 8137a54eb2..0000000000 --- a/apple/TableProTests/Views/Structure/StructureGridAdapterInspectorTests.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// StructureGridAdapterInspectorTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@MainActor -struct StructureGridAdapterInspectorTests { - private func connection(_ type: DatabaseType = .mysql) -> DatabaseConnection { - DatabaseConnection( - name: "Test", - host: "localhost", - port: type == .postgresql ? 5_432 : 3_306, - database: "test", - username: "root", - type: type - ) - } - - private func loadSchema(into 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"] - ) - } - - // The session owns the adapter in the app, and its search text is what filters the provider. - private func makeSession( - type: DatabaseType = .mysql, - objectKind: TableInfo.TableType = .table, - tab: StructureTab = .columns, - searchText: String = "" - ) -> StructureEditingSession { - let session = StructureEditingSession( - identity: "test.users", - connection: connection(type), - databaseName: "test", - schemaName: nil, - tableName: "users", - objectKind: objectKind - ) - loadSchema(into: session.changeManager) - session.selectedTab = tab - session.searchText = searchText - session.gridAdapter.refresh() - return session - } - - private func fieldIndex(_ adapter: StructureGridAdapter, _ field: StructureColumnField) throws -> Int { - try #require(adapter.provider.orderedColumnFields.firstIndex(of: field)) - } - - @Test("A row is read-only while a save holds the edits, and editable again once it lets go") - func heldSaveMakesTheRowReadOnly() throws { - let session = makeSession() - let manager = session.changeManager - let adapter = session.gridAdapter - var email = try #require(manager.workingColumns.first { $0.name == "email" }) - email.comment = "Contact" - manager.updateColumn(id: email.id, with: email) - adapter.refresh() - - let snapshot = try #require(manager.holdForSave()) - #expect(adapter.inspectorRow(atDisplayRow: 1)?.isEditable == false) - - manager.releaseHold(snapshot, written: false) - #expect(adapter.inspectorRow(atDisplayRow: 1)?.isEditable == true) - } - - // A hold changes no row, so the inspector kept the fields it built before the save and dropped what was typed. - @Test("A save hold and its release each rebuild the inspector") - func heldSaveRebuildsTheInspector() throws { - let fixture = CoordinatorFixture(connection: connection()) - let session = makeSession() - let manager = session.changeManager - let adapter = session.gridAdapter - adapter.coordinator = fixture.coordinator - var email = try #require(manager.workingColumns.first { $0.name == "email" }) - email.comment = "Contact" - manager.updateColumn(id: email.id, with: email) - adapter.refresh() - let beforeHold = fixture.coordinator.inspectorRowSourceRevision - - let snapshot = try #require(manager.holdForSave()) - adapter.refresh() - #expect(fixture.coordinator.inspectorRowSourceRevision == beforeHold + 1) - - manager.releaseHold(snapshot, written: false) - adapter.refresh() - #expect(fixture.coordinator.inspectorRowSourceRevision == beforeHold + 2) - } - - @Test("The published row describes the structure grid, not the data grid") - func publishedRowDescribesStructure() throws { - let session = makeSession() - let adapter = session.gridAdapter - let row = try #require(adapter.inspectorRow(atDisplayRow: 1)) - - #expect(row.fields.map(\.name) == adapter.provider.orderedColumnFields.map(\.displayName)) - #expect(row.fields.first?.value == "email") - } - - @Test("An inspector edit lands on the column the display row points at under a search") - func editResolvesFilteredDisplayRow() throws { - let session = makeSession(searchText: "email") - let adapter = session.gridAdapter - #expect(adapter.model.rowCount == 1) - - adapter.commitInspectorField( - displayRow: 0, - fieldIndex: try fieldIndex(adapter, .name), - value: "user_email" - ) - - #expect(session.changeManager.workingColumns[0].name == "id") - #expect(session.changeManager.workingColumns[1].name == "user_email") - } - - @Test("An inspector edit records a pending schema change") - func editRecordsPendingChange() throws { - let session = makeSession() - let adapter = session.gridAdapter - - adapter.commitInspectorField( - displayRow: 1, - fieldIndex: try fieldIndex(adapter, .type), - value: "TEXT" - ) - - #expect(session.changeManager.hasChanges) - #expect(session.changeManager.workingColumns[1].dataType == "TEXT") - } - - @Test("A flag field commits the value the dropdown offers") - func flagEditCommitsBooleanValue() throws { - let session = makeSession() - let adapter = session.gridAdapter - - adapter.commitInspectorField( - displayRow: 1, - fieldIndex: try fieldIndex(adapter, .nullable), - value: "NO" - ) - - #expect(session.changeManager.workingColumns[1].isNullable == false) - } - - // A Foreign Keys inspector built before a reference list arrived holds only "Loading…" until it rebuilds. - @Test("A reference list landing rebuilds the structure inspector") - func referenceListArrivalRebuildsInspector() { - let fixture = CoordinatorFixture(connection: connection()) - let session = makeSession(tab: .foreignKeys) - session.gridAdapter.coordinator = fixture.coordinator - let revision = fixture.coordinator.inspectorRowSourceRevision - - session.gridAdapter.referenceMenus.onListsChanged?() - - #expect(fixture.coordinator.inspectorRowSourceRevision == revision + 1) - } - - @Test("Before its first refresh the adapter publishes nothing") - func beforeRefreshPublishesNothing() { - let manager = StructureChangeManager() - loadSchema(into: manager) - let adapter = StructureGridAdapter( - structureChangeManager: manager, - connection: connection(), - tableName: "users", - databaseName: "test", - schemaName: nil, - objectKind: .table, - isSessionBound: false - ) - - #expect(adapter.inspectorRow(atDisplayRow: 0) == nil) - } - - @Test("The new-table grid publishes its own rows and takes edits") - func createTableAdapterPublishesRows() throws { - let manager = StructureChangeManager() - manager.addNewColumn() - let adapter = CreateTableGridAdapter(structureChangeManager: manager, connection: connection()) - adapter.refresh() - - let row = try #require(adapter.inspectorRow(atDisplayRow: 0)) - #expect(row.isEditable) - - let nameIndex = try #require(adapter.provider.orderedColumnFields.firstIndex(of: .name)) - adapter.commitInspectorField(displayRow: 0, fieldIndex: nameIndex, value: "sku") - #expect(manager.workingColumns[0].name == "sku") - } - - // MARK: - A kind that locks fields - - // The grid locked a materialized view's Type, Nullable and Default while the inspector offered all three. - private func makeMaterializedView(tab: StructureTab = .columns) -> StructureEditingSession { - makeSession(type: .postgresql, objectKind: .materializedView, tab: tab) - } - - @Test("A materialized view's inspector locks the fields its grid locks") - func materializedViewInspectorLocksFields() throws { - let adapter = makeMaterializedView().gridAdapter - let row = try #require(adapter.inspectorRow(atDisplayRow: 1)) - - #expect(row.isEditable) - #expect(row.fields[try fieldIndex(adapter, .name)].isEditable) - #expect(row.fields[try fieldIndex(adapter, .comment)].isEditable) - #expect(!row.fields[try fieldIndex(adapter, .type)].isEditable) - #expect(!row.fields[try fieldIndex(adapter, .nullable)].isEditable) - #expect(!row.fields[try fieldIndex(adapter, .defaultValue)].isEditable) - } - - @Test("The grid locks the same headings the inspector locks") - func gridLocksTheSameHeadings() throws { - let adapter = makeMaterializedView().gridAdapter - let headings = adapter.provider.columns - let locked = adapter.model.configuration.lockedColumns - - #expect(locked.contains(headings[try fieldIndex(adapter, .type)])) - #expect(locked.contains(headings[try fieldIndex(adapter, .nullable)])) - #expect(!locked.contains(headings[try fieldIndex(adapter, .name)])) - } - - @Test("A locked field refuses the edit even when it arrives through the inspector") - func lockedFieldRefusesTheCommit() throws { - let session = makeMaterializedView() - let adapter = session.gridAdapter - - adapter.commitInspectorField(displayRow: 1, fieldIndex: try fieldIndex(adapter, .type), value: "TEXT") - adapter.commitInspectorField(displayRow: 1, fieldIndex: try fieldIndex(adapter, .defaultValue), value: "'x'") - - #expect(!session.changeManager.hasChanges) - #expect(session.changeManager.workingColumns[1].dataType == "VARCHAR(255)") - #expect(session.changeManager.workingColumns[1].defaultValue == nil) - } - - @Test("An unlocked field on the same object still takes the edit") - func unlockedFieldTakesTheCommit() throws { - let session = makeMaterializedView() - let adapter = session.gridAdapter - - adapter.commitInspectorField(displayRow: 1, fieldIndex: try fieldIndex(adapter, .name), value: "mail") - - #expect(session.changeManager.workingColumns[1].name == "mail") - } - - @Test("A table locks nothing, so every field stays open") - func tableLocksNothing() { - let adapter = makeSession().gridAdapter - #expect(adapter.lockedFieldIndices.isEmpty) - #expect(adapter.model.configuration.lockedColumns.isEmpty) - } - - @Test("The row commands offer Delete only where the object accepts the drop") - func rowCommandsFollowTheGate() { - let columns = makeMaterializedView(tab: .columns).gridAdapter - #expect(!columns.canStageRemoveForSelectedTab) - #expect(!columns.canStageAddForSelectedTab) - #expect(columns.model.configuration.commands.isDisjoint(with: [.deleteRows, .addRow, .duplicateRows])) - - let indexes = makeMaterializedView(tab: .indexes).gridAdapter - #expect(indexes.canStageRemoveForSelectedTab) - #expect(indexes.canStageAddForSelectedTab) - #expect(indexes.model.configuration.commands.isSuperset(of: [.deleteRows, .addRow, .duplicateRows])) - } -} diff --git a/apple/TableProTests/Views/Structure/StructureGridStateStoreTests.swift b/apple/TableProTests/Views/Structure/StructureGridStateStoreTests.swift index ee853acd11..73bca4489b 100644 --- a/apple/TableProTests/Views/Structure/StructureGridStateStoreTests.swift +++ b/apple/TableProTests/Views/Structure/StructureGridStateStoreTests.swift @@ -12,15 +12,15 @@ import Testing @MainActor struct StructureGridStateStoreTests { - private func makeSession(table: String = "orders_\(UUID().uuidString.prefix(8))") -> StructureEditingSession { - let session = StructureEditingSession( - identity: "shop.\(table)", - connection: TestFixtures.makeConnection(database: "shop", type: .mysql), - databaseName: "shop", - schemaName: nil, - tableName: table - ) - session.changeManager.loadSchema( + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeEditor(table: String = "orders_\(UUID().uuidString.prefix(8))") -> StructureDefinitionEditor { + let manager = StructureChangeManager() + manager.loadSchema( tableName: table, columns: [ ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true), @@ -30,108 +30,172 @@ struct StructureGridStateStoreTests { foreignKeys: [], primaryKey: ["id"] ) - session.gridAdapter.refresh() - return session + let editor = StructureDefinitionEditor( + manager: manager, + context: .existingTable( + connection: TestFixtures.makeConnection(database: "shop", type: .mysql), + databaseName: "shop", + schemaName: nil, + tableName: table, + objectKind: .table, + isSessionBound: false + ), + defaults: suite.defaults + ) + editor.refresh() + return editor } - private func nameHeading(_ session: StructureEditingSession) throws -> String { - try #require(session.gridModel.columns.column(at: 0)).name + private func nameHeading(_ model: DataGridModel) throws -> String { + try #require(model.columns.column(at: 0)).name } - @Test("A column resized in the Structure grid stays on the session and never reaches the table's saved layout") + @Test("A column resized in a section's grid stays on its store and never reaches the table's saved layout") func resizeNeverWritesTheTableLayout() throws { - let session = makeSession() - let model = session.gridModel - let name = try nameHeading(session) + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + let model = adapter.model + let name = try nameHeading(model) let tableKey = ColumnLayoutTableKey( - connectionId: session.connection.id, + connectionId: editor.context.connection.id, databaseName: "shop", schemaName: nil, - tableName: session.tableName + tableName: editor.context.tableName ) model.columnGeometryDidChange(widths: [name: 260], order: nil) #expect(model.store.layoutScope == .ephemeral) - #expect(session.columnLayouts[.columns]?.columnWidths[name] == 260) - #expect(model.store.columnLayout.columnWidths[name] == 260) + #expect(adapter.store.columnLayout.columnWidths[name] == 260) #expect(FileColumnLayoutPersister.shared.load(for: tableKey) == nil) } - @Test("Each sub-tab keeps its own column layout") - func layoutIsPerSubTab() throws { - let session = makeSession() - let model = session.gridModel - let name = try nameHeading(session) - model.columnGeometryDidChange(widths: [name: 260], order: nil) + @Test("Each section keeps its own column layout") + func layoutIsPerSection() throws { + let editor = makeEditor() + let columns = editor.adapter(for: .columns) + let indexes = editor.adapter(for: .indexes) + let name = try nameHeading(columns.model) - session.selectedTab = .indexes - session.gridAdapter.refresh() - #expect(model.store.columnLayout.columnWidths[name] == nil) - model.columnGeometryDidChange(widths: [name: 180], order: nil) + columns.model.columnGeometryDidChange(widths: [name: 260], order: nil) + #expect(indexes.store.columnLayout.columnWidths[name] == nil) + indexes.model.columnGeometryDidChange(widths: [name: 180], order: nil) - session.selectedTab = .columns - session.gridAdapter.refresh() - #expect(model.store.columnLayout.columnWidths[name] == 260) - #expect(session.columnLayouts[.indexes]?.columnWidths[name] == 180) + #expect(columns.store.columnLayout.columnWidths[name] == 260) + #expect(indexes.store.columnLayout.columnWidths[name] == 180) } - @Test("Another sub-tab starts with no value filter and no selection") - func subTabSwitchStartsTheViewOver() throws { - let session = makeSession() - let model = session.gridModel - let name = try nameHeading(session) + @Test("A value filter in one section leaves the others unfiltered") + func valueFilterIsPerSection() throws { + let editor = makeEditor() + let columns = editor.adapter(for: .columns) + let indexes = editor.adapter(for: .indexes) + let name = try nameHeading(columns.model) var filter = GridValueFilterState() filter.set(ColumnValueFilter(selectedValues: ["id"], includesNull: false), columnName: name, forColumn: 0) - model.setValueFilter(filter) - model.selectRows([StructureRowProvider.rowID(forSourceIndex: 0)]) - #expect(model.store.valueFilter.isActive) - #expect(!model.selection.isEmpty) - - session.selectedTab = .indexes - session.gridAdapter.refresh() - - #expect(!model.store.valueFilter.isActive) - #expect(model.store.selectionSnapshot.isEmpty) - #expect(model.selection.isEmpty) - #expect(model.rowCount == 1) + + columns.model.setValueFilter(filter) + editor.refresh() + + #expect(columns.store.valueFilter.isActive) + #expect(columns.model.rowCount == 1) + #expect(!indexes.store.valueFilter.isActive) + #expect(indexes.model.rowCount == 1) } - @Test("The sort lives on the session, which is what orders the provider's rows") - func sortLivesOnTheSession() { - let session = makeSession() - let model = session.gridModel + @Test("The sort lives on the section's store, which is what orders the provider's rows") + func sortLivesOnTheSectionStore() { + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + let model = adapter.model let descending = SortState(columns: [SortColumn(columnIndex: 0, direction: .descending)], source: .user) model.setSort(descending) - #expect(session.sortState == descending) - #expect(session.sortDescriptor?.column == 0) - #expect(session.sortDescriptor?.ascending == false) + #expect(adapter.store.sortState == descending) #expect(model.rows.rows.compactMap { $0.values.first?.asText } == ["total", "id"]) + #expect(editor.adapter(for: .indexes).store.sortState.columns.isEmpty) + } + + @Test("A sort survives a refresh of the definitions") + func sortSurvivesRefresh() { + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + adapter.model.setSort(SortState(columns: [SortColumn(columnIndex: 0, direction: .descending)], source: .user)) + + var amount = EditableColumnDefinition.placeholder() + amount.name = "amount" + amount.dataType = "INT" + editor.manager.addColumn(amount) + editor.refresh() + + #expect(adapter.model.rows.rows.compactMap { $0.values.first?.asText } == ["total", "id", "amount"]) } // The provider sorts by one key, so a secondary key the header appended is dropped (changed on purpose). @Test("A secondary sort key is dropped, since the provider sorts by one column") func secondarySortKeyIsDropped() { - let session = makeSession() - let model = session.gridModel + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) let primary = SortColumn(columnIndex: 1, direction: .ascending) - model.setSort(SortState(columns: [primary, SortColumn(columnIndex: 0, direction: .descending)], source: .user)) + adapter.model.setSort(SortState(columns: [primary, SortColumn(columnIndex: 0, direction: .descending)], source: .user)) - #expect(session.sortState.columns == [primary]) - #expect(model.store.sortState.columns == [primary]) + #expect(adapter.store.sortState.columns == [primary]) + #expect(adapter.model.store.sortState.columns == [primary]) } - @Test("Create Table's grid is ephemeral too") + @Test("Create Table's grids are ephemeral too") func createTableIsEphemeral() { - let adapter = CreateTableGridAdapter( - structureChangeManager: StructureChangeManager(), - connection: TestFixtures.makeConnection(type: .postgresql) + let editor = StructureDefinitionEditor( + manager: StructureChangeManager(), + context: .newTable(connection: TestFixtures.makeConnection(type: .postgresql), databaseName: "shop", schemaName: "public"), + defaults: suite.defaults ) - adapter.refresh() + editor.refresh() + + for section in StructureSection.allCases { + #expect(editor.adapter(for: section).model.store.layoutScope == .ephemeral, "\(section.rawValue)") + } + } + + // MARK: - Hidden columns + + @Test("A section starts with its lean default columns hidden") + func defaultHiddenColumns() { + let columns = StructureGridStateStore(section: .columns, defaults: suite.defaults) + #expect(columns.columnLayout.hiddenColumns == StructureGridStateStore.defaultHiddenColumns(.columns)) + #expect(columns.columnLayout.hiddenColumns.contains(StructureColumnField.collation.displayName)) + #expect(!columns.columnLayout.hiddenColumns.contains(StructureColumnField.name.displayName)) + #expect(StructureGridStateStore(section: .indexes, defaults: suite.defaults).columnLayout.hiddenColumns.isEmpty) + } + + @Test("Columns a person hides stay hidden for the next table, per section") + func hiddenColumnsPersistPerSection() { + let comment = StructureColumnField.comment.displayName + let store = StructureGridStateStore(section: .columns, defaults: suite.defaults) + store.columnLayout.hiddenColumns = [comment] + + #expect(StructureGridStateStore(section: .columns, defaults: suite.defaults).columnLayout.hiddenColumns == [comment]) + #expect( + StructureGridStateStore(section: .foreignKeys, defaults: suite.defaults).columnLayout.hiddenColumns + == StructureGridStateStore.defaultHiddenColumns(.foreignKeys) + ) + } + + @Test("Showing every column is kept too, so the defaults do not come back") + func showingEveryColumnPersists() { + let store = StructureGridStateStore(section: .columns, defaults: suite.defaults) + store.columnLayout.hiddenColumns = [] + + #expect(StructureGridStateStore(section: .columns, defaults: suite.defaults).columnLayout.hiddenColumns.isEmpty) + } + + @Test("A width change writes nothing to the defaults") + func widthsAreNotPersisted() { + let store = StructureGridStateStore(section: .indexes, defaults: suite.defaults) + store.columnLayout.columnWidths["Name"] = 200 - #expect(adapter.model.store.layoutScope == .ephemeral) + #expect(suite.defaults.object(forKey: StructureGridStateStore.hiddenColumnsKey(.indexes)) == nil) } } diff --git a/apple/TableProTests/Views/Structure/StructureGridValueFilterEditTests.swift b/apple/TableProTests/Views/Structure/StructureGridValueFilterEditTests.swift index 38612e4914..7e341d2510 100644 --- a/apple/TableProTests/Views/Structure/StructureGridValueFilterEditTests.swift +++ b/apple/TableProTests/Views/Structure/StructureGridValueFilterEditTests.swift @@ -3,7 +3,7 @@ // TableProTests // // D2: under the grid's value filter the Structure and Create Table grids mapped a display row to the wrong -// definition. Every edit now names its row by RowID, the definition's index in the working list. +// definition. Every edit now names its row by RowID, which the section's adapter mints per definition. // import Foundation @@ -14,16 +14,15 @@ import Testing @MainActor struct StructureGridValueFilterEditTests { private let columnNames = ["id", "email", "created_at"] + private let suite: EphemeralDefaults - private func makeSession() -> StructureEditingSession { - let session = StructureEditingSession( - identity: "shop.users", - connection: TestFixtures.makeConnection(database: "shop", type: .mysql), - databaseName: "shop", - schemaName: nil, - tableName: "users" - ) - session.changeManager.loadSchema( + init() throws { + suite = try EphemeralDefaults() + } + + private func makeEditor() -> StructureDefinitionEditor { + let manager = StructureChangeManager() + manager.loadSchema( tableName: "users", columns: columnNames.map { ColumnInfo(name: $0, dataType: "VARCHAR(64)", isNullable: true, isPrimaryKey: false) @@ -32,8 +31,30 @@ struct StructureGridValueFilterEditTests { foreignKeys: [], primaryKey: [] ) - session.gridAdapter.refresh() - return session + let editor = StructureDefinitionEditor( + manager: manager, + context: .existingTable( + connection: TestFixtures.makeConnection(database: "shop", type: .mysql), + databaseName: "shop", + schemaName: nil, + tableName: "users", + objectKind: .table, + isSessionBound: false + ), + defaults: suite.defaults + ) + editor.refresh() + return editor + } + + private func makeCreateTableEditor(_ manager: StructureChangeManager) -> StructureDefinitionEditor { + let editor = StructureDefinitionEditor( + manager: manager, + context: .newTable(connection: TestFixtures.makeConnection(type: .postgresql), databaseName: "shop", schemaName: "public"), + defaults: suite.defaults + ) + editor.refresh() + return editor } // Keeps only the rows whose Name is `value`. @@ -52,40 +73,43 @@ struct StructureGridValueFilterEditTests { @Test("A Structure edit on the one row a value filter leaves lands on that column") func structureEditUnderFilter() throws { - let session = makeSession() - let model = session.gridModel + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + let model = adapter.model try filterName(to: "created_at", on: model) try #require(model.rowCount == 1) - #expect(model.rowID(at: 0) == StructureRowProvider.rowID(forSourceIndex: 2)) + #expect(model.rowID(at: 0) == adapter.rowID(forEntity: editor.manager.workingColumns[2].id)) #expect(try commitName("created_on", atDisplayRow: 0, on: model)) - #expect(session.changeManager.workingColumns.map(\.name) == ["id", "email", "created_on"]) + #expect(editor.manager.workingColumns.map(\.name) == ["id", "email", "created_on"]) } @Test("A Structure inspector edit under a value filter lands on the row the grid shows") func structureInspectorEditUnderFilter() throws { - let session = makeSession() - let adapter = session.gridAdapter - try filterName(to: "email", on: session.gridModel) + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + try filterName(to: "email", on: adapter.model) + let shown = try #require(adapter.model.rowID(at: 0)) + let id = try #require(adapter.entityID(of: shown)) let nameField = try #require(adapter.provider.orderedColumnFields.firstIndex(of: .name)) - #expect(adapter.inspectorRow(atDisplayRow: 0)?.fields.first?.value == "email") - adapter.commitInspectorField(displayRow: 0, fieldIndex: nameField, value: "mail") + #expect(editor.inspectorForm(for: .columns, id: id)?.name == "email") + editor.commitInspectorField(section: .columns, id: id, field: nameField, value: "mail") - #expect(session.changeManager.workingColumns.map(\.name) == ["id", "mail", "created_at"]) + #expect(editor.manager.workingColumns.map(\.name) == ["id", "mail", "created_at"]) } @Test("A Structure delete under a value filter marks the column the grid shows") func structureDeleteUnderFilter() throws { - let session = makeSession() - let model = session.gridModel - try filterName(to: "email", on: model) - let shown = try #require(model.rowID(at: 0)) + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + try filterName(to: "email", on: adapter.model) + let shown = try #require(adapter.model.rowID(at: 0)) - session.gridAdapter.gridDeleteRows([shown]) + adapter.gridDeleteRows([shown]) - let manager = session.changeManager + let manager = editor.manager #expect(manager.deleteInsertState(for: 1, tab: .columns).isDeleted) #expect(!manager.deleteInsertState(for: 0, tab: .columns).isDeleted) #expect(!manager.deleteInsertState(for: 2, tab: .columns).isDeleted) @@ -93,23 +117,24 @@ struct StructureGridValueFilterEditTests { @Test("A Structure edit under a sort and a value filter still lands on the row it names") func structureEditUnderSortAndFilter() throws { - let session = makeSession() - let model = session.gridModel + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + let model = adapter.model model.setSort(SortState(columns: [SortColumn(columnIndex: 0, direction: .ascending)], source: .user)) #expect(model.rows.rows.compactMap { $0.values.first?.asText } == ["created_at", "email", "id"]) try filterName(to: "id", on: model) - #expect(model.rowID(at: 0) == StructureRowProvider.rowID(forSourceIndex: 0)) + #expect(model.rowID(at: 0) == adapter.rowID(forEntity: editor.manager.workingColumns[0].id)) #expect(try commitName("user_id", atDisplayRow: 0, on: model)) - #expect(session.changeManager.workingColumns.map(\.name) == ["user_id", "email", "created_at"]) + #expect(editor.manager.workingColumns.map(\.name) == ["user_id", "email", "created_at"]) } // A value filter rearranges the list, so the drag that moves a column is withdrawn (changed on purpose). @Test("A value filter withdraws the column move") func valueFilterWithdrawsTheColumnMove() throws { - let session = makeSession() - let model = session.gridModel + let editor = makeEditor() + let model = editor.adapter(for: .columns).model try filterName(to: "email", on: model) #expect(!model.configuration.rowReorder.isEnabled) @@ -125,9 +150,8 @@ struct StructureGridValueFilterEditTests { column.dataType = "TEXT" manager.addColumn(column) } - let adapter = CreateTableGridAdapter(structureChangeManager: manager, connection: TestFixtures.makeConnection(type: .postgresql)) - adapter.refresh() - let model = adapter.model + let editor = makeCreateTableEditor(manager) + let model = editor.adapter(for: .columns).model try filterName(to: "email", on: model) try #require(model.rowCount == 1) @@ -144,8 +168,8 @@ struct StructureGridValueFilterEditTests { column.name = name manager.addColumn(column) } - let adapter = CreateTableGridAdapter(structureChangeManager: manager, connection: TestFixtures.makeConnection(type: .postgresql)) - adapter.refresh() + let editor = makeCreateTableEditor(manager) + let adapter = editor.adapter(for: .columns) try filterName(to: "created_at", on: adapter.model) let shown = try #require(adapter.model.rowID(at: 0)) diff --git a/apple/TableProTests/Views/Structure/StructureIndexTypeMenuTests.swift b/apple/TableProTests/Views/Structure/StructureIndexTypeMenuTests.swift index 3855217c90..6d4b813901 100644 --- a/apple/TableProTests/Views/Structure/StructureIndexTypeMenuTests.swift +++ b/apple/TableProTests/Views/Structure/StructureIndexTypeMenuTests.swift @@ -13,6 +13,12 @@ import Testing @MainActor struct StructureIndexTypeMenuTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + private func connection() -> DatabaseConnection { DatabaseConnection(name: "Test", host: "localhost", port: 5_432, database: "test", username: "u", type: .postgresql) } @@ -31,34 +37,30 @@ struct StructureIndexTypeMenuTests { return manager } - private func structureAdapter(_ manager: StructureChangeManager) -> StructureGridAdapter { - let adapter = StructureGridAdapter( - structureChangeManager: manager, - connection: connection(), - tableName: "items", - databaseName: "test", - schemaName: nil, - objectKind: .table, - isSessionBound: false - ) - adapter.store.switchSubTab(to: .indexes) - adapter.refresh() - return adapter + private func makeEditor(_ manager: StructureChangeManager, creatingTable: Bool = false) -> StructureDefinitionEditor { + let context: StructureEditingContext = creatingTable + ? .newTable(connection: connection(), databaseName: "test", schemaName: nil) + : .existingTable( + connection: connection(), + databaseName: "test", + schemaName: nil, + tableName: "items", + objectKind: .table, + isSessionBound: false + ) + let editor = StructureDefinitionEditor(manager: manager, context: context, defaults: suite.defaults) + editor.refresh() + return editor } private func column(_ model: DataGridModel, dataIndex: Int) throws -> DataGridColumn { try #require(model.columns.columns.first { $0.dataIndex == dataIndex }) } - private func options( - _ source: any DataGridDataSource, - model: DataGridModel, - sourceIndex: Int, - dataIndex: Int - ) throws -> [String]? { - source.gridMenuOptions( - for: StructureRowProvider.rowID(forSourceIndex: sourceIndex), - column: try column(model, dataIndex: dataIndex) + private func options(_ adapter: StructureSectionAdapter, sourceIndex: Int, dataIndex: Int) throws -> [String]? { + adapter.gridMenuOptions( + for: adapter.rowID(forEntity: adapter.manager.workingIndexes[sourceIndex].id), + column: try column(adapter.model, dataIndex: dataIndex) )?.compactMap(\.sql) } @@ -66,30 +68,30 @@ struct StructureIndexTypeMenuTests { @Test("A bloom row offers BLOOM after the known types, and a b-tree row offers the known types alone") func rowOffersItsOwnType() throws { - let adapter = structureAdapter(manager(indexTypes: ["bloom", "btree"])) + let editor = makeEditor(manager(indexTypes: ["bloom", "btree"])) + let adapter = editor.adapter(for: .indexes) let typeColumn = StructureRowProvider.indexTypeColumn - #expect(try options(adapter, model: adapter.model, sourceIndex: 0, dataIndex: typeColumn) == Self.knownNames + ["BLOOM"]) - #expect(try options(adapter, model: adapter.model, sourceIndex: 1, dataIndex: typeColumn) == Self.knownNames) - #expect(try options(adapter, model: adapter.model, sourceIndex: 0, dataIndex: 3) == nil) + #expect(try options(adapter, sourceIndex: 0, dataIndex: typeColumn) == Self.knownNames + ["BLOOM"]) + #expect(try options(adapter, sourceIndex: 1, dataIndex: typeColumn) == Self.knownNames) + #expect(try options(adapter, sourceIndex: 0, dataIndex: 3) == nil) } - @Test("The inspector's Type picker for a CLUSTERED row holds the value it shows") - func inspectorPickerHoldsTheRowType() throws { - let adapter = structureAdapter(manager(indexTypes: ["CLUSTERED"])) - let row = try #require(adapter.inspectorRow(atDisplayRow: 0)) - let typeField = row.fields[StructureRowProvider.indexTypeColumn] + @Test("The inspector's Type menu for a CLUSTERED row holds the value it shows") + func inspectorMenuHoldsTheRowType() throws { + let editor = makeEditor(manager(indexTypes: ["CLUSTERED"])) + let form = try #require(editor.inspectorForm(for: .indexes, id: editor.manager.workingIndexes[0].id)) + let typeField = form.fields[StructureRowProvider.indexTypeColumn] #expect(typeField.value == "CLUSTERED") - #expect(typeField.editor == .enumPicker(values: Self.knownNames + ["CLUSTERED"])) + #expect(typeField.kind == .menu(GridMenuOption.values(Self.knownNames + ["CLUSTERED"]))) } @Test("The new-table grid offers a pasted index its own type the same way") func createTableOffersItsOwnType() throws { - let adapter = CreateTableGridAdapter(structureChangeManager: manager(indexTypes: ["hnsw"]), connection: connection()) - adapter.select(.indexes) + let editor = makeEditor(manager(indexTypes: ["hnsw"]), creatingTable: true) - #expect(try options(adapter, model: adapter.model, sourceIndex: 0, dataIndex: StructureRowProvider.indexTypeColumn) + #expect(try options(editor.adapter(for: .indexes), sourceIndex: 0, dataIndex: StructureRowProvider.indexTypeColumn) == Self.knownNames + ["HNSW"]) } diff --git a/apple/TableProTests/Views/Structure/StructureInspectorFormTests.swift b/apple/TableProTests/Views/Structure/StructureInspectorFormTests.swift new file mode 100644 index 0000000000..94ca1adf4c --- /dev/null +++ b/apple/TableProTests/Views/Structure/StructureInspectorFormTests.swift @@ -0,0 +1,228 @@ +// +// StructureInspectorFormTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +// The inspector's form for one definition, as the editor builds it and takes its edits back. +@MainActor +struct StructureInspectorFormTests { + private func makeSession( + type: DatabaseType = .mysql, + objectKind: TableInfo.TableType = .table + ) -> StructureEditingSession { + let session = StructureEditingSession( + identity: "test.users", + connection: TestFixtures.makeConnection(database: "test", type: type), + databaseName: "test", + schemaName: nil, + tableName: "users", + objectKind: objectKind + ) + session.changeManager.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"] + ) + session.markBaselineAdopted() + session.editor.refresh() + return session + } + + private func emailID(_ session: StructureEditingSession) throws -> UUID { + try #require(session.changeManager.workingColumns.first { $0.name == "email" }).id + } + + private func emailForm(_ session: StructureEditingSession) throws -> StructureInspectorForm { + let id = try emailID(session) + return try #require(session.editor.inspectorForm(for: .columns, id: id)) + } + + private func field( + _ form: StructureInspectorForm, + _ field: StructureColumnField + ) throws -> StructureInspectorForm.Field { + try #require(form.fields.first { $0.label == field.displayName }) + } + + private func commit(_ session: StructureEditingSession, _ field: StructureColumnField, _ value: String) throws { + let form = try emailForm(session) + let index = try self.field(form, field).id + session.editor.commitInspectorField(section: .columns, id: form.entityID, field: index, value: value) + } + + // MARK: - Content + + @Test("The form names the definition and lists the grid's own fields") + func formDescribesTheDefinition() throws { + let session = makeSession() + let form = try emailForm(session) + + #expect(form.name == "email") + #expect(form.section == .columns) + #expect(form.isEditable) + #expect(!form.isNew) + #expect(!form.isDeleted) + let labels = form.fields.map { $0.label } + let headings = session.editor.adapter(for: .columns).provider.orderedColumnFields.map { $0.displayName } + #expect(labels == headings) + #expect(try field(form, .type).value == "VARCHAR(255)") + } + + @Test("Each field gets the control its grid cell has") + func controlsFollowTheGrid() throws { + let form = try emailForm(makeSession()) + + #expect(StructureInspectorControl.resolve(try field(form, .name)) == .text) + #expect(StructureInspectorControl.resolve(try field(form, .type)) == .typePicker) + #expect(StructureInspectorControl.resolve(try field(form, .nullable)) == .checkbox) + #expect(StructureInspectorControl.resolve(try field(form, .comment)) == .multiline) + } + + @Test("An edited field reads as modified and the others do not") + func editedFieldIsModified() throws { + let session = makeSession() + var email = try #require(session.changeManager.workingColumns.first { $0.name == "email" }) + email.dataType = "TEXT" + session.changeManager.updateColumn(id: email.id, with: email) + + let form = try emailForm(session) + #expect(try field(form, .type).isModified) + #expect(try !field(form, .name).isModified) + } + + // MARK: - Read-only + + @Test("A definition marked for deletion is read-only") + func deletedDefinitionIsReadOnly() throws { + let session = makeSession() + session.changeManager.deleteColumn(id: try emailID(session)) + + let form = try emailForm(session) + #expect(form.isDeleted) + #expect(!form.isEditable) + #expect(form.fields.allSatisfy { !$0.isEditable }) + } + + @Test("A save holding the edits makes the form read-only until it lets go") + func heldSaveIsReadOnly() throws { + let session = makeSession() + let manager = session.changeManager + var email = try #require(manager.workingColumns.first { $0.name == "email" }) + email.comment = "Contact" + manager.updateColumn(id: email.id, with: email) + + let snapshot = try #require(manager.holdForSave()) + #expect(try !emailForm(session).isEditable) + + manager.releaseHold(snapshot, written: false) + #expect(try emailForm(session).isEditable) + } + + @Test("Safe Mode makes the form read-only") + func safeModeIsReadOnly() throws { + let session = makeSession() + session.editor.isReadOnly = true + + let form = try emailForm(session) + #expect(!form.isEditable) + #expect(form.fields.allSatisfy { !$0.isEditable }) + } + + @Test("A materialized view locks the fields its grid locks") + func materializedViewLocksFields() throws { + let form = try emailForm(makeSession(type: .postgresql, objectKind: .materializedView)) + + #expect(form.isEditable) + #expect(try field(form, .name).isEditable) + #expect(try field(form, .comment).isEditable) + #expect(try !field(form, .type).isEditable) + #expect(try !field(form, .nullable).isEditable) + #expect(try !field(form, .defaultValue).isEditable) + } + + // MARK: - Commits + + @Test("A commit stages one schema change on the definition") + func commitStagesAChange() throws { + let session = makeSession() + try commit(session, .type, "TEXT") + + #expect(session.changeManager.hasChanges) + #expect(session.changeManager.canUndo) + #expect(session.changeManager.workingColumns[1].dataType == "TEXT") + } + + @Test("A commit lands on the definition it names, whatever the filter shows") + func commitFollowsIdentityUnderAFilter() throws { + let session = makeSession() + session.editor.searchText = "email" + try commit(session, .name, "user_email") + + #expect(session.changeManager.workingColumns[0].name == "id") + #expect(session.changeManager.workingColumns[1].name == "user_email") + } + + @Test("The checkbox writes the value the grid's menu offers") + func checkboxWritesTheGridValue() throws { + let session = makeSession() + try commit(session, .nullable, StructureRowProvider.booleanOptions[1]) + + #expect(session.changeManager.workingColumns[1].isNullable == false) + } + + @Test("A locked field refuses a commit from the inspector") + func lockedFieldRefusesTheCommit() throws { + let session = makeSession(type: .postgresql, objectKind: .materializedView) + try commit(session, .type, "TEXT") + try commit(session, .defaultValue, "'x'") + + #expect(!session.changeManager.hasChanges) + #expect(session.changeManager.workingColumns[1].dataType == "VARCHAR(255)") + #expect(session.changeManager.workingColumns[1].defaultValue == nil) + } + + @Test("A commit refreshes the editor, so the form is read again") + func commitBumpsTheRevision() throws { + let session = makeSession() + let revision = session.editor.revision + try commit(session, .comment, "Contact") + + #expect(session.editor.revision != revision) + #expect(try field(emailForm(session), .comment).value == "Contact") + } + + // MARK: - Create Table + + @Test("A drafted table's new column is editable and takes a name") + func createTableFormTakesEdits() throws { + let manager = StructureChangeManager() + manager.addNewColumn() + let editor = StructureDefinitionEditor( + manager: manager, + context: .newTable( + connection: TestFixtures.makeConnection(database: "test"), + databaseName: "test", + schemaName: nil + ) + ) + let id = try #require(manager.workingColumns.first).id + let form = try #require(editor.inspectorForm(for: .columns, id: id)) + #expect(form.isEditable) + + let name = try #require(form.fields.first { $0.label == StructureColumnField.name.displayName }) + editor.commitInspectorField(section: .columns, id: id, field: name.id, value: "sku") + #expect(manager.workingColumns[0].name == "sku") + } +} diff --git a/apple/TableProTests/Views/Structure/StructureInspectorPresentationTests.swift b/apple/TableProTests/Views/Structure/StructureInspectorPresentationTests.swift new file mode 100644 index 0000000000..baadf47579 --- /dev/null +++ b/apple/TableProTests/Views/Structure/StructureInspectorPresentationTests.swift @@ -0,0 +1,173 @@ +// +// StructureInspectorPresentationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct StructureInspectorPresentationTests { + private func field( + _ kind: StructureInspectorForm.Field.Kind, + value: String? = "x", + isEditable: Bool = true + ) -> StructureInspectorForm.Field { + StructureInspectorForm.Field(id: 0, label: "Field", value: value, kind: kind, isModified: false, isEditable: isEditable) + } + + // MARK: - Subject + + @Test("A selected index is named as an index of its table") + func entitySubjectNamesItsKind() { + let subject = StructureInspectorPresentation.subject( + for: .entity(.indexes, UUID()), + definitionName: "idx_email", + tableName: "users" + ) + #expect(subject == .definition(name: "idx_email", section: .indexes, table: "users")) + #expect(subject.subtitle == String(format: String(localized: "Index of %@"), "users")) + } + + @Test("An unnamed definition in a table not yet named reads as new, with no table") + func newDefinitionInNewTable() { + let subject = StructureInspectorPresentation.subject( + for: .entity(.columns, UUID()), + definitionName: "", + tableName: "" + ) + #expect(subject.title == String(localized: "New Column")) + #expect(subject.subtitle == String(localized: "Column")) + } + + @Test("Nothing selected, or several, names the table") + func tableSubject() { + #expect(StructureInspectorPresentation.subject(for: .table, definitionName: nil, tableName: "users") + == .tableOnly(table: "users")) + #expect(StructureInspectorPresentation.subject(for: .several(.columns, 3), definitionName: nil, tableName: "users") + == .tableOnly(table: "users")) + } + + @Test("A table Create Table has not named yet reads as a new table") + func unnamedTable() { + let subject = StructureInspectorPresentation.subject(for: .table, definitionName: nil, tableName: "") + #expect(subject.title == String(localized: "New Table")) + } + + @Test("Several selected say how many of which kind") + func severalTitle() { + #expect(StructureInspectorPresentation.severalTitle(.columns, count: 3) + == String(format: String(localized: "%d Columns Selected"), 3)) + #expect(StructureInspectorPresentation.severalTitle(.foreignKeys, count: 2) + == String(format: String(localized: "%d Foreign Keys Selected"), 2)) + #expect(StructureInspectorPresentation.severalTitle(.checkConstraints, count: 4).contains("4")) + } + + @Test("Each control is identified by its section and field index") + func controlIdentifier() { + #expect(StructureInspectorPresentation.controlIdentifier(section: .foreignKeys, field: 3) + == "structure-inspector-foreignKeys-3") + } + + // MARK: - Controls + + @Test("Each editable field kind gets its control") + func editableControls() { + #expect(StructureInspectorControl.resolve(field(.text)) == .text) + #expect(StructureInspectorControl.resolve(field(.multiline)) == .multiline) + #expect(StructureInspectorControl.resolve(field(.toggle, value: "YES")) == .checkbox) + #expect(StructureInspectorControl.resolve(field(.typePicker)) == .typePicker) + + let menu = [GridMenuOption.value(title: "BTREE", sql: "BTREE"), .value(title: "HASH", sql: "HASH")] + guard case .popUp(let choices) = StructureInspectorControl.resolve(field(.menu(menu), value: "HASH")) else { + Issue.record("A menu field is a pop-up button") + return + } + #expect(choices.selectedTitle == "HASH") + + let combo = [GridMenuOption.value(title: "NULL", sql: "NULL"), .custom(title: "Custom…")] + guard case .comboBox = StructureInspectorControl.resolve(field(.combo(combo), value: "0")) else { + Issue.record("A combo field is a combo box") + return + } + } + + @Test("A field the form cannot edit is plain text, whatever its kind") + func readOnlyControls() { + #expect(StructureInspectorControl.resolve(field(.text, value: "email", isEditable: false)) == .readOnly("email")) + #expect(StructureInspectorControl.resolve(field(.toggle, value: "NO", isEditable: false)) == .readOnly("NO")) + #expect(StructureInspectorControl.resolve(field(.typePicker, value: nil, isEditable: false)) == .readOnly("")) + #expect(StructureInspectorControl.resolve(field(.combo([]), value: "''", isEditable: false)) == .readOnly("''")) + } + + @Test("A read-only menu field shows the title the menu gives its value") + func readOnlyMenuShowsTitle() { + let options = [GridMenuOption.value(title: "Empty string", sql: "''")] + #expect(StructureInspectorControl.resolve(field(.menu(options), value: "''", isEditable: false)) + == .readOnly("Empty string")) + } + + // MARK: - Choices + + @Test("The item whose SQL the field holds is selected, and picking an item writes its SQL") + func selectsBySQL() throws { + let choices = StructureInspectorChoices( + options: [.value(title: "Empty string", sql: "''"), .value(title: "NULL", sql: "NULL")], + current: "''" + ) + #expect(choices.selectedTitle == "Empty string") + let null = try #require(choices.items.first { $0.title == "NULL" }) + #expect(choices.value(of: null.id) == "NULL") + } + + @Test("A value the menu does not list is kept as the first item and selected") + func keepsUnlistedValue() { + let choices = StructureInspectorChoices(options: GridMenuOption.values(["utf8mb4", "latin1"]), current: "utf8") + #expect(choices.items.first?.title == "utf8") + #expect(choices.selectedTitle == "utf8") + #expect(choices.items.count == 3) + } + + @Test("A value that differs only in case selects the listed item rather than adding one") + func matchesCaseInsensitively() { + let choices = StructureInspectorChoices(options: GridMenuOption.values(["utf8mb4"]), current: "UTF8MB4") + #expect(choices.items.count == 1) + #expect(choices.selectedTitle == "utf8mb4") + } + + @Test("An empty value selects the item that clears, and nothing when there is none") + func emptyValue() { + let clearing = StructureInspectorChoices( + options: [.clear(title: "No Default"), .value(title: "NULL", sql: "NULL")], + current: "" + ) + #expect(clearing.selectedTitle == "No Default") + + let plain = StructureInspectorChoices(options: GridMenuOption.values(["CASCADE", "RESTRICT"]), current: "") + #expect(plain.selectedID == nil) + #expect(plain.items.count == 2) + } + + @Test("A section header starts a group and the custom entry is left to free text") + func groupsAndCustom() { + let choices = StructureInspectorChoices( + options: [ + .value(title: "NULL", sql: "NULL"), + .sectionHeader("Functions"), + .value(title: "CURRENT_TIMESTAMP", sql: "CURRENT_TIMESTAMP"), + .custom(title: "Custom…"), + ], + current: "NULL" + ) + #expect(choices.groups.map { $0.header } == [nil, "Functions"]) + #expect(choices.groups.map { group in group.items.map { $0.title } } == [["NULL"], ["CURRENT_TIMESTAMP"]]) + #expect(!choices.items.contains { $0.title == "Custom…" }) + } + + @Test("A header with nothing under it still shows, since it reports the menu's state") + func lonelyHeaderStays() { + let choices = StructureInspectorChoices(options: [.sectionHeader("Loading…")], current: "") + #expect(choices.groups.map { $0.header } == ["Loading…"]) + } +} diff --git a/apple/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift b/apple/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift deleted file mode 100644 index f9b86cebfb..0000000000 --- a/apple/TableProTests/Views/Structure/StructureInspectorRowBuilderTests.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// StructureInspectorRowBuilderTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@MainActor -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], - serverSupport: .unrestricted, - 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.options, .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/apple/TableProTests/Views/Structure/StructureListGridAccessibilityTests.swift b/apple/TableProTests/Views/Structure/StructureListGridAccessibilityTests.swift index 6a51846885..b4e8440113 100644 --- a/apple/TableProTests/Views/Structure/StructureListGridAccessibilityTests.swift +++ b/apple/TableProTests/Views/Structure/StructureListGridAccessibilityTests.swift @@ -162,7 +162,7 @@ struct StructureListGridAccessibilityTests { } let table = try #require(Self.mainTable(in: window.contentView)) - #expect(table.accessibilityLabel() == StructureTab.triggers.displayName) + #expect(table.accessibilityLabel() == StructurePane.triggers.displayName) } private static func mainTable(in view: NSView?) -> DataGridTableView? { diff --git a/apple/TableProTests/Views/Structure/StructureLoadStateTests.swift b/apple/TableProTests/Views/Structure/StructureLoadStateTests.swift new file mode 100644 index 0000000000..a8377c2eaa --- /dev/null +++ b/apple/TableProTests/Views/Structure/StructureLoadStateTests.swift @@ -0,0 +1,148 @@ +// +// StructureLoadStateTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +struct StructureLoadStateTests { + @Test("A fresh state has no data and needs every part fetched") + func freshStateNeedsFetch() { + let state = StructureLoadState() + + for item in StructureLoadState.Item.allCases { + #expect(!state.hasData(item)) + #expect(state.needsFetch(item)) + #expect(state.failure(item) == nil) + } + } + + @Test("markFetched records data and clears the fetch requirement") + func markFetchedRecordsData() { + var state = StructureLoadState() + state.markFetched(.indexes) + + #expect(state.hasData(.indexes)) + #expect(!state.needsFetch(.indexes)) + #expect(!state.hasData(.columns)) + } + + @Test("markAllStale keeps counts on screen while requiring a refetch") + func markAllStaleKeepsData() { + var state = StructureLoadState() + state.markFetched(.columns) + state.markFetched(.indexes) + state.markFetched(.foreignKeys) + + state.markAllStale() + + for item in [StructureLoadState.Item.columns, .indexes, .foreignKeys] { + #expect(state.hasData(item)) + #expect(state.needsFetch(item)) + } + } + + @Test("Refetching a stale part clears only that part's staleness") + func refetchClearsSinglePart() { + var state = StructureLoadState() + state.markFetched(.columns) + state.markFetched(.ddl) + state.markAllStale() + + state.markFetched(.columns) + + #expect(!state.needsFetch(.columns)) + #expect(state.needsFetch(.ddl)) + #expect(state.hasData(.ddl)) + } + + @Test("reset drops the data and the failures so a different table starts cold") + func resetDropsData() { + var state = StructureLoadState() + state.markFetched(.columns) + state.markFailed(.triggers, message: "permission denied") + state.markAllStale() + + state.reset() + + #expect(!state.hasData(.columns)) + #expect(state.needsFetch(.columns)) + #expect(state.failures.isEmpty) + } + + // One failed read used to blank every sub-tab, and a failed triggers read showed as "No Triggers". + @Test("A failed read is reported for its own part only, and keeps what that part had loaded") + func failureIsPerItem() { + var state = StructureLoadState() + state.markFetched(.columns) + state.markFetched(.triggers) + + state.markFailed(.triggers, message: "permission denied") + + #expect(state.failure(.triggers) == "permission denied") + #expect(state.hasData(.triggers)) + #expect(state.needsFetch(.triggers)) + #expect(state.failure(.columns) == nil) + #expect(!state.needsFetch(.columns)) + #expect(state.failures == [.triggers: "permission denied"]) + } + + @Test("A first read that fails has no data, says why, and is fetched again") + func firstReadFailure() { + var state = StructureLoadState() + + state.markFailed(.ddl, message: "timed out") + + #expect(!state.hasData(.ddl)) + #expect(state.failure(.ddl) == "timed out") + #expect(state.needsFetch(.ddl)) + } + + @Test("Several parts can fail at once, each with its own message") + func failuresAreIndependent() { + var state = StructureLoadState() + + state.markFailed(.indexes, message: "no index access") + state.markFailed(.checkConstraints, message: "no catalog access") + + #expect(state.failure(.indexes) == "no index access") + #expect(state.failure(.checkConstraints) == "no catalog access") + #expect(state.failure(.foreignKeys) == nil) + } + + @Test("A read that lands clears that part's failure") + func fetchClearsFailure() { + var state = StructureLoadState() + state.markFailed(.foreignKeys, message: "timed out") + state.markFailed(.indexes, message: "timed out") + + state.markFetched(.foreignKeys) + + #expect(state.failure(.foreignKeys) == nil) + #expect(!state.needsFetch(.foreignKeys)) + #expect(state.failure(.indexes) == "timed out") + } + + @Test("Marking everything stale keeps the failures on screen") + func staleKeepsFailures() { + var state = StructureLoadState() + state.markFetched(.columns) + state.markFailed(.columns, message: "timed out") + + state.markAllStale() + + #expect(state.failure(.columns) == "timed out") + #expect(state.hasData(.columns)) + } + + @Test("Each section reads into its own part") + func sectionMapsToItsItem() { + #expect(StructureLoadState.Item(StructureSection.columns) == .columns) + #expect(StructureLoadState.Item(StructureSection.indexes) == .indexes) + #expect(StructureLoadState.Item(StructureSection.foreignKeys) == .foreignKeys) + #expect(StructureLoadState.Item(StructureSection.checkConstraints) == .checkConstraints) + } +} diff --git a/apple/TableProTests/Views/Structure/StructureRowMenuRouteTests.swift b/apple/TableProTests/Views/Structure/StructureRowMenuRouteTests.swift index ca55e1a0a9..c3312a8a20 100644 --- a/apple/TableProTests/Views/Structure/StructureRowMenuRouteTests.swift +++ b/apple/TableProTests/Views/Structure/StructureRowMenuRouteTests.swift @@ -12,31 +12,38 @@ import Testing // Every row menu, from any click and either pane, is the grid's commands plus the owner's `gridRowMenuItems`. @MainActor struct StructureRowMenuRouteTests { - private func makeAdapter( - tab: StructureTab = .columns, + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeEditor( type: DatabaseType = .mysql, objectKind: TableInfo.TableType = .table, manager: StructureChangeManager = StructureChangeManager() - ) -> StructureGridAdapter { + ) -> StructureDefinitionEditor { if manager.workingColumns.isEmpty { manager.addColumn(.placeholder()) } - let adapter = StructureGridAdapter( - structureChangeManager: manager, - connection: TestFixtures.makeConnection(type: type), - tableName: "orders", - databaseName: "shop", - schemaName: nil, - objectKind: objectKind, - isSessionBound: false + let editor = StructureDefinitionEditor( + manager: manager, + context: .existingTable( + connection: TestFixtures.makeConnection(type: type), + databaseName: "shop", + schemaName: nil, + tableName: "orders", + objectKind: objectKind, + isSessionBound: false + ), + defaults: suite.defaults ) - adapter.store.switchSubTab(to: tab) - adapter.refresh() - return adapter + editor.refresh() + return editor } - private func row(_ sourceIndex: Int) -> RowID { - StructureRowProvider.rowID(forSourceIndex: sourceIndex) + private func row(_ sourceIndex: Int, in adapter: StructureSectionAdapter) -> RowID { + adapter.rowID(forEntity: StructureGridEdits.entityIDs(in: adapter.section, manager: adapter.manager)[sourceIndex]) } private func titles(_ items: [NSMenuItem]) -> [String] { @@ -45,8 +52,9 @@ struct StructureRowMenuRouteTests { @Test("A column row's menu is the schema's own items, with no result commands") func columnRowsGetTheStructureMenu() { - let adapter = makeAdapter() - let items = titles(adapter.gridRowMenuItems(for: [row(0)], clickedRow: row(0))) + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + let items = titles(adapter.gridRowMenuItems(for: [row(0, in: adapter)], clickedRow: row(0, in: adapter))) #expect(items.contains(String(localized: "Copy Name"))) #expect(items.contains(String(localized: "Copy Definition"))) @@ -54,15 +62,6 @@ struct StructureRowMenuRouteTests { #expect(commands.isDisjoint(with: [.exportResults, .showRowsAsJSON, .clearResults, .copyAsSQL, .filterWithColumn])) } - @Test("A DDL tab row offers no row items and no row commands") - func theDdlTabRaisesNoMenu() { - let adapter = makeAdapter(tab: .ddl) - - #expect(adapter.gridRowMenuItems(for: [row(0)], clickedRow: row(0)).isEmpty) - #expect(adapter.gridEmptySpaceMenu() == nil) - #expect(adapter.model.configuration.commands.isDisjoint(with: [.deleteRows, .duplicateRows, .addRow, .undoDelete])) - } - @Test("A row pending deletion offers none of the schema's items") func deletedRowOffersNothing() throws { let manager = StructureChangeManager() @@ -73,21 +72,25 @@ struct StructureRowMenuRouteTests { foreignKeys: [], primaryKey: ["id"] ) - let adapter = makeAdapter(manager: manager) - adapter.gridDeleteRows([row(0)]) + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .columns) + let id = row(0, in: adapter) + adapter.gridDeleteRows([id]) try #require(manager.deleteInsertState(for: 0, tab: .columns).isDeleted) - #expect(adapter.gridRowMenuItems(for: [row(0)], clickedRow: row(0)).isEmpty) + #expect(adapter.gridRowMenuItems(for: [id], clickedRow: id).isEmpty) } // A materialized view refuses DROP COLUMN and ADD COLUMN; the menu used to offer both and do nothing. @Test("Delete and Duplicate are offered only where the object accepts them") func rowEditsFollowTheGate() { - let refused = makeAdapter(type: .postgresql, objectKind: .materializedView) + let refusedEditor = makeEditor(type: .postgresql, objectKind: .materializedView) + let refused = refusedEditor.adapter(for: .columns) #expect(refused.model.configuration.commands.isDisjoint(with: [.deleteRows, .duplicateRows])) - #expect(refused.gridRowMenuItems(for: [row(0)], clickedRow: row(0)).last?.isSeparatorItem == false) + #expect(refused.gridRowMenuItems(for: [row(0, in: refused)], clickedRow: row(0, in: refused)).last?.isSeparatorItem == false) - let accepted = makeAdapter() + let acceptedEditor = makeEditor() + let accepted = acceptedEditor.adapter(for: .columns) #expect(accepted.model.configuration.commands.isSuperset(of: [.deleteRows, .duplicateRows, .undoDelete])) } @@ -100,13 +103,14 @@ struct StructureRowMenuRouteTests { second.referencedTable = "products" manager.addForeignKey(first) manager.addForeignKey(second) - let adapter = makeAdapter(tab: .foreignKeys, manager: manager) + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .foreignKeys) let openCustomers = String(format: String(localized: "Open %@"), "customers") let openProducts = String(format: String(localized: "Open %@"), "products") - #expect(titles(adapter.gridRowMenuItems(for: [row(0)], clickedRow: row(0))).contains(openCustomers)) + #expect(titles(adapter.gridRowMenuItems(for: [row(0, in: adapter)], clickedRow: row(0, in: adapter))).contains(openCustomers)) - let selection = titles(adapter.structureRowMenuItems(for: [row(0), row(1)], clickedRow: row(1))) + let selection = titles(adapter.rowMenuItems(for: [row(0, in: adapter), row(1, in: adapter)], clickedRow: row(1, in: adapter))) #expect(selection.contains(openProducts)) #expect(!selection.contains(openCustomers)) } @@ -125,12 +129,16 @@ struct StructureRowMenuRouteTests { foreignKeys: [], primaryKey: ["id"] ) - let adapter = makeAdapter(manager: manager) + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .columns) try #require(adapter.model.configuration.rowReorder.isEnabled) var moved: (from: Int, to: Int)? - adapter.moveRowHandler = { moved = ($0, $1) } + editor.moveColumnHandler = { moved = ($0, $1) } - let items = adapter.structureRowMenuItems(for: [row(0), row(1), row(2)], clickedRow: row(2)) + let items = adapter.rowMenuItems( + for: [row(0, in: adapter), row(1, in: adapter), row(2, in: adapter)], + clickedRow: row(2, in: adapter) + ) let up = try #require(items.first { $0.title == String(localized: "Move Column Up") }) let down = try #require(items.first { $0.title == String(localized: "Move Column Down") }) #expect(up.action != nil) @@ -156,28 +164,17 @@ struct StructureRowMenuRouteTests { foreignKeys: [], primaryKey: ["id"] ) - let adapter = makeAdapter(manager: manager) - adapter.gridDeleteRows([row(1)]) + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .columns) + let rows = [row(0, in: adapter), row(1, in: adapter), row(2, in: adapter)] + adapter.gridDeleteRows([rows[1]]) try #require(manager.deleteInsertState(for: 1, tab: .columns).isDeleted) - let selection = [row(0), row(1), row(2)] - let items = titles(adapter.gridRowMenuItems(for: selection, clickedRow: row(0))) + let items = titles(adapter.gridRowMenuItems(for: rows, clickedRow: rows[0])) #expect(items.contains(String(localized: "Copy Name"))) #expect(items.contains(String(localized: "Copy Definition"))) #expect(items.contains(String(localized: "Copy As"))) - #expect(titles(adapter.structureRowMenuItems(for: selection, clickedRow: row(2))).contains(String(localized: "Copy Name"))) - #expect(adapter.structureRowMenuItems(for: selection, clickedRow: row(1)).isEmpty) - } - - @Test("The empty space below the rows offers the sub-tab's own Add") - func emptySpaceOffersAdd() { - let columns = makeAdapter(tab: .columns) - #expect(titles(columns.gridEmptySpaceMenu()?.items ?? []) == [String(localized: "Add Column")]) - - let indexes = makeAdapter(tab: .indexes) - #expect(titles(indexes.gridEmptySpaceMenu()?.items ?? []) == [String(localized: "Add Index")]) - - let view = makeAdapter(type: .postgresql, objectKind: .view) - #expect(view.gridEmptySpaceMenu() == nil) + #expect(titles(adapter.rowMenuItems(for: rows, clickedRow: rows[2])).contains(String(localized: "Copy Name"))) + #expect(adapter.rowMenuItems(for: rows, clickedRow: rows[1]).isEmpty) } } diff --git a/apple/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift b/apple/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift index 6d909ff121..6e3278759f 100644 --- a/apple/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift +++ b/apple/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift @@ -36,7 +36,7 @@ struct StructureRowProviderBooleanOptionsTests { return manager } - private func makeProvider(tab: StructureTab = .columns) -> StructureRowProvider { + private func makeProvider(tab: StructureSection = .columns) -> StructureRowProvider { StructureRowProvider( changeManager: makeManager(), tab: tab, @@ -62,9 +62,7 @@ struct StructureRowProviderBooleanOptionsTests { } } - /// A closed vocabulary has to contain whatever the grid drew, or the menu cannot show the - /// current value as the selected one. The Default column is deliberately not closed: it holds - /// an open-ended SQL expression, which is what its `Custom…` entry marks it as. + // A menu can show the current value as selected only if it holds what the grid drew. Default is open: `Custom…`. @Test("A closed column's options are exactly what the grid draws") func optionsMatchRenderedValues() { let provider = makeProvider() @@ -109,7 +107,7 @@ struct StructureRowProviderBooleanOptionsTests { @Test("No structure column reports itself as nullable, so Set NULL is never offered") func structureColumnsAreNeverNullable() { let provider = makeProvider() - let rows = provider.asTableRows() + let rows = provider.asTableRows { RowID.existing($0) } #expect(!rows.columnNullable.isEmpty) for column in provider.columns { #expect(rows.columnNullable[column] == false) diff --git a/apple/TableProTests/Views/Structure/StructureRowProviderTests.swift b/apple/TableProTests/Views/Structure/StructureRowProviderTests.swift index e899cdce4a..647cd754ff 100644 --- a/apple/TableProTests/Views/Structure/StructureRowProviderTests.swift +++ b/apple/TableProTests/Views/Structure/StructureRowProviderTests.swift @@ -141,7 +141,7 @@ struct StructureRowProviderChangeStateTests { private func provider( _ manager: StructureChangeManager, - tab: StructureTab = .columns, + tab: StructureSection = .columns, filterText: String? = nil ) -> StructureRowProvider { StructureRowProvider( diff --git a/apple/TableProTests/Views/Structure/StructureSectionAdapterTests.swift b/apple/TableProTests/Views/Structure/StructureSectionAdapterTests.swift new file mode 100644 index 0000000000..52de660f26 --- /dev/null +++ b/apple/TableProTests/Views/Structure/StructureSectionAdapterTests.swift @@ -0,0 +1,285 @@ +// +// StructureSectionAdapterTests.swift +// TableProTests +// +// Add and Delete per section, the contract the +/- beneath each table, Cmd+Shift+N and the row menus share. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct StructureSectionAdapterTests { + private let suite: EphemeralDefaults + + init() throws { + suite = try EphemeralDefaults() + } + + private func makeEditor( + type: DatabaseType = .mysql, + objectKind: TableInfo.TableType = .table, + manager: StructureChangeManager = StructureChangeManager() + ) -> StructureDefinitionEditor { + let editor = StructureDefinitionEditor( + manager: manager, + context: .existingTable( + connection: TestFixtures.makeConnection(type: type), + databaseName: "db", + schemaName: nil, + tableName: "t", + objectKind: objectKind, + isSessionBound: false + ), + defaults: suite.defaults + ) + editor.refresh() + return editor + } + + private func row(_ sourceIndex: Int, in adapter: StructureSectionAdapter) -> RowID { + adapter.rowID(forEntity: StructureGridEdits.entityIDs(in: adapter.section, manager: adapter.manager)[sourceIndex]) + } + + private func columns(named names: [String]) -> StructureChangeManager { + let manager = StructureChangeManager() + for name in names { + var column = EditableColumnDefinition.placeholder() + column.name = name + column.dataType = "TEXT" + manager.addColumn(column) + } + return manager + } + + @Test("Columns: Add Row appends a placeholder column") + func columnsAddsColumn() { + let editor = makeEditor() + let before = editor.manager.workingColumns.count + editor.adapter(for: .columns).gridAddRow() + #expect(editor.manager.workingColumns.count == before + 1) + } + + @Test("Indexes: Add Row appends a placeholder index") + func indexesAddsIndex() { + let editor = makeEditor() + let before = editor.manager.workingIndexes.count + editor.adapter(for: .indexes).gridAddRow() + #expect(editor.manager.workingIndexes.count == before + 1) + } + + @Test("Foreign keys: Add Row appends a placeholder foreign key") + func foreignKeysAddsForeignKey() { + let editor = makeEditor() + let before = editor.manager.workingForeignKeys.count + editor.adapter(for: .foreignKeys).gridAddRow() + #expect(editor.manager.workingForeignKeys.count == before + 1) + } + + @Test("Each section adds only its own kind of definition", arguments: StructureSection.allCases) + func addStaysInItsSection(section: StructureSection) { + let editor = makeEditor(type: .postgresql) + editor.adapter(for: section).gridAddRow() + + for other in StructureSection.allCases { + let count = StructureGridEdits.rowCount(on: other, manager: editor.manager) + #expect(count == (other == section ? 1 : 0), "\(other.rawValue)") + } + } + + // SQLite's curated snapshot overrides neither supportsAddIndex nor supportsDropIndex, so both default to true. + @Test("Indexes on SQLite: Add Row appends an index") + func sqliteIndexesAddsAnIndex() { + let editor = makeEditor(type: .sqlite) + let before = editor.manager.workingIndexes.count + editor.adapter(for: .indexes).gridAddRow() + #expect(editor.manager.workingIndexes.count == before + 1) + } + + @Test("Columns: Delete removes the column the row names") + func columnsDeleteRemovesColumn() { + let editor = makeEditor() + let adapter = editor.adapter(for: .columns) + adapter.gridAddRow() + let after = editor.manager.workingColumns.count + #expect(after > 0) + adapter.gridDeleteRows([row(after - 1, in: adapter)]) + #expect(editor.manager.workingColumns.count == after - 1) + } + + @Test("Indexes: Delete removes the index the row names") + func indexesDeleteRemovesIndex() { + let editor = makeEditor() + let adapter = editor.adapter(for: .indexes) + adapter.gridAddRow() + let after = editor.manager.workingIndexes.count + #expect(after > 0) + adapter.gridDeleteRows([row(after - 1, in: adapter)]) + #expect(editor.manager.workingIndexes.count == after - 1) + } + + @Test("Foreign keys: Delete removes the foreign key the row names") + func foreignKeysDeleteRemovesForeignKey() { + let editor = makeEditor() + let adapter = editor.adapter(for: .foreignKeys) + adapter.gridAddRow() + let after = editor.manager.workingForeignKeys.count + #expect(after > 0) + adapter.gridDeleteRows([row(after - 1, in: adapter)]) + #expect(editor.manager.workingForeignKeys.count == after - 1) + } + + @Test("Indexes on SQLite: Delete removes an index") + func sqliteIndexesDeletesAnIndex() { + let manager = StructureChangeManager() + manager.addIndex(.placeholder()) + let editor = makeEditor(type: .sqlite, manager: manager) + let adapter = editor.adapter(for: .indexes) + let before = manager.workingIndexes.count + + adapter.gridDeleteRows([row(before - 1, in: adapter)]) + + #expect(manager.workingIndexes.count == before - 1) + } + + @Test("Deleting the last row selects the row above it") + func deletingTheLastRowSelectsTheOneAbove() { + let manager = StructureChangeManager() + for _ in 0..<3 { + manager.addColumn(.placeholder()) + } + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .columns) + let above = row(1, in: adapter) + adapter.model.selectRows([row(2, in: adapter)]) + + adapter.gridDeleteRows([row(2, in: adapter)]) + + #expect(manager.workingColumns.count == 2) + #expect(adapter.model.selectedRowIDs == [above]) + } + + // MARK: - Row identity + + // A RowID was the definition's index, so removing one above moved the selection onto its neighbour. + @Test("The selection stays on its definition across a refresh after a definition above it is removed") + func selectionSurvivesRemovalAbove() { + let manager = columns(named: ["a", "b", "c"]) + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .columns) + let c = manager.workingColumns[2].id + let rowID = adapter.rowID(forEntity: c) + adapter.select(entity: c) + #expect(adapter.sourceIndex(of: rowID) == 2) + + manager.deleteColumn(id: manager.workingColumns[0].id) + editor.refresh() + + #expect(manager.workingColumns.map(\.name) == ["b", "c"]) + #expect(adapter.rowID(forEntity: c) == rowID) + #expect(adapter.entityID(of: rowID) == c) + #expect(adapter.sourceIndex(of: rowID) == 1) + #expect(adapter.model.selectedRowIDs == [rowID]) + #expect(adapter.selectedEntityIDs == [c]) + #expect(editor.inspectorTarget == .entity(.columns, c)) + } + + @Test("A sort reorders the rows without renaming them") + func rowIDSurvivesSort() { + let manager = columns(named: ["a", "b", "c"]) + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .columns) + let a = manager.workingColumns[0].id + + adapter.sortDidChange(SortState(columns: [SortColumn(columnIndex: 0, direction: .descending)], source: .user)) + + #expect(adapter.model.rowID(at: 2) == adapter.rowID(forEntity: a)) + #expect(adapter.sourceIndex(of: adapter.rowID(forEntity: a)) == 0) + } + + // MARK: - Several rows + + @Test("Duplicating several rows is one undo step") + func duplicateIsOneUndoStep() { + let manager = columns(named: ["a", "b", "c"]) + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .columns) + + adapter.gridDuplicateRows([row(0, in: adapter), row(2, in: adapter)]) + #expect(manager.workingColumns.map(\.name) == ["a", "b", "c", "a", "c"]) + + manager.undo() + + #expect(manager.workingColumns.map(\.name) == ["a", "b", "c"]) + } + + @Test("Deleting several rows is one undo step") + func deleteIsOneUndoStep() { + let manager = columns(named: ["a", "b", "c"]) + let editor = makeEditor(manager: manager) + let adapter = editor.adapter(for: .columns) + + adapter.gridDeleteRows([row(0, in: adapter), row(2, in: adapter)]) + #expect(manager.workingColumns.map(\.name) == ["b"]) + + manager.undo() + + #expect(manager.workingColumns.map(\.name) == ["a", "b", "c"]) + } + + // MARK: - Per object kind + + // Cmd+Shift+N, the row menus and the +/- all reach the adapter, so it refuses for itself (#2726). + @Test("A PostgreSQL view refuses an added column, whatever reaches the adapter") + func viewRefusesAnAddedColumn() { + let editor = makeEditor(type: .postgresql, objectKind: .view) + let adapter = editor.adapter(for: .columns) + adapter.gridAddRow() + #expect(editor.manager.workingColumns.isEmpty) + #expect(!adapter.model.configuration.commands.contains(.addRow)) + } + + @Test("A PostgreSQL view refuses an added index and a materialized view accepts one") + func indexesFollowTheObjectKind() { + let view = makeEditor(type: .postgresql, objectKind: .view) + view.adapter(for: .indexes).gridAddRow() + #expect(view.manager.workingIndexes.isEmpty) + + let matview = makeEditor(type: .postgresql, objectKind: .materializedView) + matview.adapter(for: .indexes).gridAddRow() + #expect(matview.manager.workingIndexes.count == 1) + } + + @Test("A PostgreSQL materialized view refuses a foreign key") + func materializedViewRefusesAForeignKey() { + let editor = makeEditor(type: .postgresql, objectKind: .materializedView) + editor.adapter(for: .foreignKeys).gridAddRow() + #expect(editor.manager.workingForeignKeys.isEmpty) + } + + @Test("A view refuses to drop a column its own grid is showing") + func viewRefusesADroppedColumn() { + let manager = StructureChangeManager() + manager.addColumn(.placeholder()) + #expect(manager.workingColumns.count == 1) + let editor = makeEditor(type: .postgresql, objectKind: .view, manager: manager) + let view = editor.adapter(for: .columns) + + view.gridDeleteRows([row(0, in: view)]) + + #expect(manager.workingColumns.count == 1) + #expect(!view.model.configuration.commands.contains(.deleteRows)) + } + + @Test("A system table refuses every add", arguments: StructureSection.allCases) + func systemTableRefusesEveryAdd(section: StructureSection) { + let editor = makeEditor(type: .postgresql, objectKind: .systemTable) + editor.adapter(for: section).gridAddRow() + #expect(editor.manager.workingColumns.isEmpty) + #expect(editor.manager.workingIndexes.isEmpty) + #expect(editor.manager.workingForeignKeys.isEmpty) + #expect(editor.manager.workingCheckConstraints.isEmpty) + } +} diff --git a/apple/TableProTests/Views/Structure/StructureServerSupportTests.swift b/apple/TableProTests/Views/Structure/StructureServerSupportTests.swift index ae7bc59834..915945b6c2 100644 --- a/apple/TableProTests/Views/Structure/StructureServerSupportTests.swift +++ b/apple/TableProTests/Views/Structure/StructureServerSupportTests.swift @@ -57,7 +57,7 @@ struct StructureServerSupportTests { return manager } - private func provider(tab: StructureTab, support: StructureServerSupport) -> StructureRowProvider { + private func provider(tab: StructureSection, support: StructureServerSupport) -> StructureRowProvider { StructureRowProvider( changeManager: makeManager(), tab: tab, @@ -154,7 +154,7 @@ struct StructureServerSupportTests { let support = StructureServerSupport(driver: adapter) #expect(support.checkConstraintRefusal == "Check constraints need MySQL 8.0.16 or later.") #expect(!support.offers(.checkConstraints)) - for tab in StructureTab.allCases where tab != .checkConstraints { + for tab in StructureSection.allCases where tab != .checkConstraints { #expect(support.offers(tab), "\(tab)") } #expect(StructureServerSupport.unrestricted.offers(.checkConstraints)) diff --git a/apple/TableProTests/Views/Structure/StructureTabAvailabilityTests.swift b/apple/TableProTests/Views/Structure/StructureTabAvailabilityTests.swift deleted file mode 100644 index 77308cd14b..0000000000 --- a/apple/TableProTests/Views/Structure/StructureTabAvailabilityTests.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// StructureTabAvailabilityTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct StructureTabAvailabilityTests { - private static let legacyMySQL = StructureServerSupport( - unsupportedColumnFields: [], - unsupportedIndexTypes: [], - checkConstraintRefusal: "Check constraints need MySQL 8.0.16 or later." - ) - - @Test("A MySQL server that discards a CHECK clause is not offered the tab") - func legacyServerHidesConstraints() { - let tabs = StructureTabAvailability.tabs(for: .mysql, serverSupport: Self.legacyMySQL) - #expect(!tabs.contains(.checkConstraints)) - #expect(tabs.contains(.columns)) - #expect(tabs.contains(.indexes)) - #expect(tabs.contains(.foreignKeys)) - #expect(tabs.contains(.ddl)) - } - - @Test("A server that keeps them is offered the tab, on every engine that has them") - func modernServerShowsConstraints() { - #expect(StructureTabAvailability.tabs(for: .mysql, serverSupport: .unrestricted) - .contains(.checkConstraints)) - #expect(StructureTabAvailability.tabs(for: .postgresql, serverSupport: .unrestricted) - .contains(.checkConstraints)) - } - - @Test("An engine with no check constraints at all stays hidden either way") - func engineWithoutChecksStaysHidden() { - for support in [StructureServerSupport.unrestricted, Self.legacyMySQL] { - let tabs = StructureTabAvailability.tabs(for: .redis, serverSupport: support) - #expect(!tabs.contains(.checkConstraints)) - } - } - - // The gate is the driver's statement probe for the object, so any engine whose driver answers gets the tab. - @Test("Options follows the driver's answer, not the engine type") - func optionsFollowsTheDriver() { - #expect(StructureTabAvailability.tabs(for: .postgresql, serverSupport: .unrestricted, offersTableOptions: true) - .contains(.options)) - #expect(!StructureTabAvailability.tabs(for: .mysql, serverSupport: .unrestricted, offersTableOptions: false) - .contains(.options)) - #expect(!StructureTabAvailability.tabs(for: .mysql, serverSupport: .unrestricted).contains(.options)) - } - - @Test("Options sits after the editable tabs and before DDL") - func optionsSitsBeforeDDL() throws { - let tabs = StructureTabAvailability.tabs(for: .mysql, serverSupport: .unrestricted, offersTableOptions: true) - let options = try #require(tabs.firstIndex(of: .options)) - let ddl = try #require(tabs.firstIndex(of: .ddl)) - let columns = try #require(tabs.firstIndex(of: .columns)) - #expect(columns < options) - #expect(options < ddl) - } - - @Test("Parts is ClickHouse only, and the server refusal does not reach it") - func partsIsClickHouseOnly() { - #expect(StructureTabAvailability.tabs(for: .clickhouse, serverSupport: .unrestricted).contains(.parts)) - #expect(!StructureTabAvailability.tabs(for: .mysql, serverSupport: .unrestricted).contains(.parts)) - #expect(StructureTabAvailability.tabs(for: .clickhouse, serverSupport: Self.legacyMySQL).contains(.parts)) - } -} diff --git a/apple/TableProTests/Views/Structure/StructureTabDataStateTests.swift b/apple/TableProTests/Views/Structure/StructureTabDataStateTests.swift deleted file mode 100644 index f24bf763b1..0000000000 --- a/apple/TableProTests/Views/Structure/StructureTabDataStateTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// StructureTabDataStateTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -struct StructureTabDataStateTests { - @Test("a fresh state has no data and needs every tab fetched") - func freshStateNeedsFetch() { - let state = StructureTabDataState() - - for tab in StructureTab.allCases { - #expect(!state.hasData(tab)) - #expect(state.needsFetch(tab)) - } - } - - @Test("markFetched records data and clears the fetch requirement") - func markFetchedRecordsData() { - var state = StructureTabDataState() - state.markFetched(.indexes) - - #expect(state.hasData(.indexes)) - #expect(!state.needsFetch(.indexes)) - #expect(!state.hasData(.columns)) - } - - @Test("markAllStale keeps counts on screen while requiring a refetch") - func markAllStaleKeepsData() { - var state = StructureTabDataState() - state.markFetched(.columns) - state.markFetched(.indexes) - state.markFetched(.foreignKeys) - - state.markAllStale() - - for tab in [StructureTab.columns, .indexes, .foreignKeys] { - #expect(state.hasData(tab)) - #expect(state.needsFetch(tab)) - } - } - - @Test("refetching a stale tab clears only that tab's staleness") - func refetchClearsSingleTab() { - var state = StructureTabDataState() - state.markFetched(.columns) - state.markFetched(.ddl) - state.markAllStale() - - state.markFetched(.columns) - - #expect(!state.needsFetch(.columns)) - #expect(state.needsFetch(.ddl)) - #expect(state.hasData(.ddl)) - } - - @Test("reset drops the data so a different table starts cold") - func resetDropsData() { - var state = StructureTabDataState() - state.markFetched(.columns) - state.markAllStale() - - state.reset() - - #expect(!state.hasData(.columns)) - #expect(state.needsFetch(.columns)) - } - - @Test("a tab with data renders its count, a tab without renders a bare name") - func labelIncludesCountOnlyWhenLoaded() { - let withCount = StructureTabDataState.label(for: .indexes, count: 4) - let withoutCount = StructureTabDataState.label(for: .indexes, count: nil) - - #expect(withCount == "\(StructureTab.indexes.displayName) (4)") - #expect(withoutCount == StructureTab.indexes.displayName) - #expect(withCount != withoutCount) - } - - @Test("a zero count still renders, so the label never collapses to a bare name") - func labelRendersZeroCount() { - #expect(StructureTabDataState.label(for: .foreignKeys, count: 0) - == "\(StructureTab.foreignKeys.displayName) (0)") - } -} diff --git a/apple/TableProUITests/SidebarAddMenuUITests.swift b/apple/TableProUITests/SidebarAddMenuUITests.swift index 51ff5a494c..d5314221a4 100644 --- a/apple/TableProUITests/SidebarAddMenuUITests.swift +++ b/apple/TableProUITests/SidebarAddMenuUITests.swift @@ -14,16 +14,11 @@ final class SidebarAddMenuUITests: UITestCase { XCTAssertTrue(newTable.waitToExist(timeout: 10), "The add button offers New Table…") newTable.click() - // By identifier: the editor tab named Create Table also matches a "Create Table" button query. - let commit = window.buttons["create-table-commit"].firstMatch - XCTAssertTrue(commit.waitToExist(timeout: 30), "New Table… from the add button opens the Create Table tab") - XCTAssertTrue( - window.staticTexts.matching(identifier: "create-table-validation").firstMatch.waitToExist(timeout: 10), - "A draft that cannot run yet must say what is missing" - ) - XCTAssertFalse(commit.isEnabled, "An empty draft has no name and no column, so Create Table stays dimmed") - let name = window.textFields["create-table-name"] + XCTAssertTrue(name.waitToExist(timeout: 30), "New Table… from the add button opens the Create Table tab") + let validation = window.descendants(matching: .any).matching(identifier: "create-table-validation").firstMatch + XCTAssertTrue(validation.waitToExist(timeout: 10), "A draft that cannot run yet must say what is missing") + XCTAssertTrue(waitUntilHittable(name, timeout: 10)) name.click() name.typeText("playlist") @@ -31,10 +26,11 @@ final class SidebarAddMenuUITests: UITestCase { fillFirstColumn(named: "title", type: "TEXT", in: window, app: app) XCTAssertTrue( - waitForPredicate(timeout: 10) { commit.isEnabled }, - "A named table with a named, typed column can be created" + waitForPredicate(timeout: 10) { !validation.exists }, + "A named table with a named, typed column has nothing left to fix" ) - commit.click() + // Create Table has no button of its own: the toolbar's commit item and Cmd+S run it. + app.typeShortcut(.save) XCTAssertTrue( objectBrowserRow("playlist", in: window).waitToExist(timeout: 30), @@ -50,7 +46,9 @@ final class SidebarAddMenuUITests: UITestCase { // Cells are drawn, not mounted, so the row is reached through a point and edited from the keyboard: // Left until the cursor stops on the first column, Return opens its editor, Tab commits and opens the next. private func fillFirstColumn(named column: String, type: String, in window: XCUIElement, app: XCUIApplication) { - let grid = dataGrid(in: window) + let columns = window.descendants(matching: .any).matching(identifier: "structure-section-columns").firstMatch + XCTAssertTrue(columns.waitToExist(timeout: 10), "The Definition page lists the draft's columns") + let grid = dataGrid(in: columns) XCTAssertTrue(waitForClickableRows(in: grid), "The draft starts with one empty column row") let firstRow = grid.tableRows.firstMatch gridPoint(in: grid, of: window, dy: firstRow.frame.midY - grid.frame.minY).click() diff --git a/apple/TableProUITests/StructureSaveConfirmationUITests.swift b/apple/TableProUITests/StructureSaveConfirmationUITests.swift index 17cab44c64..eb34d4e539 100644 --- a/apple/TableProUITests/StructureSaveConfirmationUITests.swift +++ b/apple/TableProUITests/StructureSaveConfirmationUITests.swift @@ -21,7 +21,7 @@ final class StructureSaveConfirmationUITests: UITestCase { waitForPredicate(timeout: 10) { grid.element.tableRows.element(boundBy: 1).isSelected }, "The click must select the tag row" ) - let remove = window.buttons["structure-footer-remove"].firstMatch + let remove = window.buttons["structure-columns-remove"].firstMatch XCTAssertTrue(remove.waitToExist(timeout: 20), "The Columns tab must offer a remove button") XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the selected column must be offered") // On the 1024pt runner the structure editor overflows its pane and this button ends past the window edge. diff --git a/apple/TableProUITests/TableChangeReloadUITests.swift b/apple/TableProUITests/TableChangeReloadUITests.swift index b0bcd2ae1b..a74ed34f06 100644 --- a/apple/TableProUITests/TableChangeReloadUITests.swift +++ b/apple/TableProUITests/TableChangeReloadUITests.swift @@ -62,7 +62,7 @@ final class TableChangeReloadUITests: UITestCase { XCTAssertTrue(grid.header("label").waitToExist(timeout: 30), "The Data view must show document's columns") showStructure(in: app, window: window) - let add = window.buttons["structure-footer-add"].firstMatch + let add = window.buttons["structure-columns-add"].firstMatch XCTAssertTrue(add.waitToExist(timeout: 20), "The Columns tab must offer an add button") XCTAssertTrue(waitForPredicate(timeout: 10) { add.isEnabled }, "SQLite adds a column with ALTER TABLE") add.click() From a2f03648c557551957928a244e2af3087f53ed0a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 16:07:36 +0700 Subject: [PATCH 2/9] docs(changelog): add the Structure tab redesign entries --- apple/changelog.d/3872.added.md | 1 + apple/changelog.d/3872.changed.md | 1 + apple/changelog.d/3872.fixed.1.md | 1 + apple/changelog.d/3872.fixed.2.md | 1 + apple/changelog.d/3872.fixed.3.md | 1 + apple/changelog.d/3872.fixed.4.md | 1 + apple/changelog.d/3872.fixed.5.md | 1 + apple/changelog.d/3872.fixed.6.md | 1 + apple/changelog.d/3872.fixed.7.md | 1 + apple/changelog.d/3872.fixed.8.md | 1 + apple/changelog.d/3872.fixed.md | 1 + 11 files changed, 11 insertions(+) create mode 100644 apple/changelog.d/3872.added.md create mode 100644 apple/changelog.d/3872.changed.md create mode 100644 apple/changelog.d/3872.fixed.1.md create mode 100644 apple/changelog.d/3872.fixed.2.md create mode 100644 apple/changelog.d/3872.fixed.3.md create mode 100644 apple/changelog.d/3872.fixed.4.md create mode 100644 apple/changelog.d/3872.fixed.5.md create mode 100644 apple/changelog.d/3872.fixed.6.md create mode 100644 apple/changelog.d/3872.fixed.7.md create mode 100644 apple/changelog.d/3872.fixed.8.md create mode 100644 apple/changelog.d/3872.fixed.md diff --git a/apple/changelog.d/3872.added.md b/apple/changelog.d/3872.added.md new file mode 100644 index 0000000000..32b25ffe58 --- /dev/null +++ b/apple/changelog.d/3872.added.md @@ -0,0 +1 @@ +File > Discard Changes… discards staged structure or data edits after asking. diff --git a/apple/changelog.d/3872.changed.md b/apple/changelog.d/3872.changed.md new file mode 100644 index 0000000000..736ff69800 --- /dev/null +++ b/apple/changelog.d/3872.changed.md @@ -0,0 +1 @@ +The Structure tab shows columns, indexes, foreign keys and checks on one page, with the inspector editing them. diff --git a/apple/changelog.d/3872.fixed.1.md b/apple/changelog.d/3872.fixed.1.md new file mode 100644 index 0000000000..d6c7ffe35b --- /dev/null +++ b/apple/changelog.d/3872.fixed.1.md @@ -0,0 +1 @@ +Undo Delete on an edited column in the Structure tab dropped the edit made before the delete. diff --git a/apple/changelog.d/3872.fixed.2.md b/apple/changelog.d/3872.fixed.2.md new file mode 100644 index 0000000000..cb1ae238d3 --- /dev/null +++ b/apple/changelog.d/3872.fixed.2.md @@ -0,0 +1 @@ +Refreshing the Structure tab, or saving in it, did not read check constraints again. diff --git a/apple/changelog.d/3872.fixed.3.md b/apple/changelog.d/3872.fixed.3.md new file mode 100644 index 0000000000..8781032e6f --- /dev/null +++ b/apple/changelog.d/3872.fixed.3.md @@ -0,0 +1 @@ +The selection in the Structure tab moved to another column after a reload or a sort. diff --git a/apple/changelog.d/3872.fixed.4.md b/apple/changelog.d/3872.fixed.4.md new file mode 100644 index 0000000000..51ec28cb4a --- /dev/null +++ b/apple/changelog.d/3872.fixed.4.md @@ -0,0 +1 @@ +One failed read blanked every Structure tab, and a failed Triggers read showed No Triggers. diff --git a/apple/changelog.d/3872.fixed.5.md b/apple/changelog.d/3872.fixed.5.md new file mode 100644 index 0000000000..67568e0a3b --- /dev/null +++ b/apple/changelog.d/3872.fixed.5.md @@ -0,0 +1 @@ +MySQL and MariaDB indexes offered a Condition the server drops. diff --git a/apple/changelog.d/3872.fixed.6.md b/apple/changelog.d/3872.fixed.6.md new file mode 100644 index 0000000000..f07eb45db5 --- /dev/null +++ b/apple/changelog.d/3872.fixed.6.md @@ -0,0 +1 @@ +Create Table read foreign key targets from the sidebar's database instead of the tab's. diff --git a/apple/changelog.d/3872.fixed.7.md b/apple/changelog.d/3872.fixed.7.md new file mode 100644 index 0000000000..6f63932edb --- /dev/null +++ b/apple/changelog.d/3872.fixed.7.md @@ -0,0 +1 @@ +Edit > Undo in the Structure tab did not name the change and stayed enabled with nothing to undo. diff --git a/apple/changelog.d/3872.fixed.8.md b/apple/changelog.d/3872.fixed.8.md new file mode 100644 index 0000000000..f51bbc6ec7 --- /dev/null +++ b/apple/changelog.d/3872.fixed.8.md @@ -0,0 +1 @@ +Switching a table from Structure to Data dimmed Save Changes while structure edits were still staged. diff --git a/apple/changelog.d/3872.fixed.md b/apple/changelog.d/3872.fixed.md new file mode 100644 index 0000000000..9e7465aad4 --- /dev/null +++ b/apple/changelog.d/3872.fixed.md @@ -0,0 +1 @@ +Making a column part of the primary key in the Structure tab did not change the table's key. From 7f18b4e9d8b1049b77346c9e1d004428f382d325 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 16:21:07 +0700 Subject: [PATCH 3/9] fix(structure): commit inspector text before Save and show partly read definitions --- .../MainContentCoordinator+GridModels.swift | 2 ++ .../StructureInspectorControls.swift | 22 +++++++++++++--- .../Structure/StructureDefinitionEditor.swift | 9 +++++-- .../StructureEditingSession+Commands.swift | 3 ++- .../StructureEditingSession+Loading.swift | 16 +++++++++--- .../Structure/StructureEditingSession.swift | 4 +++ .../StructureEditingSessionTests.swift | 25 +++++++++++++++++++ 7 files changed, 71 insertions(+), 10 deletions(-) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift index ad9c2ffc2a..d94260bf32 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift @@ -143,6 +143,8 @@ extension MainContentCoordinator { if let schemaModel = activeGridModelProvider(for: tab)?.activeGridModel { committed = schemaModel.commitEditing() && committed } + // Text typed in a structure inspector field counts as an edit the moment a command needs it. + activeStructureEditor?.commitPendingEdits() return committed } diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift index ec290eaf94..5e10888442 100644 --- a/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift @@ -12,6 +12,8 @@ struct StructureInspectorTextField: View { let value: String let revision: Int var isMultiline = false + // Moved by a pick from a menu beside the field: the field shows the picked value even while it has focus. + var resetToken = 0 let onCommit: (String) -> Void @State private var draft = "" @@ -46,6 +48,11 @@ struct StructureInspectorTextField: View { draft = latest base = latest } + .onChange(of: resetToken) { _ in + draft = value + base = value + latest = value + } .onChange(of: draft) { text in guard isFocused, text != base else { editor?.clearFieldDraft() @@ -143,11 +150,15 @@ struct StructureInspectorSuggestionField: View { let groups: [StructureInspectorChoices.ItemGroup] let onCommit: (String) -> Void + @State private var pickRevision = 0 + var body: some View { HStack(spacing: 4) { - StructureInspectorTextField(label: label, value: value, revision: revision, onCommit: onCommit) - .accessibilityLabel(label) - .accessibilityIdentifier(identifier) + StructureInspectorTextField( + label: label, value: value, revision: revision, resetToken: pickRevision, onCommit: onCommit + ) + .accessibilityLabel(label) + .accessibilityIdentifier(identifier) Menu { ForEach(groups) { group in if let header = group.header { @@ -168,9 +179,14 @@ struct StructureInspectorSuggestionField: View { } } + // A pick replaces whatever is typed in the field, so the typed text never lands on top of it later. + @Environment(\.structureDefinitionEditor) private var editor + private func items(_ group: StructureInspectorChoices.ItemGroup) -> some View { ForEach(group.items) { item in Button(item.title) { + editor?.clearFieldDraft() + pickRevision &+= 1 guard item.value != value else { return } onCommit(item.value) } diff --git a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift index 10a0747cd4..6493914d95 100644 --- a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift +++ b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift @@ -67,7 +67,12 @@ final class StructureDefinitionEditor: ObservableObject { private let defaults: UserDefaults private var cancellables: Set = [] - private var fieldDraft: (text: String, commit: (String) -> Void)? + private var fieldDraft: (text: String, commit: (String) -> Void)? { + didSet { + let isPending = fieldDraft != nil + if hasPendingFieldDraft != isPending { hasPendingFieldDraft = isPending } + } + } private var isSyncingSelection = false init( @@ -291,7 +296,7 @@ final class StructureDefinitionEditor: ObservableObject { // An inspector field's typed text is committed on Return or when focus leaves; until then it is noted here, so // Save, close and the other commands see it and commit it first. - var hasPendingFieldDraft: Bool { fieldDraft != nil } + @Published private(set) var hasPendingFieldDraft = false func noteFieldDraft(_ text: String, commit: @escaping (String) -> Void) { fieldDraft = (text, commit) diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift index f8b58edfc2..5623cf6c65 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift @@ -9,7 +9,8 @@ import Foundation // What the menu bar, the toolbar and the keyboard ask of a structure tab. The coordinator calls these on the // selected tab's session, so they work whether or not a view is mounted. internal extension StructureEditingSession { - var hasChanges: Bool { changeManager.hasChanges } + // Typing in an inspector field already counts, so Save is offered before the field commits. + var hasChanges: Bool { changeManager.hasChanges || editor.hasPendingFieldDraft } var pendingChangeCount: Int { changeManager.getChangesArray().count } diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift index 2316b2cffa..7b08238b0c 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Loading.swift @@ -56,8 +56,12 @@ internal extension StructureEditingSession { } } + // A partial read still shows what it got, read-only, so a failed section never hides the ones that loaded. func adoptIfCurrent() { - guard definitionsAreCurrent else { return } + guard definitionsAreCurrent else { + if !changeManager.hasChanges { loadDefinitionsIntoManager() } + return + } hasLoaded = true adoptFetchedBaseline() } @@ -142,8 +146,14 @@ internal extension StructureEditingSession { // MARK: - Baseline - // PostgreSQL leaves `isPrimaryKey` false on every column, so the primary index decides first. func adoptLoadedBaseline() { + loadDefinitionsIntoManager() + hasBaseline = true + editor.isAwaitingBaseline = false + } + + // PostgreSQL leaves `isPrimaryKey` false on every column, so the primary index decides first. + private func loadDefinitionsIntoManager() { let pkFromIndexes = indexes.first(where: { $0.isPrimary })?.columns ?? [] let pkFromColumns = columns.filter { $0.isPrimaryKey }.map { $0.name } let primaryKey = pkFromIndexes.isEmpty ? pkFromColumns : pkFromIndexes @@ -159,8 +169,6 @@ internal extension StructureEditingSession { tableOptions: loadedTableOptions, autoIncrementConfirmed: autoIncrementRead == .confirmed ) - hasBaseline = true - editor.isAwaitingBaseline = false editor.refresh() } diff --git a/apple/TablePro/Views/Structure/StructureEditingSession.swift b/apple/TablePro/Views/Structure/StructureEditingSession.swift index 5e4cd04c59..925b6dc094 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession.swift @@ -150,6 +150,10 @@ internal final class StructureEditingSession: ObservableObject { changeManager.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) + editor.$hasPendingFieldDraft + .dropFirst() + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) $tableOptionsCatalog .dropFirst() .receive(on: RunLoop.main) diff --git a/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift b/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift index c67641e12a..96e628f7f0 100644 --- a/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift +++ b/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift @@ -275,6 +275,31 @@ struct StructureEditingSessionTests { #expect(session.changeManager.workingColumns == columns) } + @Test("A failed section still shows the sections that loaded, read-only") + func partialReadShowsLoadedSectionsReadOnly() { + let session = Self.makeSession(connection: TestFixtures.makeConnection()) + session.columns = [ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true)] + session.loadState.markFetched(.columns) + session.loadState.markFailed(.indexes, message: "timed out") + + session.adoptIfCurrent() + + #expect(session.changeManager.workingColumns.map(\.name) == ["id"]) + #expect(!session.hasBaseline) + #expect(!session.canAddRow) + #expect(!session.editor.canAdd(.columns)) + } + + @Test("Text typed in the inspector counts as a change before it is committed") + func pendingFieldDraftCountsAsAChange() { + let session = Self.makeSession(connection: TestFixtures.makeConnection()) + #expect(!session.hasChanges) + session.editor.noteFieldDraft("x") { _ in } + #expect(session.hasChanges) + session.editor.clearFieldDraft() + #expect(!session.hasChanges) + } + @Test("Row commands wait for the definitions the table was read with") func rowCommandsWaitForTheBaseline() { let session = Self.makeSession(connection: TestFixtures.makeConnection()) From 70510615e71fca49ae77dc8208a40e09a59817d4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 16:46:18 +0700 Subject: [PATCH 4/9] fix(structure): stage inspector typing as it happens and undo each run as one step --- .../StructureChangeManager.swift | 44 +++++++++-- .../MainContentCoordinator+GridModels.swift | 2 +- .../MainContentCoordinator+Protection.swift | 4 +- .../StructureDefinitionFormView.swift | 32 +++++--- .../StructureInspectorControls.swift | 76 +++++++------------ .../Inspector/StructureInspectorView.swift | 1 - .../Structure/StructureDefinitionEditor.swift | 55 ++++++++------ .../StructureEditingSession+Commands.swift | 4 +- .../Structure/StructureEditingSession.swift | 4 - .../Structure/StructureSectionAdapter.swift | 2 +- .../StructureDefinitionEditorTests.swift | 21 +++-- .../StructureEditingSessionTests.swift | 27 +++++-- 12 files changed, 159 insertions(+), 113 deletions(-) diff --git a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift index ee207790e4..7a2e8a0e96 100644 --- a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -86,14 +86,39 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { return manager }() - var canUndo: Bool { !isHeldForSave && (undoManager.canUndo || hasTableCommentRun) } - var canRedo: Bool { !isHeldForSave && !hasTableCommentRun && undoManager.canRedo } + var canUndo: Bool { !isHeldForSave && (undoManager.canUndo || hasTableCommentRun || typingRun != nil) } + var canRedo: Bool { !isHeldForSave && !hasTableCommentRun && typingRun == nil && undoManager.canRedo } var undoMenuItemTitle: String { - guard !hasTableCommentRun else { return undoManager.undoMenuTitle(forUndoActionName: Self.tableCommentActionName) } + if hasTableCommentRun { return undoManager.undoMenuTitle(forUndoActionName: Self.tableCommentActionName) } + if let typingRun { return undoManager.undoMenuTitle(forUndoActionName: typingRun.actionName) } return undoManager.undoMenuItemTitle } + // Typing in one field of one definition: every keystroke is staged, so Save, Refresh and the close prompt see it, + // and the whole run undoes as one step. Anything else that registers an undo ends it first. + private var typingRun: (key: String, actionName: String)? + private var isStagingTyping = false + + func stageTyping(in key: String, actionName: String, _ body: () -> Void) { + guard !isHeldForSave else { return } + if typingRun?.key != key { endTypingRun() } + if typingRun == nil { + endTableCommentRun() + undoManager.beginUndoGrouping() + typingRun = (key, actionName) + } + isStagingTyping = true + defer { isStagingTyping = false } + body() + } + + func endTypingRun() { + guard typingRun != nil else { return } + typingRun = nil + undoManager.endUndoGrouping() + } + var redoMenuItemTitle: String { undoManager.redoMenuItemTitle } /// Mirrors `DataChangeManager.registerUndo`. The `groupingLevel` check is what lets @@ -103,6 +128,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { private func registerUndo(_ actionName: String, _ handler: @escaping (StructureChangeManager) -> Void) { if !undoManager.isUndoing, !undoManager.isRedoing { endTableCommentRun() + if !isStagingTyping { endTypingRun() } } let opensOwnGroup = !undoManager.groupsByEvent && undoManager.groupingLevel == 0 if opensOwnGroup { undoManager.beginUndoGrouping() } @@ -117,6 +143,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { func performAsOneUndoStep(_ body: () -> Void) { guard !isHeldForSave else { return } endTableCommentRun() + if !isStagingTyping { endTypingRun() } undoManager.beginUndoGrouping() defer { undoManager.endUndoGrouping() } body() @@ -136,6 +163,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { autoIncrementConfirmed: Bool = true ) { guard !isHeldForSave else { return } + endTypingRun() self.tableName = tableName self.tableComment = ObjectCommentDraft(original: tableComment) tableCommentRunStart = nil @@ -504,11 +532,11 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { // A column whose delete took it out of a staged key goes back into that key, at the place it held. private func restoreKeyMembership(ofColumn id: UUID) { - let position = removedKeyPositions.removeValue(forKey: id) - guard let column = workingColumns.first(where: { $0.id == id }), column.isPrimaryKey, + guard let position = removedKeyPositions.removeValue(forKey: id), + let column = workingColumns.first(where: { $0.id == id }), column.isPrimaryKey, !workingPrimaryKey.contains(column.name) else { return } var key = workingPrimaryKey - key.insert(column.name, at: min(position ?? key.count, key.count)) + key.insert(column.name, at: min(position, key.count)) stagePrimaryKey(key) } @@ -833,6 +861,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { func discardChanges() { guard !isHeldForSave else { return } + endTypingRun() tableComment = ObjectCommentDraft(original: tableComment.original) tableCommentRunStart = nil tableOptions = TableOptionsDraft( @@ -858,6 +887,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { /// Taken before the save's first suspension, which is what refuses a second press. func holdForSave() -> StructureSaveSnapshot? { endTableCommentRun() + endTypingRun() guard heldSave == nil, hasChanges else { return nil } let snapshot = StructureSaveSnapshot(changes: getChangesArray()) heldSave = snapshot @@ -899,6 +929,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { func undo() { guard !isHeldForSave else { return } endTableCommentRun() + endTypingRun() guard undoManager.canUndo else { return } undoManager.undo() } @@ -906,6 +937,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { func redo() { guard !isHeldForSave else { return } endTableCommentRun() + endTypingRun() guard undoManager.canRedo else { return } undoManager.redo() } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift index d94260bf32..be655cf9ae 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift @@ -143,7 +143,7 @@ extension MainContentCoordinator { if let schemaModel = activeGridModelProvider(for: tab)?.activeGridModel { committed = schemaModel.commitEditing() && committed } - // Text typed in a structure inspector field counts as an edit the moment a command needs it. + // A run of typing in the structure inspector ends, so the command's undo step stands apart from it. activeStructureEditor?.commitPendingEdits() return committed } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift index 5b0a86b3ee..f7b85bde40 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+Protection.swift @@ -76,7 +76,7 @@ extension MainContentCoordinator { // Read from the tab's session, mounted or not, so it holds in Data view and in a background tab. func hasStagedStructureEdits(in tab: QueryTab) -> Bool { - (structureSessions[tab.id].map { $0.changeManager.hasChanges || $0.editor.hasPendingFieldDraft } ?? false) + (structureSessions[tab.id]?.changeManager.hasChanges ?? false) || (erDiagramModels[tab.id]?.holdsUnsavedWork ?? false) } @@ -90,7 +90,7 @@ extension MainContentCoordinator { } func hasTableDraftWork(in tab: QueryTab) -> Bool { - createTableDrafts[tab.id].map { $0.holdsWork || $0.editor.hasPendingFieldDraft } ?? false + createTableDrafts[tab.id]?.holdsWork ?? false } // Never narrower than `hasUnsavedWork(in:)`; broader for a scratch tab, whose text is unsaved but comes back. diff --git a/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift b/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift index c1f1bc5f28..ec0123cf4f 100644 --- a/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift +++ b/apple/TablePro/Views/Structure/Inspector/StructureDefinitionFormView.swift @@ -57,13 +57,17 @@ struct StructureDefinitionFormView: View { let value = field.value ?? "" switch StructureInspectorControl.resolve(field) { case .text: - StructureInspectorTextField(label: field.label, value: value, revision: revision) { commit($0, to: field) } - .accessibilityLabel(field.label) - .accessibilityIdentifier(identifier) + StructureInspectorTextField( + label: field.label, value: value, revision: revision, + onType: { type($0, into: field) }, onEnd: { editor.endInspectorTyping() } + ) + .accessibilityLabel(field.label) + .accessibilityIdentifier(identifier) case .multiline: StructureInspectorTextField( - label: field.label, value: value, revision: revision, isMultiline: true - ) { commit($0, to: field) } + label: field.label, value: value, revision: revision, isMultiline: true, + onType: { type($0, into: field) }, onEnd: { editor.endInspectorTyping() } + ) .accessibilityLabel(field.label) .accessibilityIdentifier(identifier) case .checkbox: @@ -91,8 +95,11 @@ struct StructureDefinitionFormView: View { value: value, revision: revision, identifier: identifier, - groups: choices.groups - ) { commit($0, to: field) } + groups: choices.groups, + onType: { type($0, into: field) }, + onPick: { commit($0, to: field) }, + onEnd: { editor.endInspectorTyping() } + ) case .typePicker: StructureInspectorTypeField( value: value, @@ -100,8 +107,11 @@ struct StructureDefinitionFormView: View { label: field.label, identifier: identifier, databaseType: editor.context.databaseType, - scope: editor.context.scope - ) { commit($0, to: field) } + scope: editor.context.scope, + onType: { type($0, into: field) }, + onPick: { commit($0, to: field) }, + onEnd: { editor.endInspectorTyping() } + ) case .readOnly(let text): Text(text) .textSelection(.enabled) @@ -144,6 +154,10 @@ struct StructureDefinitionFormView: View { // A tag no item carries, so a value the menu does not list shows nothing selected rather than another item. private static let noSelection = -1 + private func type(_ value: String, into field: StructureInspectorForm.Field) { + editor.typeInspectorField(section: form.section, id: form.entityID, field: field.id, value: value) + } + private func commit(_ value: String, to field: StructureInspectorForm.Field) { guard value != (field.value ?? "") else { return } editor.commitInspectorField(section: form.section, id: form.entityID, field: field.id, value: value) diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift index 5e10888442..f9846b16f5 100644 --- a/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorControls.swift @@ -5,8 +5,9 @@ import SwiftUI -// A definition field's text: one write on Return or when focus leaves, so a typed value is one undo step. Typing -// survives a refresh while the field has focus. +// A definition field's text. Every keystroke is staged at once, the way the table comment's field is, so Save, Refresh +// and the close prompt always see what is typed; the editor folds the run into one undo step, ended by Return or by +// focus leaving. struct StructureInspectorTextField: View { let label: String let value: String @@ -14,16 +15,16 @@ struct StructureInspectorTextField: View { var isMultiline = false // Moved by a pick from a menu beside the field: the field shows the picked value even while it has focus. var resetToken = 0 - let onCommit: (String) -> Void + let onType: (String) -> Void + let onEnd: () -> Void @State private var draft = "" - // What the draft was last synced from, so only typing commits, never a value the model moved under the field. + // What the field last wrote or was synced from, so only typing writes, never a value the model moved under it. @State private var base = "" - // The stored value, kept while focused, so leaving a field whose write was refused puts it back. + // The stored value, so leaving a field whose write was refused puts it back. @State private var latest = "" @State private var hasSeeded = false @FocusState private var isFocused: Bool - @Environment(\.structureDefinitionEditor) private var editor private struct Sync: Equatable { let value: String @@ -36,15 +37,20 @@ struct StructureInspectorTextField: View { .autocorrectionDisabled(true) .focused($isFocused) .onAppear(perform: seed) - // The revision moves on a refused write too, which puts the stored value back in the field. .onChange(of: Sync(value: value, revision: revision)) { sync in latest = sync.value guard !isFocused else { return } draft = sync.value base = sync.value } + .onChange(of: draft) { text in + guard hasSeeded, isFocused, text != base else { return } + base = text + onType(text) + } .onChange(of: isFocused) { focused in - guard !focused, !commit() else { return } + guard !focused else { return } + onEnd() draft = latest base = latest } @@ -53,18 +59,11 @@ struct StructureInspectorTextField: View { base = value latest = value } - .onChange(of: draft) { text in - guard isFocused, text != base else { - editor?.clearFieldDraft() - return - } - editor?.noteFieldDraft(text, commit: onCommit) - } - .onSubmit { commit() } - .onDisappear { commit() } + .onSubmit(onEnd) + .onDisappear(perform: onEnd) } - // The label column names the field, so an empty one shows no placeholder. + // The field's name is above it, so an empty field shows no placeholder. @ViewBuilder private var field: some View { if isMultiline { @@ -84,15 +83,6 @@ struct StructureInspectorTextField: View { base = value latest = value } - - @discardableResult - private func commit() -> Bool { - editor?.clearFieldDraft() - guard hasSeeded, draft != base else { return false } - base = draft - onCommit(draft) - return true - } } // Free text with the engine's types and the database's own types to pick from, grouped as the grid's type picker @@ -104,7 +94,9 @@ struct StructureInspectorTypeField: View { let identifier: String let databaseType: DatabaseType let scope: DatabaseScope? - let onCommit: (String) -> Void + let onType: (String) -> Void + let onPick: (String) -> Void + let onEnd: () -> Void var body: some View { UserDefinedTypeAwarePicker(scope: scope) { userDefinedTypes in @@ -114,7 +106,9 @@ struct StructureInspectorTypeField: View { revision: revision, identifier: identifier, groups: Self.groups(userDefinedTypes: userDefinedTypes, databaseType: databaseType), - onCommit: onCommit + onType: onType, + onPick: onPick, + onEnd: onEnd ) } } @@ -148,14 +142,16 @@ struct StructureInspectorSuggestionField: View { let revision: Int let identifier: String let groups: [StructureInspectorChoices.ItemGroup] - let onCommit: (String) -> Void + let onType: (String) -> Void + let onPick: (String) -> Void + let onEnd: () -> Void @State private var pickRevision = 0 var body: some View { HStack(spacing: 4) { StructureInspectorTextField( - label: label, value: value, revision: revision, resetToken: pickRevision, onCommit: onCommit + label: label, value: value, revision: revision, resetToken: pickRevision, onType: onType, onEnd: onEnd ) .accessibilityLabel(label) .accessibilityIdentifier(identifier) @@ -179,16 +175,13 @@ struct StructureInspectorSuggestionField: View { } } - // A pick replaces whatever is typed in the field, so the typed text never lands on top of it later. - @Environment(\.structureDefinitionEditor) private var editor - + // A pick is its own undo step and replaces whatever is typed in the field. private func items(_ group: StructureInspectorChoices.ItemGroup) -> some View { ForEach(group.items) { item in Button(item.title) { - editor?.clearFieldDraft() pickRevision &+= 1 guard item.value != value else { return } - onCommit(item.value) + onPick(item.value) } } } @@ -237,14 +230,3 @@ struct StructureInspectorFootnote: View { .frame(maxWidth: .infinity, alignment: .leading) } } - -private struct StructureDefinitionEditorKey: EnvironmentKey { - static let defaultValue: StructureDefinitionEditor? = nil -} - -extension EnvironmentValues { - var structureDefinitionEditor: StructureDefinitionEditor? { - get { self[StructureDefinitionEditorKey.self] } - set { self[StructureDefinitionEditorKey.self] = newValue } - } -} diff --git a/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift b/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift index 39aa29f483..6a71080f21 100644 --- a/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift +++ b/apple/TablePro/Views/Structure/Inspector/StructureInspectorView.swift @@ -56,7 +56,6 @@ private struct StructureInspectorContent: View { content(target: target, form: form) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .environment(\.structureDefinitionEditor, editor) .accessibilityElement(children: .contain) .accessibilityIdentifier("structure-inspector") } diff --git a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift index 6493914d95..f86e0181ce 100644 --- a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift +++ b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift @@ -67,12 +67,6 @@ final class StructureDefinitionEditor: ObservableObject { private let defaults: UserDefaults private var cancellables: Set = [] - private var fieldDraft: (text: String, commit: (String) -> Void)? { - didSet { - let isPending = fieldDraft != nil - if hasPendingFieldDraft != isPending { hasPendingFieldDraft = isPending } - } - } private var isSyncingSelection = false init( @@ -174,6 +168,13 @@ final class StructureDefinitionEditor: ObservableObject { refresh() } + // One field of one definition changed, which no other section shows: the cheap refresh a keystroke can afford. + func definitionsDidChange(in section: StructureSection) { + adapter(for: section).refresh() + settleInspectorTarget() + revision &+= 1 + } + // MARK: - Selection var focusedSection: StructureSection? { @@ -292,25 +293,11 @@ final class StructureDefinitionEditor: ObservableObject { moveColumnHandler?(sourceIndex, destination) } - // MARK: - Pending edits - - // An inspector field's typed text is committed on Return or when focus leaves; until then it is noted here, so - // Save, close and the other commands see it and commit it first. - @Published private(set) var hasPendingFieldDraft = false - - func noteFieldDraft(_ text: String, commit: @escaping (String) -> Void) { - fieldDraft = (text, commit) - } - - func clearFieldDraft() { - fieldDraft = nil - } + // MARK: - Open edits + // Commits an edit open in a grid cell, and ends a run of typing in the inspector, before a command acts. func commitPendingEdits() { - if let draft = fieldDraft { - fieldDraft = nil - draft.commit(draft.text) - } + manager.endTypingRun() for section in StructureSection.allCases { adapter(for: section).model.commitEditing() } @@ -328,7 +315,29 @@ final class StructureDefinitionEditor: ObservableObject { return StructureInspectorForm.build(adapter: adapter, sourceIndex: sourceIndex, entityID: id) } + // A pick, a checkbox or a menu choice: one change, one undo step. func commitInspectorField(section: StructureSection, id: UUID, field: Int, value: String) { + manager.endTypingRun() adapter(for: section).applyEdit(entity: id, field: field, text: value) } + + // A keystroke in an inspector field, staged at once and undone with the rest of its run. + func typeInspectorField(section: StructureSection, id: UUID, field: Int, value: String) { + manager.stageTyping(in: "\(section.rawValue).\(id.uuidString).\(field)", actionName: Self.editActionName(section)) { + adapter(for: section).applyEdit(entity: id, field: field, text: value) + } + } + + func endInspectorTyping() { + manager.endTypingRun() + } + + private static func editActionName(_ section: StructureSection) -> String { + switch section { + case .columns: String(localized: "Edit Column") + case .indexes: String(localized: "Edit Index") + case .foreignKeys: String(localized: "Edit Foreign Key") + case .checkConstraints: String(localized: "Edit Check Constraint") + } + } } diff --git a/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift b/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift index 5623cf6c65..66f98a8b10 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession+Commands.swift @@ -9,8 +9,7 @@ import Foundation // What the menu bar, the toolbar and the keyboard ask of a structure tab. The coordinator calls these on the // selected tab's session, so they work whether or not a view is mounted. internal extension StructureEditingSession { - // Typing in an inspector field already counts, so Save is offered before the field commits. - var hasChanges: Bool { changeManager.hasChanges || editor.hasPendingFieldDraft } + var hasChanges: Bool { changeManager.hasChanges } var pendingChangeCount: Int { changeManager.getChangesArray().count } @@ -75,7 +74,6 @@ internal extension StructureEditingSession { // MARK: - Discard func discardChanges() { - editor.clearFieldDraft() for section in StructureSection.allCases { editor.adapter(for: section).model.discardEditing() } diff --git a/apple/TablePro/Views/Structure/StructureEditingSession.swift b/apple/TablePro/Views/Structure/StructureEditingSession.swift index 925b6dc094..5e4cd04c59 100644 --- a/apple/TablePro/Views/Structure/StructureEditingSession.swift +++ b/apple/TablePro/Views/Structure/StructureEditingSession.swift @@ -150,10 +150,6 @@ internal final class StructureEditingSession: ObservableObject { changeManager.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } .store(in: &cancellables) - editor.$hasPendingFieldDraft - .dropFirst() - .sink { [weak self] _ in self?.objectWillChange.send() } - .store(in: &cancellables) $tableOptionsCatalog .dropFirst() .receive(on: RunLoop.main) diff --git a/apple/TablePro/Views/Structure/StructureSectionAdapter.swift b/apple/TablePro/Views/Structure/StructureSectionAdapter.swift index f40aaeae17..bfe5d2de88 100644 --- a/apple/TablePro/Views/Structure/StructureSectionAdapter.swift +++ b/apple/TablePro/Views/Structure/StructureSectionAdapter.swift @@ -270,7 +270,7 @@ final class StructureSectionAdapter { func applyEdit(rowID: RowID, field: Int, text: String) -> Bool { guard canEdit, let sourceIndex = sourceIndex(of: rowID) else { return false } let applied = write(text, field: field, sourceIndex: sourceIndex) - editor?.definitionsDidChange() + editor?.definitionsDidChange(in: section) return applied } diff --git a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift index 48d5b2977e..dba42e4984 100644 --- a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift +++ b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift @@ -152,19 +152,18 @@ struct StructureDefinitionEditorTests { #expect(makeEditor().adapter(for: .indexes).provider.columns.contains(String(localized: "Condition"))) } - @Test("Text typed in an inspector field is committed before a command runs") - func pendingFieldDraftCommitsFirst() { + @Test("A pick after typing is its own undo step") + func pickAfterTypingIsItsOwnStep() { let editor = makeEditor() let status = id(of: "status", in: editor) - editor.noteFieldDraft("state") { text in - editor.commitInspectorField(section: .columns, id: status, field: 0, value: text) - } - #expect(editor.hasPendingFieldDraft) - - editor.commitPendingEdits() - - #expect(!editor.hasPendingFieldDraft) - #expect(editor.manager.workingColumns.contains { $0.name == "state" }) + editor.typeInspectorField(section: .columns, id: status, field: 0, value: "stat") + editor.typeInspectorField(section: .columns, id: status, field: 0, value: "state") + editor.commitInspectorField(section: .columns, id: status, field: 1, value: "VARCHAR(20)") + + editor.manager.undo() + #expect(editor.manager.workingColumns.contains { $0.name == "state" && $0.dataType != "VARCHAR(20)" }) + editor.manager.undo() + #expect(editor.manager.workingColumns.contains { $0.name == "status" }) } @Test("Nothing can be edited while the table is read again after a save") diff --git a/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift b/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift index 96e628f7f0..d1e40cd7a6 100644 --- a/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift +++ b/apple/TableProTests/Views/Structure/StructureEditingSessionTests.swift @@ -290,13 +290,30 @@ struct StructureEditingSessionTests { #expect(!session.editor.canAdd(.columns)) } - @Test("Text typed in the inspector counts as a change before it is committed") - func pendingFieldDraftCountsAsAChange() { + @Test("Text typed in the inspector is staged as it is typed, and its run undoes as one step") + func inspectorTypingIsStagedAndUndoesOnce() { let session = Self.makeSession(connection: TestFixtures.makeConnection()) - #expect(!session.hasChanges) - session.editor.noteFieldDraft("x") { _ in } + session.changeManager.loadSchema( + tableName: "users", + columns: [ColumnInfo(name: "email", dataType: "TEXT", isNullable: true, isPrimaryKey: false)], + indexes: [], + foreignKeys: [], + primaryKey: [] + ) + session.markBaselineAdopted() + session.editor.refresh() + let id = session.changeManager.workingColumns[0].id + for text in ["e", "em", "ema", "mail"] { + session.editor.typeInspectorField(section: .columns, id: id, field: 0, value: text) + } + #expect(session.hasChanges) - session.editor.clearFieldDraft() + #expect(session.changeManager.workingColumns[0].name == "mail") + #expect(session.undoMenuItemTitle.contains(String(localized: "Edit Column"))) + + session.undo() + + #expect(session.changeManager.workingColumns[0].name == "email") #expect(!session.hasChanges) } From 88b6389e07cd65f77a1a38e59430f3037ed4fecb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 17:01:42 +0700 Subject: [PATCH 5/9] fix(structure): derive the primary key from its columns and drop it before a key column goes --- .../SchemaStatementGenerator.swift | 31 ++++- .../StructureChangeManager.swift | 125 ++++-------------- .../Structure/StructureDefinitionEditor.swift | 8 +- .../PrimaryKeyStatementOrderTests.swift | 71 ++++++++++ .../StructureChangeManagerStagingTests.swift | 26 ++++ .../StructureDefinitionEditorTests.swift | 12 ++ 6 files changed, 174 insertions(+), 99 deletions(-) create mode 100644 apple/TableProTests/Core/SchemaTracking/PrimaryKeyStatementOrderTests.swift diff --git a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index b641439237..274be172fa 100644 --- a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -175,10 +175,37 @@ struct SchemaStatementGenerator { let constraintHandoffs = Self.inNameOrder(constraintModifies) let indexHandoffs = Self.inNameOrder(indexAdds) + let keyHandoff = Self.keyDropsBeforeColumnDrops(pkChanges, columnDeletes: columnDeletes) return constraintDeletes + constraintHandoffs.drops + constraintHandoffs.ordered + fkDeletes - + indexDeletes + indexHandoffs.drops + columnDeletes + characterSetChanges + columnModifies + columnAdds - + pkChanges + autoIncrementChanges + indexHandoffs.ordered + fkAdds + constraintAdds + commentChanges + + indexDeletes + indexHandoffs.drops + keyHandoff.drops + columnDeletes + characterSetChanges + + columnModifies + columnAdds + keyHandoff.adds + autoIncrementChanges + indexHandoffs.ordered + fkAdds + + constraintAdds + commentChanges + } + + // Dropping a key column takes the key with it on PostgreSQL and MySQL, so the key's own DROP would then fail: a key + // that loses a dropped column is dropped before the columns and added back after the columns are added. + private static func keyDropsBeforeColumnDrops( + _ keyChanges: [SchemaChange], + columnDeletes: [SchemaChange] + ) -> (drops: [SchemaChange], adds: [SchemaChange]) { + let dropped = Set(columnDeletes.compactMap { change -> String? in + guard case .deleteColumn(let column) = change else { return nil } + return column.name + }) + var drops: [SchemaChange] = [] + var adds: [SchemaChange] = [] + for change in keyChanges { + guard case .modifyPrimaryKey(let old, let new) = change, !old.isEmpty, old.contains(where: dropped.contains) else { + adds.append(change) + continue + } + drops.append(.modifyPrimaryKey(old: old, new: [])) + if !new.isEmpty { + adds.append(.modifyPrimaryKey(old: [], new: new)) + } + } + return (drops, adds) } /// Orders changes that each give up one name and take another in a single step, a rename or a diff --git a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 7a2e8a0e96..28f7c413dc 100644 --- a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -31,8 +31,9 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { @Published var workingIndexes: [EditableIndexDefinition] = [] @Published var workingForeignKeys: [EditableForeignKeyDefinition] = [] @Published var workingCheckConstraints: [EditableCheckConstraintDefinition] = [] - @Published var workingPrimaryKey: [String] = [] - private var removedKeyPositions: [UUID: Int] = [:] + // Derived from the columns after every change: the ones that stay and are marked Primary Key, the loaded members in + // the loaded key's order, new ones after them in column order. A key nobody changed follows a rename by itself. + @Published private(set) var workingPrimaryKey: [String] = [] @Published var tableName: String? @@ -255,78 +256,12 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { // MARK: - Column Operations - // The key is the table's, so a Primary Key cell stages the key change the drivers write, not a column change. func updateColumn(id: UUID, with newColumn: EditableColumnDefinition) { - guard let previous = workingColumns.first(where: { $0.id == id }) else { - stageEdit(id: id, with: newColumn, using: Self.columnOperations) - return - } - let togglesKey = previous.isPrimaryKey != newColumn.isPrimaryKey - // A staged key names columns by their new names; a key nobody changed follows a rename by itself. - let renamesStagedKeyMember = previous.name != newColumn.name - && isStaged(.primaryKey) && workingPrimaryKey.contains(previous.name) - guard togglesKey || renamesStagedKeyMember else { - stageEdit(id: id, with: newColumn, using: Self.columnOperations) - return - } - performAsOneUndoStep { - stageEdit(id: id, with: newColumn, using: Self.columnOperations) - if togglesKey { - let names = Set([previous.name, newColumn.name] + currentColumns.filter { $0.id == id }.map(\.name)) - stagePrimaryKeyMembership(of: newColumn.name, replacing: names, isMember: newColumn.isPrimaryKey) - } else { - stagePrimaryKey(workingPrimaryKey.map { $0 == previous.name ? newColumn.name : $0 }) - } - } + stageEdit(id: id, with: newColumn, using: Self.columnOperations) } - private func stagePrimaryKeyMembership(of name: String, replacing names: Set, isMember: Bool) { - var new = keyInWorkingNames.filter { !names.contains($0) } - if isMember { new.append(name) } - stagePrimaryKey(new) - } - - // The loaded key names columns as they were read; once a change to the key is staged, it names them as they are now. - private var keyInWorkingNames: [String] { - guard !isStaged(.primaryKey) else { return workingPrimaryKey } - return workingPrimaryKey.map { name in - guard let id = currentColumns.first(where: { $0.name == name })?.id, - let working = workingColumns.first(where: { $0.id == id }) else { return name } - return working.name - } - } - - private func stagePrimaryKey(_ new: [String]) { - let old = workingPrimaryKey - guard new != old else { return } - registerUndo(String(localized: "Change Primary Key")) { target in - target.applySchemaUndo(.primaryKeyChange(old: old, new: new)) - } - workingPrimaryKey = new - let key = SchemaChangeIdentifier.primaryKey - if new != currentPrimaryKey { - pendingChanges[key] = .modifyPrimaryKey(old: currentPrimaryKey, new: new) - trackChangeKey(key) - } else { - pendingChanges.removeValue(forKey: key) - untrackChangeKey(key) - } - workingCopyDidChange() - } - - - // A staged key loses a column that goes, so it never names one Save cannot find. func deleteColumn(id: UUID) { - guard let column = workingColumns.first(where: { $0.id == id }), - isStaged(.primaryKey), workingPrimaryKey.contains(column.name) else { - stageDeletion(id: id, using: Self.columnOperations) - return - } - performAsOneUndoStep { - stageDeletion(id: id, using: Self.columnOperations) - removedKeyPositions[id] = workingPrimaryKey.firstIndex(of: column.name) - stagePrimaryKey(workingPrimaryKey.filter { $0 != column.name }) - } + stageDeletion(id: id, using: Self.columnOperations) } // MARK: - Index Operations @@ -521,25 +456,13 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { func undoDelete(section: StructureSection, id: UUID) { guard !isHeldForSave else { return } switch section { - case .columns: - undoDeletion(id: id, using: Self.columnOperations) - restoreKeyMembership(ofColumn: id) + case .columns: undoDeletion(id: id, using: Self.columnOperations) case .indexes: undoDeletion(id: id, using: Self.indexOperations) case .foreignKeys: undoDeletion(id: id, using: Self.foreignKeyOperations) case .checkConstraints: undoDeletion(id: id, using: Self.checkConstraintOperations) } } - // A column whose delete took it out of a staged key goes back into that key, at the place it held. - private func restoreKeyMembership(ofColumn id: UUID) { - guard let position = removedKeyPositions.removeValue(forKey: id), - let column = workingColumns.first(where: { $0.id == id }), column.isPrimaryKey, - !workingPrimaryKey.contains(column.name) else { return } - var key = workingPrimaryKey - key.insert(column.name, at: min(position, key.count)) - stagePrimaryKey(key) - } - private func undoDeletion(id: UUID, using operations: SchemaEntityOperations) { let key = operations.identifier(id) guard pendingChanges[key]?.isDelete == true else { return } @@ -566,6 +489,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { /// decides for rows it did not touch, then validation. private func workingCopyDidChange() { settleClusteredAdditions() + restagePrimaryKey() validate() } @@ -1057,8 +981,8 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { applyAdditionUndo(constraint, using: Self.checkConstraintOperations) case .checkConstraintDelete(let constraint, let at): applyDeletionUndo(constraint, at: at, using: Self.checkConstraintOperations) - case .primaryKeyChange(let old, _): - applyPrimaryKeyChangeUndo(old: old) + case .primaryKeyChange: + restagePrimaryKey() case .tableCommentEdit(let old, let new): applyTableCommentUndo(old: old, new: new) case .tableOptionsEdit(let old, let new): @@ -1133,19 +1057,28 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { } } - private func applyPrimaryKeyChangeUndo(old: [String]) { - let current = workingPrimaryKey - registerUndo(String(localized: "Change Primary Key")) { target in - target.applySchemaUndo(.primaryKeyChange(old: current, new: old)) + // MARK: - Primary key + + private func restagePrimaryKey() { + let loadedIDs = currentPrimaryKey.compactMap { name in currentColumns.first { $0.name == name }?.id } + let key = SchemaChangeIdentifier.primaryKey + // A key naming a column that was not read (a failed read, an expression key) is left as it was loaded. + guard loadedIDs.count == currentPrimaryKey.count else { + workingPrimaryKey = currentPrimaryKey + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) + return } - workingPrimaryKey = old - let pkKey = SchemaChangeIdentifier.primaryKey - if workingPrimaryKey != currentPrimaryKey { - pendingChanges[pkKey] = .modifyPrimaryKey(old: currentPrimaryKey, new: workingPrimaryKey) - trackChangeKey(pkKey) + let members = workingColumns.filter { $0.isPrimaryKey && !isPendingDeletion(.column($0.id)) } + let ordered = loadedIDs.compactMap { id in members.first { $0.id == id } } + + members.filter { !loadedIDs.contains($0.id) } + workingPrimaryKey = ordered.map(\.name) + if ordered.map(\.id) == loadedIDs { + pendingChanges.removeValue(forKey: key) + untrackChangeKey(key) } else { - pendingChanges.removeValue(forKey: pkKey) - untrackChangeKey(pkKey) + pendingChanges[key] = .modifyPrimaryKey(old: currentPrimaryKey, new: workingPrimaryKey) + trackChangeKey(key) } } diff --git a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift index f86e0181ce..2ab4b64a07 100644 --- a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift +++ b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift @@ -220,11 +220,17 @@ final class StructureDefinitionEditor: ObservableObject { } } + // A filter that hides the definition is cleared first, or the selection would land on nothing. func reveal(section: StructureSection, entity id: UUID) { if collapsedSections.contains(section) { setExpanded(section, true) } - adapter(for: section).select(entity: id) + let adapter = adapter(for: section) + if adapter.model.displayIndex(of: adapter.rowID(forEntity: id)) == nil { + if !searchText.isEmpty { searchText = "" } + if adapter.store.valueFilter.isActive { adapter.model.setValueFilter(GridValueFilterState()) } + } + adapter.select(entity: id) } // MARK: - Commands diff --git a/apple/TableProTests/Core/SchemaTracking/PrimaryKeyStatementOrderTests.swift b/apple/TableProTests/Core/SchemaTracking/PrimaryKeyStatementOrderTests.swift new file mode 100644 index 0000000000..d2ee826e1c --- /dev/null +++ b/apple/TableProTests/Core/SchemaTracking/PrimaryKeyStatementOrderTests.swift @@ -0,0 +1,71 @@ +// +// PrimaryKeyStatementOrderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class KeyDDLDriver: PluginDriverStub, @unchecked Sendable { + var isConnected: Bool { true } + var currentSchema: String? { "public" } + + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + "ALTER TABLE \(table) DROP COLUMN \(columnName)" + } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + "ALTER TABLE \(table) ADD COLUMN \(column.name)" + } + + func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { + var statements: [String] = [] + if !oldColumns.isEmpty { statements.append("ALTER TABLE \(table) DROP CONSTRAINT \(constraintName ?? "pk")") } + if !newColumns.isEmpty { statements.append("ALTER TABLE \(table) ADD PRIMARY KEY (\(newColumns.joined(separator: ", ")))") } + return statements + } +} + +struct PrimaryKeyStatementOrderTests { + private func column(_ name: String) -> EditableColumnDefinition { + EditableColumnDefinition.from(ColumnInfo(name: name, dataType: "INT", isNullable: false, isPrimaryKey: false)) + } + + // Dropping a key column drops the key with it, so the key's own DROP has to run first. + @Test("A key that loses a dropped column is dropped before the column and added after the new columns") + func keyDropRunsBeforeTheColumnDrop() throws { + let statements = try SchemaStatementGenerator(tableName: "orders", pluginDriver: KeyDDLDriver()) + .generate(changes: [ + .deleteColumn(column("a")), + .addColumn(column("c")), + .modifyPrimaryKey(old: ["a"], new: ["b", "c"]), + ]) + .map(\.sql) + + #expect(statements == [ + "ALTER TABLE orders DROP CONSTRAINT pk;", + "ALTER TABLE orders DROP COLUMN a;", + "ALTER TABLE orders ADD COLUMN c;", + "ALTER TABLE orders ADD PRIMARY KEY (b, c);", + ]) + } + + @Test("A key change that drops no key column stays one change after the columns") + func keyChangeWithoutDroppedColumnStaysWhole() throws { + let statements = try SchemaStatementGenerator(tableName: "orders", pluginDriver: KeyDDLDriver()) + .generate(changes: [.modifyPrimaryKey(old: ["a"], new: ["a", "b"])]) + .map(\.sql) + + #expect(statements == [ + "ALTER TABLE orders DROP CONSTRAINT pk;", + "ALTER TABLE orders ADD PRIMARY KEY (a, b);", + ]) + } +} diff --git a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift index 8288461bb6..0f775263bf 100644 --- a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift @@ -236,4 +236,30 @@ struct StructureChangeManagerStagingTests { #expect(manager.workingPrimaryKey == ["a", "b", "c"]) } + + @Test("Deleting the old key column, then marking another, stages a key that names only surviving columns") + func deleteThenMarkStagesSurvivingKey() { + let manager = loadedManager() + manager.deleteColumn(id: column("id", in: manager).id) + var email = column("email", in: manager) + email.isPrimaryKey = true + manager.updateColumn(id: email.id, with: email) + + #expect(manager.workingPrimaryKey == ["email"]) + #expect(manager.getChangesArray().contains(.modifyPrimaryKey(old: ["id"], new: ["email"]))) + #expect(manager.canCommit) + } + + @Test("Renaming a key column without changing the key stages no key change") + func renameOfKeyColumnStagesNoKeyChange() { + let manager = loadedManager() + var id = column("id", in: manager) + id.name = "order_id" + manager.updateColumn(id: id.id, with: id) + manager.deleteColumn(id: id.id) + manager.undoDelete(section: .columns, id: id.id) + + #expect(!manager.getChangesArray().contains { if case .modifyPrimaryKey = $0 { true } else { false } }) + #expect(manager.workingPrimaryKey == ["order_id"]) + } } diff --git a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift index dba42e4984..71f341da2d 100644 --- a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift +++ b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift @@ -119,6 +119,18 @@ struct StructureDefinitionEditorTests { #expect(editor.inspectorTarget == .entity(.columns, added)) } + @Test("Revealing a definition the filter hides clears the filter and selects it") + func revealClearsAHidingFilter() { + let editor = makeEditor() + editor.searchText = "stat" + let id = id(of: "customer_id", in: editor) + + editor.reveal(section: .columns, entity: id) + + #expect(editor.searchText.isEmpty) + #expect(editor.inspectorTarget == .entity(.columns, id)) + } + @Test("A collapsed section stays collapsed for the next table") func collapsedSectionsPersist() { let editor = makeEditor() From 01be5161fd44163e6e036c9f31801f7709371fcc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 17:20:17 +0700 Subject: [PATCH 6/9] fix(structure): leave a key whose columns are all dropped to the database --- .../SchemaStatementGenerator.swift | 8 ++++--- .../StructureChangeManager.swift | 5 +++- .../PrimaryKeyStatementOrderTests.swift | 23 +++++++++++++++---- .../StructureChangeManagerStagingTests.swift | 8 +++++++ 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 274be172fa..c7abd17fd1 100644 --- a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -183,8 +183,8 @@ struct SchemaStatementGenerator { + constraintAdds + commentChanges } - // Dropping a key column takes the key with it on PostgreSQL and MySQL, so the key's own DROP would then fail: a key - // that loses a dropped column is dropped before the columns and added back after the columns are added. + // Dropping a key column takes the key with it, so the key's own DROP would then fail. A key whose every column is + // dropped is left to the database and only the new key is added; one that keeps some is dropped first. private static func keyDropsBeforeColumnDrops( _ keyChanges: [SchemaChange], columnDeletes: [SchemaChange] @@ -200,7 +200,9 @@ struct SchemaStatementGenerator { adds.append(change) continue } - drops.append(.modifyPrimaryKey(old: old, new: [])) + if !old.allSatisfy(dropped.contains) { + drops.append(.modifyPrimaryKey(old: old, new: [])) + } if !new.isEmpty { adds.append(.modifyPrimaryKey(old: [], new: new)) } diff --git a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 28f7c413dc..e4111892b5 100644 --- a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -1073,7 +1073,10 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { let ordered = loadedIDs.compactMap { id in members.first { $0.id == id } } + members.filter { !loadedIDs.contains($0.id) } workingPrimaryKey = ordered.map(\.name) - if ordered.map(\.id) == loadedIDs { + // A key that only loses columns being dropped needs no statement: the database takes them out with the column, + // and on MySQL an AUTO_INCREMENT key cannot be dropped before its column. + let survivingLoadedIDs = loadedIDs.filter { !isPendingDeletion(.column($0)) } + if ordered.map(\.id) == loadedIDs || ordered.map(\.id) == survivingLoadedIDs { pendingChanges.removeValue(forKey: key) untrackChangeKey(key) } else { diff --git a/apple/TableProTests/Core/SchemaTracking/PrimaryKeyStatementOrderTests.swift b/apple/TableProTests/Core/SchemaTracking/PrimaryKeyStatementOrderTests.swift index d2ee826e1c..d79b454133 100644 --- a/apple/TableProTests/Core/SchemaTracking/PrimaryKeyStatementOrderTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/PrimaryKeyStatementOrderTests.swift @@ -38,9 +38,9 @@ struct PrimaryKeyStatementOrderTests { EditableColumnDefinition.from(ColumnInfo(name: name, dataType: "INT", isNullable: false, isPrimaryKey: false)) } - // Dropping a key column drops the key with it, so the key's own DROP has to run first. - @Test("A key that loses a dropped column is dropped before the column and added after the new columns") - func keyDropRunsBeforeTheColumnDrop() throws { + // The column's drop takes the key with it, and on MySQL an AUTO_INCREMENT key cannot be dropped ahead of its column. + @Test("A key whose every column is dropped is left to the drop, and the new key is added after the columns") + func fullyDroppedKeyIsNotDroppedAgain() throws { let statements = try SchemaStatementGenerator(tableName: "orders", pluginDriver: KeyDDLDriver()) .generate(changes: [ .deleteColumn(column("a")), @@ -50,13 +50,28 @@ struct PrimaryKeyStatementOrderTests { .map(\.sql) #expect(statements == [ - "ALTER TABLE orders DROP CONSTRAINT pk;", "ALTER TABLE orders DROP COLUMN a;", "ALTER TABLE orders ADD COLUMN c;", "ALTER TABLE orders ADD PRIMARY KEY (b, c);", ]) } + @Test("A key that keeps some columns is dropped before a dropped member goes") + func partlyDroppedKeyIsDroppedFirst() throws { + let statements = try SchemaStatementGenerator(tableName: "orders", pluginDriver: KeyDDLDriver()) + .generate(changes: [ + .deleteColumn(column("a")), + .modifyPrimaryKey(old: ["a", "b"], new: ["b", "c"]), + ]) + .map(\.sql) + + #expect(statements == [ + "ALTER TABLE orders DROP CONSTRAINT pk;", + "ALTER TABLE orders DROP COLUMN a;", + "ALTER TABLE orders ADD PRIMARY KEY (b, c);", + ]) + } + @Test("A key change that drops no key column stays one change after the columns") func keyChangeWithoutDroppedColumnStaysWhole() throws { let statements = try SchemaStatementGenerator(tableName: "orders", pluginDriver: KeyDDLDriver()) diff --git a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift index 0f775263bf..257d6dbda8 100644 --- a/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift +++ b/apple/TableProTests/Core/SchemaTracking/StructureChangeManagerStagingTests.swift @@ -262,4 +262,12 @@ struct StructureChangeManagerStagingTests { #expect(!manager.getChangesArray().contains { if case .modifyPrimaryKey = $0 { true } else { false } }) #expect(manager.workingPrimaryKey == ["order_id"]) } + + @Test("Deleting the key column alone stages no key change, as the database drops it with the column") + func deletingKeyColumnAloneStagesNoKeyChange() { + let manager = loadedManager() + manager.deleteColumn(id: column("id", in: manager).id) + + #expect(!manager.getChangesArray().contains { if case .modifyPrimaryKey = $0 { true } else { false } }) + } } From a395b9cbe88fcc70bf2715d0944290c7d2c7e89a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 17:33:13 +0700 Subject: [PATCH 7/9] fix(structure): keep an edited definition listed under the filter and follow the save hold --- .../Structure/StructureDefinitionEditor.swift | 8 ++++++ .../Structure/StructureRowProvider.swift | 10 ++++--- .../Structure/StructureSectionAdapter.swift | 5 +++- .../StructureDefinitionEditorTests.swift | 26 +++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift index 2ab4b64a07..5aa05e0478 100644 --- a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift +++ b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift @@ -99,6 +99,14 @@ final class StructureDefinitionEditor: ObservableObject { .receive(on: RunLoop.main) .sink { [weak self] _ in self?.refresh() } .store(in: &cancellables) + // A save holds every edit while it runs; the grids close on the hold and open again when it ends. + manager.$heldSave + .map { $0 != nil } + .removeDuplicates() + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.refresh() } + .store(in: &cancellables) } static let collapsedSectionsKey = "structure.collapsedSections" diff --git a/apple/TablePro/Views/Structure/StructureRowProvider.swift b/apple/TablePro/Views/Structure/StructureRowProvider.swift index 7258b82d77..784b0f3da2 100644 --- a/apple/TablePro/Views/Structure/StructureRowProvider.swift +++ b/apple/TablePro/Views/Structure/StructureRowProvider.swift @@ -168,7 +168,8 @@ final class StructureRowProvider { serverSupport: StructureServerSupport, filterText: String? = nil, sortDescriptor: StructureSortDescriptor? = nil, - picksCharacterSets: Bool = false + picksCharacterSets: Bool = false, + keepingSourceIndices: Set = [] ) { self.changeManager = changeManager self.tab = tab @@ -188,7 +189,7 @@ final class StructureRowProvider { tab: tab, changeManager: changeManager, orderedColumnFields: self.orderedColumnFields, databaseType: databaseType ) self.cachedRows = Self.applyFilterAndSort( - allRows, filterText: filterText, sortDescriptor: sortDescriptor + allRows, filterText: filterText, sortDescriptor: sortDescriptor, keeping: keepingSourceIndices ) } @@ -353,15 +354,18 @@ final class StructureRowProvider { ] } + // `keeping` stays listed whatever the filter says: a definition being edited never vanishes under the editor. private static func applyFilterAndSort( _ rows: [IndexedRow], filterText: String?, - sortDescriptor: StructureSortDescriptor? + sortDescriptor: StructureSortDescriptor?, + keeping: Set = [] ) -> [IndexedRow] { var result = rows if let filterText, !filterText.isEmpty { result = result.filter { indexed in + if keeping.contains(indexed.sourceIndex) { return true } guard let name = indexed.row.first ?? nil else { return false } return name.localizedCaseInsensitiveContains(filterText) } diff --git a/apple/TablePro/Views/Structure/StructureSectionAdapter.swift b/apple/TablePro/Views/Structure/StructureSectionAdapter.swift index bfe5d2de88..fb6f4082a3 100644 --- a/apple/TablePro/Views/Structure/StructureSectionAdapter.swift +++ b/apple/TablePro/Views/Structure/StructureSectionAdapter.swift @@ -137,6 +137,8 @@ final class StructureSectionAdapter { let entityIDs = StructureGridEdits.entityIDs(in: section, manager: manager) let rowIDs = entityIDs.map { identities.rowID(for: $0) } changeTracking.rowIDs = rowIDs + let selected = Set(selectedEntityIDs) + let keptIndices = Set(entityIDs.indices.filter { selected.contains(entityIDs[$0]) }) let provider = StructureRowProvider( changeManager: manager, @@ -146,7 +148,8 @@ final class StructureSectionAdapter { serverSupport: serverSupport, filterText: searchText.isEmpty ? nil : searchText, sortDescriptor: sortDescriptor, - picksCharacterSets: ColumnCharacterSetMenus.offersMenus(characterSetContext.catalog) + picksCharacterSets: ColumnCharacterSetMenus.offersMenus(characterSetContext.catalog), + keepingSourceIndices: keptIndices ) self.provider = provider let rows = provider.asTableRows { rowIDs.indices.contains($0) ? rowIDs[$0] : .existing(-1) } diff --git a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift index 71f341da2d..f4be9a4a16 100644 --- a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift +++ b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import TableProPluginKit +import TableProTestSupport import Testing @testable import TablePro @@ -131,6 +132,31 @@ struct StructureDefinitionEditorTests { #expect(editor.inspectorTarget == .entity(.columns, id)) } + @Test("A definition renamed out of the filter stays listed and selected while it is edited") + func renamedDefinitionStaysUnderTheFilter() { + let editor = makeEditor() + editor.searchText = "stat" + let status = id(of: "status", in: editor) + editor.adapter(for: .columns).select(entity: status) + + editor.typeInspectorField(section: .columns, id: status, field: 0, value: "phase") + + #expect(editor.inspectorTarget == .entity(.columns, status)) + #expect(editor.adapter(for: .columns).visibleRowCount == 1) + } + + @Test("A save's hold closes the grids and its end opens them again") + func saveHoldTogglesEditing() async throws { + let editor = makeEditor() + editor.manager.deleteColumn(id: id(of: "status", in: editor)) + let hold = try #require(editor.manager.holdForSave()) + try await waitUntil { !editor.adapter(for: .columns).model.configuration.isEditable } + + _ = editor.manager.releaseHold(hold, written: false) + + try await waitUntil { editor.adapter(for: .columns).model.configuration.isEditable } + } + @Test("A collapsed section stays collapsed for the next table") func collapsedSectionsPersist() { let editor = makeEditor() From 065541e2bbb547970bb70b8b3a33350ed718e737 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 17:45:14 +0700 Subject: [PATCH 8/9] fix(structure): scroll a revealed definition into view --- .../Core/SchemaTracking/SchemaStatementGenerator.swift | 8 ++++++-- .../Views/Structure/StructureDefinitionEditor.swift | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index c7abd17fd1..73082f022b 100644 --- a/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/apple/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -200,9 +200,13 @@ struct SchemaStatementGenerator { adds.append(change) continue } - if !old.allSatisfy(dropped.contains) { - drops.append(.modifyPrimaryKey(old: old, new: [])) + if old.allSatisfy(dropped.contains) { + if !new.isEmpty { + adds.append(.modifyPrimaryKey(old: [], new: new)) + } + continue } + drops.append(.modifyPrimaryKey(old: old, new: [])) if !new.isEmpty { adds.append(.modifyPrimaryKey(old: [], new: new)) } diff --git a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift index 5aa05e0478..545560b2fc 100644 --- a/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift +++ b/apple/TablePro/Views/Structure/StructureDefinitionEditor.swift @@ -239,6 +239,9 @@ final class StructureDefinitionEditor: ObservableObject { if adapter.store.valueFilter.isActive { adapter.model.setValueFilter(GridValueFilterState()) } } adapter.select(entity: id) + // The section may have just expanded, so its grid scrolls the page once it is on screen. + let rowID = adapter.rowID(forEntity: id) + DispatchQueue.main.async { adapter.model.reveal(rowID: rowID) } } // MARK: - Commands From 57b95ed1642a330393276e2280d070e69b515e3a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 18:07:19 +0700 Subject: [PATCH 9/9] fix(structure): keep the selected definition across a reload --- .../StructureChangeManager.swift | 29 +++++++++++++++++ .../StructureDefinitionEditorTests.swift | 31 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift index e4111892b5..68e54b12b5 100644 --- a/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/apple/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -183,6 +183,7 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { self.currentForeignKeys = EditableForeignKeyDefinition.grouping(foreignKeys).sorted { $0.name < $1.name } self.currentCheckConstraints = checkConstraints.map { EditableCheckConstraintDefinition.from($0) } self.currentPrimaryKey = primaryKey + keepIdentitiesAcrossReload() resetWorkingState() @@ -197,6 +198,34 @@ final class StructureChangeManager: ObservableObject, ChangeManaging { reloadVersion += 1 } + // Every fetch mints fresh ids; a definition that comes back under a name it had keeps its id, so the grid + // selection and the inspector survive a refresh or a save. + private func keepIdentitiesAcrossReload() { + currentColumns = Self.keepingIDs(of: currentColumns, from: workingColumns, \.name, \.id) + currentIndexes = Self.keepingIDs(of: currentIndexes, from: workingIndexes, \.name, \.id) + currentForeignKeys = Self.keepingIDs(of: currentForeignKeys, from: workingForeignKeys, \.name, \.id) + currentCheckConstraints = Self.keepingIDs(of: currentCheckConstraints, from: workingCheckConstraints, \.name, \.id) + } + + private static func keepingIDs( + of loaded: [Entity], + from earlier: [Entity], + _ name: KeyPath, + _ id: WritableKeyPath + ) -> [Entity] { + var ids: [String: UUID] = [:] + for entity in earlier where !entity[keyPath: name].isEmpty && ids[entity[keyPath: name]] == nil { + ids[entity[keyPath: name]] = entity[keyPath: id] + } + var used = Set() + return loaded.map { entity in + guard let previous = ids[entity[keyPath: name]], used.insert(previous).inserted else { return entity } + var kept = entity + kept[keyPath: id] = previous + return kept + } + } + private func resetWorkingState() { workingColumns = currentColumns workingIndexes = currentIndexes diff --git a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift index f4be9a4a16..01f56ac935 100644 --- a/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift +++ b/apple/TableProTests/Views/Structure/StructureDefinitionEditorTests.swift @@ -145,6 +145,37 @@ struct StructureDefinitionEditorTests { #expect(editor.adapter(for: .columns).visibleRowCount == 1) } + @Test("A reload keeps the selected definition, under the name the save gave it") + func reloadKeepsTheSelection() async throws { + let editor = makeEditor() + let status = id(of: "status", in: editor) + let index = editor.manager.workingIndexes[0].id + editor.adapter(for: .columns).select(entity: status) + editor.commitInspectorField(section: .columns, id: status, field: 0, value: "phase") + let version = editor.manager.reloadVersion + + editor.manager.loadSchema( + tableName: "orders", + columns: [ + ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "customer_id", dataType: "INT", isNullable: false, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil), + ColumnInfo(name: "phase", dataType: "TEXT", isNullable: true, isPrimaryKey: false, + defaultValue: nil, extra: nil, charset: nil, collation: nil, comment: nil) + ], + indexes: [IndexInfo(name: "orders_pkey", columns: ["id"], isUnique: true, isPrimary: true, type: "BTREE")], + foreignKeys: [], + primaryKey: ["id"] + ) + try await waitUntil { editor.manager.reloadVersion > version && editor.adapter(for: .columns).rowCount == 3 } + + #expect(id(of: "phase", in: editor) == status) + #expect(editor.manager.workingIndexes[0].id == index) + #expect(Set(editor.manager.workingColumns.map(\.id)).count == 3) + #expect(editor.inspectorTarget == .entity(.columns, status)) + } + @Test("A save's hold closes the grids and its end opens them again") func saveHoldTogglesEditing() async throws { let editor = makeEditor()