From 5685bdb1c4c4f8973d053d28bf72549f3a8e0abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 13:18:53 +0700 Subject: [PATCH] fix(editor): convert create-table tab in place on commit and wire Cmd+S --- CHANGELOG.md | 2 + .../Connection/ConnectionToolbarState.swift | 3 ++ .../MainContentCoordinator+Navigation.swift | 1 + .../Extensions/MainContentView+Bindings.swift | 1 + .../Extensions/MainContentView+Setup.swift | 5 ++ .../Main/MainContentCommandActions.swift | 4 ++ .../Views/Main/MainContentCoordinator.swift | 4 ++ TablePro/Views/Main/MainContentView.swift | 3 +- .../Structure/CreateTableActionHandler.swift | 11 ++++ .../Views/Structure/CreateTableView.swift | 28 ++++++++-- .../Main/CommandActionsDispatchTests.swift | 38 ++++++++++++++ .../Views/Main/OpenTableTabTests.swift | 43 ++++++++++++++++ .../Views/Main/TriggerStructTests.swift | 51 ++++++++++++++----- docs/features/keyboard-shortcuts.mdx | 1 + docs/features/table-structure.mdx | 2 +- 15 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 TablePro/Views/Structure/CreateTableActionHandler.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 982c41389..55ed39f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Creating a table now turns the Create Table tab into the new table's tab instead of leaving the creation tab open next to a duplicate, and the sidebar shows the new table without a manual refresh. (#1664) +- Cmd+S in the Create Table tab now creates the table, matching the Save shortcut everywhere else. (#1664) - Format Query can now be undone with Cmd+Z; the formatting is applied as a single editor edit instead of clearing the undo history. (#1645) - Format Query now formats only the selected text when a selection is active, and the full query when nothing is selected. (#1656) - Sorting a query result no longer overwrites the SQL editor text or the contents of an opened `.sql` file; the sort runs as a separate query and the editor keeps what you wrote. (#1645) diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 15ce5c401..451e92723 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -196,6 +196,9 @@ final class ConnectionToolbarState { /// Whether the structure view has pending schema changes var hasStructureChanges: Bool = false + /// Whether the Create Table tab has a committable definition (name + valid column) + var hasCreateTablePending: Bool = false + /// Whether the current editor has non-empty query text var hasQueryText: Bool = false diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 0f0458d7c..18a55de7a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -304,6 +304,7 @@ extension MainContentCoordinator { || tab.hasUserActiveSort { return false } + if tab.tabType == .createTable { return !toolbarState.hasCreateTablePending } if tab.isPreview { return true } if tab.tabType == .query, tab.execution.lastExecutedAt == nil, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 4466f8212..dbccc0b66 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -137,4 +137,5 @@ struct PendingChangeTrigger: Equatable { let pendingDeletes: Set let hasStructureChanges: Bool let isFileDirty: Bool + let hasCreateTablePending: Bool } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 3fa11a196..8ae574d37 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -170,6 +170,11 @@ extension MainContentView { // MARK: - Command Actions Setup func updateToolbarPendingState() { + if tabManager.selectedTab?.tabType == .createTable { + toolbarState.hasDataPendingChanges = false + toolbarState.hasPendingChanges = toolbarState.hasCreateTablePending + return + } let hasDataChanges = changeManager.hasChanges || !pendingTruncates.isEmpty diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 906bedec3..262ef3ded 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -584,6 +584,10 @@ final class MainContentCommandActions { // MARK: - Data Operations (Group A — Called Directly) func saveChanges() { + if coordinator?.tabManager.selectedTab?.tabType == .createTable { + coordinator?.createTableActions?.createTable?() + return + } // Check if we're in structure view mode if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator?.structureActions?.saveChanges?() diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 2803d8ab0..91c9bcfcb 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -117,6 +117,10 @@ final class MainContentCoordinator { /// Direct reference to structure view actions — eliminates notification broadcasts weak var structureActions: StructureViewActionHandler? + /// Direct reference to create-table view actions so the Save Changes menu + /// (Cmd+S) routes to table creation. Set by `CreateTableView` on appear. + weak var createTableActions: CreateTableActionHandler? + /// Published capability/labels for the structure-mode footer in the bottom status bar. /// `TableStructureView` writes to this; `MainStatusBarView` reads from it. let structureFooterState = StructureFooterState() diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 2cb67ada7..fa8e131c4 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -296,7 +296,8 @@ struct MainContentView: View { pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, hasStructureChanges: toolbarState.hasStructureChanges, - isFileDirty: tabManager.selectedTab?.content.isFileDirty ?? false + isFileDirty: tabManager.selectedTab?.content.isFileDirty ?? false, + hasCreateTablePending: toolbarState.hasCreateTablePending ) } diff --git a/TablePro/Views/Structure/CreateTableActionHandler.swift b/TablePro/Views/Structure/CreateTableActionHandler.swift new file mode 100644 index 000000000..0ccfad88e --- /dev/null +++ b/TablePro/Views/Structure/CreateTableActionHandler.swift @@ -0,0 +1,11 @@ +// +// CreateTableActionHandler.swift +// TablePro +// + +import Foundation + +@MainActor +final class CreateTableActionHandler { + var createTable: (() -> Void)? +} diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 60002ed2c..e505415f1 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -45,6 +45,7 @@ struct CreateTableView: View { @State private var showError = false @State private var previewSQL = "" @State private var gridDelegate: CreateTableGridDelegate + @State private var actionHandler = CreateTableActionHandler() // DataGridView state @State private var selectedRows: Set = [] @@ -85,10 +86,18 @@ struct CreateTableView: View { if structureChangeManager.workingColumns.isEmpty { structureChangeManager.addNewColumn() } + actionHandler.createTable = { createTable() } + coordinator?.createTableActions = actionHandler + coordinator?.toolbarState.hasCreateTablePending = isReadyToCreate + } + .onDisappear { + selectionState.indices = [] + coordinator?.createTableActions = nil + coordinator?.toolbarState.hasCreateTablePending = false } - .onDisappear { selectionState.indices = [] } .onChange(of: selectedRows) { _, newRows in selectionState.indices = newRows } .onChange(of: selectedTab) { updateGridDelegate() } + .onChange(of: isReadyToCreate) { updateCreateTablePendingState() } .alert(String(localized: "Create Table Failed"), isPresented: $showError) { Button("OK") {} } message: { @@ -340,8 +349,18 @@ struct CreateTableView: View { // MARK: - Create Table + private var isReadyToCreate: Bool { + !isCreating + && !tableName.isEmpty + && structureChangeManager.workingColumns.contains { !$0.name.isEmpty && !$0.dataType.isEmpty } + } + + private func updateCreateTablePendingState() { + coordinator?.toolbarState.hasCreateTablePending = isReadyToCreate + } + private func createTable() { - guard !tableName.isEmpty else { return } + guard !isCreating, !tableName.isEmpty else { return } guard let sql = buildCreateTableSQL() else { errorMessage = String(localized: "Add at least one column with a name and type") showError = true @@ -350,6 +369,7 @@ struct CreateTableView: View { isCreating = true errorMessage = nil + updateCreateTablePendingState() Task { defer { isCreating = false } @@ -389,11 +409,11 @@ struct CreateTableView: View { wasSuccessful: true ) - AppCommands.shared.refreshData.send(connection.id) - if let coordinator { coordinator.openTableTab(tableName) } + + AppCommands.shared.refreshData.send(connection.id) } catch { Self.logger.error("Create table failed: \(error.localizedDescription, privacy: .public)") errorMessage = error.localizedDescription diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index a69f959ff..d2cdb9b87 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -173,4 +173,42 @@ struct CommandActionsDispatchTests { #expect(removeRowCalled) } + + // MARK: - saveChanges (createTable tab) + + @Test("saveChanges dispatches createTableActions when the selected tab is createTable") + func saveChanges_createTableTab_callsCreateTableAction() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addCreateTableTab(databaseName: "testdb") + + let createHandler = CreateTableActionHandler() + var createCalled = false + createHandler.createTable = { createCalled = true } + coordinator.createTableActions = createHandler + + let handler = StructureViewActionHandler() + var structureSaveCalled = false + handler.saveChanges = { structureSaveCalled = true } + coordinator.structureActions = handler + + actions.saveChanges() + + #expect(createCalled) + #expect(!structureSaveCalled) + } + + @Test("saveChanges without createTableActions is a no-op for a createTable tab") + func saveChanges_createTableTab_withoutAction_doesNothing() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addCreateTableTab(databaseName: "testdb") + + let handler = StructureViewActionHandler() + var structureSaveCalled = false + handler.saveChanges = { structureSaveCalled = true } + coordinator.structureActions = handler + + actions.saveChanges() + + #expect(!structureSaveCalled) + } } diff --git a/TableProTests/Views/Main/OpenTableTabTests.swift b/TableProTests/Views/Main/OpenTableTabTests.swift index 343bccd49..a05747804 100644 --- a/TableProTests/Views/Main/OpenTableTabTests.swift +++ b/TableProTests/Views/Main/OpenTableTabTests.swift @@ -82,6 +82,30 @@ struct OpenTableTabTests { #expect(tabManager.selectedTab?.filterState.isVisible == false) } + @Test("openTableTab converts a createTable tab in place after the table is created") + @MainActor + func convertsCreateTableTabInPlace() { + let connection = TestFixtures.makeConnection(database: "db_a") + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + tabManager.addCreateTableTab(databaseName: "db_a") + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.tabType == .createTable) + + coordinator.openTableTab("users") + + #expect(tabManager.tabs.count == 1) + #expect(tabManager.selectedTab?.tabType == .table) + #expect(tabManager.selectedTab?.tableContext.tableName == "users") + } + @Test("Clicking the active table again is a no-op") @MainActor func clickingActiveTableAgainIsNoOp() throws { @@ -125,6 +149,25 @@ struct OpenTableTabTests { #expect(coordinator.isActiveTabReusable == false) } + @Test("A createTable tab without a committable design is reusable") + @MainActor + func createTableTabIsReusable() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addCreateTableTab(databaseName: "db") + #expect(coordinator.isActiveTabReusable == true) + } + + @Test("A createTable tab with a committable design is protected and not reusable") + @MainActor + func createTableTabWithPendingDesignIsNotReusable() { + let coordinator = Self.makeCoordinator() + defer { coordinator.teardown() } + coordinator.tabManager.addCreateTableTab(databaseName: "db") + coordinator.toolbarState.hasCreateTablePending = true + #expect(coordinator.isActiveTabReusable == false) + } + @Test("A blank query tab is reusable") @MainActor func blankQueryTabIsReusable() { diff --git a/TableProTests/Views/Main/TriggerStructTests.swift b/TableProTests/Views/Main/TriggerStructTests.swift index df517bef7..ac8b40225 100644 --- a/TableProTests/Views/Main/TriggerStructTests.swift +++ b/TableProTests/Views/Main/TriggerStructTests.swift @@ -6,8 +6,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing // MARK: - InspectorTrigger Tests @@ -61,45 +61,70 @@ struct InspectorTriggerTests { @Suite("PendingChangeTrigger") struct PendingChangeTriggerTests { + private func makeTrigger( + hasDataChanges: Bool = false, + pendingTruncates: Set = [], + pendingDeletes: Set = [], + hasStructureChanges: Bool = false, + isFileDirty: Bool = false, + hasCreateTablePending: Bool = false + ) -> PendingChangeTrigger { + PendingChangeTrigger( + hasDataChanges: hasDataChanges, + pendingTruncates: pendingTruncates, + pendingDeletes: pendingDeletes, + hasStructureChanges: hasStructureChanges, + isFileDirty: isFileDirty, + hasCreateTablePending: hasCreateTablePending + ) + } + @Test("Same values are equal") func sameValuesAreEqual() { - let a = PendingChangeTrigger(hasDataChanges: true, pendingTruncates: ["t1"], pendingDeletes: ["t2"], hasStructureChanges: false, isFileDirty: false) - let b = PendingChangeTrigger(hasDataChanges: true, pendingTruncates: ["t1"], pendingDeletes: ["t2"], hasStructureChanges: false, isFileDirty: false) + let a = makeTrigger(hasDataChanges: true, pendingTruncates: ["t1"], pendingDeletes: ["t2"]) + let b = makeTrigger(hasDataChanges: true, pendingTruncates: ["t1"], pendingDeletes: ["t2"]) #expect(a == b) } @Test("Empty sets are equal") func emptySetsAreEqual() { - let a = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: [], pendingDeletes: [], hasStructureChanges: false, isFileDirty: false) - let b = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: [], pendingDeletes: [], hasStructureChanges: false, isFileDirty: false) + let a = makeTrigger() + let b = makeTrigger() #expect(a == b) } @Test("Different hasDataChanges produces unequal triggers") func differentHasDataChanges() { - let a = PendingChangeTrigger(hasDataChanges: true, pendingTruncates: [], pendingDeletes: [], hasStructureChanges: false, isFileDirty: false) - let b = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: [], pendingDeletes: [], hasStructureChanges: false, isFileDirty: false) + let a = makeTrigger(hasDataChanges: true) + let b = makeTrigger(hasDataChanges: false) #expect(a != b) } @Test("Different pendingTruncates produces unequal triggers") func differentPendingTruncates() { - let a = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: ["t1"], pendingDeletes: [], hasStructureChanges: false, isFileDirty: false) - let b = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: ["t2"], pendingDeletes: [], hasStructureChanges: false, isFileDirty: false) + let a = makeTrigger(pendingTruncates: ["t1"]) + let b = makeTrigger(pendingTruncates: ["t2"]) #expect(a != b) } @Test("Different pendingDeletes produces unequal triggers") func differentPendingDeletes() { - let a = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: [], pendingDeletes: ["d1"], hasStructureChanges: false, isFileDirty: false) - let b = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: [], pendingDeletes: ["d2"], hasStructureChanges: false, isFileDirty: false) + let a = makeTrigger(pendingDeletes: ["d1"]) + let b = makeTrigger(pendingDeletes: ["d2"]) #expect(a != b) } @Test("Different hasStructureChanges produces unequal triggers") func differentHasStructureChanges() { - let a = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: [], pendingDeletes: [], hasStructureChanges: true, isFileDirty: false) - let b = PendingChangeTrigger(hasDataChanges: false, pendingTruncates: [], pendingDeletes: [], hasStructureChanges: false, isFileDirty: false) + let a = makeTrigger(hasStructureChanges: true) + let b = makeTrigger(hasStructureChanges: false) + #expect(a != b) + } + + @Test("Different hasCreateTablePending produces unequal triggers") + func differentHasCreateTablePending() { + let a = makeTrigger(hasCreateTablePending: true) + let b = makeTrigger(hasCreateTablePending: false) #expect(a != b) } } diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index c56cc6fd1..ffaa96769 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -128,6 +128,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Undo change | `Cmd+Z` | | Redo change | `Cmd+Shift+Z` | | Commit all changes | `Cmd+S` | +| Create table from the Create Table tab | `Cmd+S` or `Cmd+Return` | | Preview SQL | `Cmd+Shift+P` | Preview pending SQL before commit | ### Selection diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 9793f8c3b..2f9fdfa85 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -167,7 +167,7 @@ Right-click in the sidebar and select **Create New Table...**. A visual editor o - **Foreign Keys tab** - define relationships with referenced tables, ON DELETE/ON UPDATE actions - **SQL Preview tab** - live-generated CREATE TABLE DDL with syntax highlighting -Click **Create Table** (or `Cmd+Return`) to execute. The new table appears in the sidebar immediately. +Click **Create Table** (or press `Cmd+S` or `Cmd+Return`) to execute. The tab becomes the new table's data view and the table appears in the sidebar immediately. Supported databases: MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, ClickHouse, and DuckDB. Each generates database-specific DDL syntax.