Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Create Database dialog showing MySQL charset/collation options for all database types; now shows database-specific options (encoding/LC_COLLATE for PostgreSQL, hidden for Redis/etcd)
- SSH Tunnel not working with `~/.ssh/config` profiles (#672): added `Include` directive support, SSH token expansion (`%d`, `%h`, `%u`, `%r`), multi-word `Host` filtering, and detailed handshake error messages

## [0.30.1] - 2026-04-10
Expand Down
4 changes: 3 additions & 1 deletion Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
func createDatabase(name: String, charset: String, collation: String?) async throws {
let escapedName = name.replacingOccurrences(of: "`", with: "``")

let validCharsets = ["utf8mb4", "utf8", "latin1", "ascii"]
let validCharsets = ["utf8mb4", "utf8mb3", "utf8", "latin1", "ascii",
"binary", "utf16", "utf32", "cp1251", "big5",
"euckr", "gb2312", "gbk", "sjis"]
guard validCharsets.contains(charset) else {
throw MariaDBPluginError(code: 0, message: "Invalid character set: \(charset)", sqlState: nil)
}
Expand Down
3 changes: 2 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,8 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

func createDatabase(name: String, charset: String, collation: String?) async throws {
let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"")
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII"]
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP",
"EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK"]
let normalizedCharset = charset.uppercased()
guard validCharsets.contains(normalizedCharset) else {
throw LibPQPluginError(message: "Invalid encoding: \(charset)", sqlState: nil, detail: nil)
Expand Down
3 changes: 2 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,8 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

func createDatabase(name: String, charset: String, collation: String?) async throws {
let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"")
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII"]
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP",
"EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK"]
let normalizedCharset = charset.uppercased()
guard validCharsets.contains(normalizedCharset) else {
throw LibPQPluginError(message: "Invalid encoding: \(charset)", sqlState: nil, detail: nil)
Expand Down
70 changes: 70 additions & 0 deletions TablePro/Models/Schema/CreateDatabaseOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// CreateDatabaseOptions.swift
// TablePro
//
// Database-type-specific options for CREATE DATABASE dialog.
//

import Foundation

struct CreateDatabaseOptions {
struct Config {
let charsetLabel: String
let collationLabel: String
let defaultCharset: String
let defaultCollation: String
let charsets: [String]
let collations: [String: [String]]
let showOptions: Bool
}

static func config(for type: DatabaseType) -> Config {
if type == .mysql || type == .mariadb {
return Config(
charsetLabel: "Character Set",
collationLabel: "Collation",
defaultCharset: "utf8mb4",
defaultCollation: "utf8mb4_unicode_ci",
charsets: CreateTableOptions.charsets,
collations: CreateTableOptions.collations,
showOptions: true
)
} else if type == .postgresql || type == .redshift {
return Config(
charsetLabel: "Encoding",
collationLabel: "LC_COLLATE",
defaultCharset: "UTF8",
defaultCollation: "en_US.UTF-8",
charsets: postgresqlEncodings,
collations: postgresqlLocales,
showOptions: true
)
} else {
return Config(
charsetLabel: "",
collationLabel: "",
defaultCharset: "",
defaultCollation: "",
charsets: [],
collations: [:],
showOptions: false
)
}
}

private static let postgresqlEncodings = [
"UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP",
"EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK"
]

// PostgreSQL LC_COLLATE is OS-locale based, not encoding-dependent
private static let localeOptions = ["en_US.UTF-8", "C", "POSIX", "C.UTF-8"]

private static let postgresqlLocales: [String: [String]] = {
var result: [String: [String]] = [:]
for enc in postgresqlEncodings {
result[enc] = localeOptions
}
return result
}()
}
86 changes: 44 additions & 42 deletions TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,25 @@ import SwiftUI
struct CreateDatabaseSheet: View {
@Environment(\.dismiss) private var dismiss

let databaseType: DatabaseType
let onCreate: (String, String, String?) async throws -> Void

@State private var databaseName = ""
@State private var charset = "utf8mb4"
@State private var collation = "utf8mb4_unicode_ci"
@State private var charset: String
@State private var collation: String
@State private var isCreating = false
@State private var errorMessage: String?

private let charsets = [
"utf8mb4",
"utf8",
"latin1",
"ascii"
]
private let config: CreateDatabaseOptions.Config

private let collations: [String: [String]] = [
"utf8mb4": ["utf8mb4_unicode_ci", "utf8mb4_general_ci", "utf8mb4_bin"],
"utf8": ["utf8_unicode_ci", "utf8_general_ci", "utf8_bin"],
"latin1": ["latin1_swedish_ci", "latin1_general_ci", "latin1_bin"],
"ascii": ["ascii_general_ci", "ascii_bin"]
]
init(databaseType: DatabaseType, onCreate: @escaping (String, String, String?) async throws -> Void) {
self.databaseType = databaseType
self.onCreate = onCreate
let cfg = CreateDatabaseOptions.config(for: databaseType)
self.config = cfg
self._charset = State(initialValue: cfg.defaultCharset)
self._collation = State(initialValue: cfg.defaultCollation)
}

var body: some View {
VStack(spacing: 0) {
Expand All @@ -54,36 +52,38 @@ struct CreateDatabaseSheet: View {
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
}

// Charset
VStack(alignment: .leading, spacing: 6) {
Text("Character Set")
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium))
.foregroundStyle(.secondary)

Picker("", selection: $charset) {
ForEach(charsets, id: \.self) { cs in
Text(cs).tag(cs)
if config.showOptions {
// Charset / Encoding
VStack(alignment: .leading, spacing: 6) {
Text(config.charsetLabel)
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium))
.foregroundStyle(.secondary)

Picker("", selection: $charset) {
ForEach(config.charsets, id: \.self) { cs in
Text(cs).tag(cs)
}
}
.labelsHidden()
.pickerStyle(.menu)
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
}
.labelsHidden()
.pickerStyle(.menu)
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
}

// Collation
VStack(alignment: .leading, spacing: 6) {
Text("Collation")
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium))
.foregroundStyle(.secondary)
// Collation / LC_COLLATE
VStack(alignment: .leading, spacing: 6) {
Text(config.collationLabel)
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium))
.foregroundStyle(.secondary)

Picker("", selection: $collation) {
ForEach(collations[charset] ?? [], id: \.self) { col in
Text(col).tag(col)
Picker("", selection: $collation) {
ForEach(config.collations[charset] ?? [], id: \.self) { col in
Text(col).tag(col)
}
}
.labelsHidden()
.pickerStyle(.menu)
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
}
.labelsHidden()
.pickerStyle(.menu)
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
}

// Error message
Expand Down Expand Up @@ -116,14 +116,12 @@ struct CreateDatabaseSheet: View {
}
.frame(width: 380)
.onExitCommand {
// Prevent dismissing the sheet via ESC while a database is being created
if !isCreating {
dismiss()
}
}
.onChange(of: charset) { _, newCharset in
// Update collation when charset changes
if let firstCollation = collations[newCharset]?.first {
if let firstCollation = config.collations[newCharset]?.first {
collation = firstCollation
}
}
Expand All @@ -137,7 +135,11 @@ struct CreateDatabaseSheet: View {

Task {
do {
try await onCreate(databaseName, charset, collation)
if config.showOptions {
try await onCreate(databaseName, charset, collation)
} else {
try await onCreate(databaseName, "", nil)
}
await MainActor.run {
dismiss()
}
Expand Down
6 changes: 4 additions & 2 deletions TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ struct DatabaseSwitcherSheet: View {
.defaultFocus($focus, .search)
.task { await viewModel.fetchDatabases() }
.sheet(isPresented: $showCreateDialog) {
CreateDatabaseSheet { name, charset, collation in
CreateDatabaseSheet(databaseType: databaseType) { name, charset, collation in
try await viewModel.createDatabase(
name: name, charset: charset, collation: collation)
await viewModel.refreshDatabases()
Expand Down Expand Up @@ -174,7 +174,9 @@ struct DatabaseSwitcherSheet: View {
.help(String(localized: "Refresh database list"))

// Create (only for non-SQLite)
if databaseType != .sqlite && !isSchemaMode {
if databaseType != .sqlite && databaseType != .redis
&& databaseType != .etcd && !isSchemaMode
{
Button(action: { showCreateDialog = true }) {
Image(systemName: "plus")
.frame(width: 24, height: 24)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,8 @@ extension MainContentCoordinator {
}
AppSettingsStorage.shared.saveLastDatabase(database, for: connectionId)
await loadSchema()
reloadSidebar()
await schemaProvider.invalidateTables()
sidebarViewModel?.forceLoadTables()
} catch {
// Restore toolbar to previous database on failure
toolbarState.databaseName = previousDatabase
Expand Down
6 changes: 5 additions & 1 deletion TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,11 @@ final class MainContentCommandActions {
if let driver = DatabaseManager.shared.driver(for: self.connection.id) {
coordinator?.toolbarState.databaseVersion = driver.serverVersion
}
coordinator?.reloadSidebar()
// Skip sidebar reload during database switch — switchDatabase() handles it
// after schema invalidation to avoid flashing stale tables.
if coordinator?.isSwitchingDatabase != true {
coordinator?.reloadSidebar()
}
coordinator?.initRedisKeyTreeIfNeeded()
}
}
Expand Down
6 changes: 4 additions & 2 deletions TablePro/Views/Sidebar/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ struct SidebarView: View {
}
.onChange(of: tables) { _, newTables in
let hasSession = DatabaseManager.shared.activeSessions[connectionId] != nil
if newTables.isEmpty && hasSession && !viewModel.isLoading {
if newTables.isEmpty && hasSession && !viewModel.isLoading
&& coordinator?.isSwitchingDatabase != true
{
viewModel.loadTables()
}
}
Expand Down Expand Up @@ -159,7 +161,7 @@ struct SidebarView: View {
}

private var hasActiveConnection: Bool {
viewModel.isLoading || DatabaseManager.shared.driver(for: connectionId) != nil
viewModel.isLoading
}

private var loadingState: some View {
Expand Down
Loading
Loading