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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Models/Connection/ConnectionToolbarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ extension MainContentCoordinator {
|| tab.hasUserActiveSort {
return false
}
if tab.tabType == .createTable { return !toolbarState.hasCreateTablePending }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Protect incomplete create-table drafts before reuse

When the selected Create Table tab is not yet ready to create, this returns true, so sidebar navigation replaces the tab in place. A user who has typed a table name or started a column but has not filled a valid name+type yet has hasCreateTablePending == false, and that draft is silently discarded when they click a table in the sidebar. Consider tracking draft dirtiness separately from create readiness, or only reusing truly empty create-table tabs.

Useful? React with 👍 / 👎.

if tab.isPreview { return true }
if tab.tabType == .query,
tab.execution.lastExecutedAt == nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,5 @@ struct PendingChangeTrigger: Equatable {
let pendingDeletes: Set<String>
let hasStructureChanges: Bool
let isFileDirty: Bool
let hasCreateTablePending: Bool
}
5 changes: 5 additions & 0 deletions TablePro/Views/Main/Extensions/MainContentView+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?()
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
11 changes: 11 additions & 0 deletions TablePro/Views/Structure/CreateTableActionHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// CreateTableActionHandler.swift
// TablePro
//

import Foundation

@MainActor
final class CreateTableActionHandler {
var createTable: (() -> Void)?
}
28 changes: 24 additions & 4 deletions TablePro/Views/Structure/CreateTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> = []
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand All @@ -350,6 +369,7 @@ struct CreateTableView: View {

isCreating = true
errorMessage = nil
updateCreateTablePendingState()

Task {
defer { isCreating = false }
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions TableProTests/Views/Main/CommandActionsDispatchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
43 changes: 43 additions & 0 deletions TableProTests/Views/Main/OpenTableTabTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
51 changes: 38 additions & 13 deletions TableProTests/Views/Main/TriggerStructTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
//

import Foundation
import TableProPluginKit
@testable import TablePro
import TableProPluginKit
import Testing

// MARK: - InspectorTrigger Tests
Expand Down Expand Up @@ -61,45 +61,70 @@ struct InspectorTriggerTests {

@Suite("PendingChangeTrigger")
struct PendingChangeTriggerTests {
private func makeTrigger(
hasDataChanges: Bool = false,
pendingTruncates: Set<String> = [],
pendingDeletes: Set<String> = [],
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)
}
}
1 change: 1 addition & 0 deletions docs/features/keyboard-shortcuts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/features/table-structure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Note>
Supported databases: MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, ClickHouse, and DuckDB. Each generates database-specific DDL syntax.
Expand Down
Loading