Skip to content

Commit 9213e60

Browse files
authored
fix(ios): prevent crash in SQL syntax highlighter with stale range (#689)
* chore(ios): add missing translations for Turkish, Vietnamese, and Chinese * fix: show database-specific options in Create Database dialog * fix: sidebar loading spinner stuck when database has no tables * fix: stale tables showing briefly after database switch * fix: prevent stale tables flash during database switch * fix: skip auto-reload from stale cache during database switch * fix(ios): prevent crash in SQL syntax highlighter with stale range The highlight method is called asynchronously from the text storage delegate. By the time it executes, the text may have changed, making the editedRange invalid (location beyond string length). Add bounds check and clamp the range before calling lineRangeForRange. Fixes crash: -[NSString lineRangeForRange:] out-of-bounds exception
1 parent 00e0ce6 commit 9213e60

File tree

11 files changed

+146
-54
lines changed

11 files changed

+146
-54
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
### Fixed
1919

20+
- 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)
2021
- 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
2122

2223
## [0.30.1] - 2026-04-10

Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
560560
func createDatabase(name: String, charset: String, collation: String?) async throws {
561561
let escapedName = name.replacingOccurrences(of: "`", with: "``")
562562

563-
let validCharsets = ["utf8mb4", "utf8", "latin1", "ascii"]
563+
let validCharsets = ["utf8mb4", "utf8mb3", "utf8", "latin1", "ascii",
564+
"binary", "utf16", "utf32", "cp1251", "big5",
565+
"euckr", "gb2312", "gbk", "sjis"]
564566
guard validCharsets.contains(charset) else {
565567
throw MariaDBPluginError(code: 0, message: "Invalid character set: \(charset)", sqlState: nil)
566568
}

Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,8 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
765765

766766
func createDatabase(name: String, charset: String, collation: String?) async throws {
767767
let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"")
768-
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII"]
768+
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP",
769+
"EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK"]
769770
let normalizedCharset = charset.uppercased()
770771
guard validCharsets.contains(normalizedCharset) else {
771772
throw LibPQPluginError(message: "Invalid encoding: \(charset)", sqlState: nil, detail: nil)

Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,8 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
621621

622622
func createDatabase(name: String, charset: String, collation: String?) async throws {
623623
let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"")
624-
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII"]
624+
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP",
625+
"EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK"]
625626
let normalizedCharset = charset.uppercased()
626627
guard validCharsets.contains(normalizedCharset) else {
627628
throw LibPQPluginError(message: "Invalid encoding: \(charset)", sqlState: nil, detail: nil)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// CreateDatabaseOptions.swift
3+
// TablePro
4+
//
5+
// Database-type-specific options for CREATE DATABASE dialog.
6+
//
7+
8+
import Foundation
9+
10+
struct CreateDatabaseOptions {
11+
struct Config {
12+
let charsetLabel: String
13+
let collationLabel: String
14+
let defaultCharset: String
15+
let defaultCollation: String
16+
let charsets: [String]
17+
let collations: [String: [String]]
18+
let showOptions: Bool
19+
}
20+
21+
static func config(for type: DatabaseType) -> Config {
22+
if type == .mysql || type == .mariadb {
23+
return Config(
24+
charsetLabel: "Character Set",
25+
collationLabel: "Collation",
26+
defaultCharset: "utf8mb4",
27+
defaultCollation: "utf8mb4_unicode_ci",
28+
charsets: CreateTableOptions.charsets,
29+
collations: CreateTableOptions.collations,
30+
showOptions: true
31+
)
32+
} else if type == .postgresql || type == .redshift {
33+
return Config(
34+
charsetLabel: "Encoding",
35+
collationLabel: "LC_COLLATE",
36+
defaultCharset: "UTF8",
37+
defaultCollation: "en_US.UTF-8",
38+
charsets: postgresqlEncodings,
39+
collations: postgresqlLocales,
40+
showOptions: true
41+
)
42+
} else {
43+
return Config(
44+
charsetLabel: "",
45+
collationLabel: "",
46+
defaultCharset: "",
47+
defaultCollation: "",
48+
charsets: [],
49+
collations: [:],
50+
showOptions: false
51+
)
52+
}
53+
}
54+
55+
private static let postgresqlEncodings = [
56+
"UTF8", "LATIN1", "SQL_ASCII", "WIN1252", "EUC_JP",
57+
"EUC_KR", "ISO_8859_5", "KOI8R", "SJIS", "BIG5", "GBK"
58+
]
59+
60+
// PostgreSQL LC_COLLATE is OS-locale based, not encoding-dependent
61+
private static let localeOptions = ["en_US.UTF-8", "C", "POSIX", "C.UTF-8"]
62+
63+
private static let postgresqlLocales: [String: [String]] = {
64+
var result: [String: [String]] = [:]
65+
for enc in postgresqlEncodings {
66+
result[enc] = localeOptions
67+
}
68+
return result
69+
}()
70+
}

TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,25 @@ import SwiftUI
1010
struct CreateDatabaseSheet: View {
1111
@Environment(\.dismiss) private var dismiss
1212

13+
let databaseType: DatabaseType
1314
let onCreate: (String, String, String?) async throws -> Void
1415

1516
@State private var databaseName = ""
16-
@State private var charset = "utf8mb4"
17-
@State private var collation = "utf8mb4_unicode_ci"
17+
@State private var charset: String
18+
@State private var collation: String
1819
@State private var isCreating = false
1920
@State private var errorMessage: String?
2021

21-
private let charsets = [
22-
"utf8mb4",
23-
"utf8",
24-
"latin1",
25-
"ascii"
26-
]
22+
private let config: CreateDatabaseOptions.Config
2723

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

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

57-
// Charset
58-
VStack(alignment: .leading, spacing: 6) {
59-
Text("Character Set")
60-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium))
61-
.foregroundStyle(.secondary)
62-
63-
Picker("", selection: $charset) {
64-
ForEach(charsets, id: \.self) { cs in
65-
Text(cs).tag(cs)
55+
if config.showOptions {
56+
// Charset / Encoding
57+
VStack(alignment: .leading, spacing: 6) {
58+
Text(config.charsetLabel)
59+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium))
60+
.foregroundStyle(.secondary)
61+
62+
Picker("", selection: $charset) {
63+
ForEach(config.charsets, id: \.self) { cs in
64+
Text(cs).tag(cs)
65+
}
6666
}
67+
.labelsHidden()
68+
.pickerStyle(.menu)
69+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
6770
}
68-
.labelsHidden()
69-
.pickerStyle(.menu)
70-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
71-
}
7271

73-
// Collation
74-
VStack(alignment: .leading, spacing: 6) {
75-
Text("Collation")
76-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium))
77-
.foregroundStyle(.secondary)
72+
// Collation / LC_COLLATE
73+
VStack(alignment: .leading, spacing: 6) {
74+
Text(config.collationLabel)
75+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium))
76+
.foregroundStyle(.secondary)
7877

79-
Picker("", selection: $collation) {
80-
ForEach(collations[charset] ?? [], id: \.self) { col in
81-
Text(col).tag(col)
78+
Picker("", selection: $collation) {
79+
ForEach(config.collations[charset] ?? [], id: \.self) { col in
80+
Text(col).tag(col)
81+
}
8282
}
83+
.labelsHidden()
84+
.pickerStyle(.menu)
85+
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
8386
}
84-
.labelsHidden()
85-
.pickerStyle(.menu)
86-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
8787
}
8888

8989
// Error message
@@ -116,14 +116,12 @@ struct CreateDatabaseSheet: View {
116116
}
117117
.frame(width: 380)
118118
.onExitCommand {
119-
// Prevent dismissing the sheet via ESC while a database is being created
120119
if !isCreating {
121120
dismiss()
122121
}
123122
}
124123
.onChange(of: charset) { _, newCharset in
125-
// Update collation when charset changes
126-
if let firstCollation = collations[newCharset]?.first {
124+
if let firstCollation = config.collations[newCharset]?.first {
127125
collation = firstCollation
128126
}
129127
}
@@ -137,7 +135,11 @@ struct CreateDatabaseSheet: View {
137135

138136
Task {
139137
do {
140-
try await onCreate(databaseName, charset, collation)
138+
if config.showOptions {
139+
try await onCreate(databaseName, charset, collation)
140+
} else {
141+
try await onCreate(databaseName, "", nil)
142+
}
141143
await MainActor.run {
142144
dismiss()
143145
}

TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ struct DatabaseSwitcherSheet: View {
116116
.defaultFocus($focus, .search)
117117
.task { await viewModel.fetchDatabases() }
118118
.sheet(isPresented: $showCreateDialog) {
119-
CreateDatabaseSheet { name, charset, collation in
119+
CreateDatabaseSheet(databaseType: databaseType) { name, charset, collation in
120120
try await viewModel.createDatabase(
121121
name: name, charset: charset, collation: collation)
122122
await viewModel.refreshDatabases()
@@ -174,7 +174,9 @@ struct DatabaseSwitcherSheet: View {
174174
.help(String(localized: "Refresh database list"))
175175

176176
// Create (only for non-SQLite)
177-
if databaseType != .sqlite && !isSchemaMode {
177+
if databaseType != .sqlite && databaseType != .redis
178+
&& databaseType != .etcd && !isSchemaMode
179+
{
178180
Button(action: { showCreateDialog = true }) {
179181
Image(systemName: "plus")
180182
.frame(width: 24, height: 24)

TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,8 @@ extension MainContentCoordinator {
431431
}
432432
AppSettingsStorage.shared.saveLastDatabase(database, for: connectionId)
433433
await loadSchema()
434-
reloadSidebar()
434+
await schemaProvider.invalidateTables()
435+
sidebarViewModel?.forceLoadTables()
435436
} catch {
436437
// Restore toolbar to previous database on failure
437438
toolbarState.databaseName = previousDatabase

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,11 @@ final class MainContentCommandActions {
711711
if let driver = DatabaseManager.shared.driver(for: self.connection.id) {
712712
coordinator?.toolbarState.databaseVersion = driver.serverVersion
713713
}
714-
coordinator?.reloadSidebar()
714+
// Skip sidebar reload during database switch — switchDatabase() handles it
715+
// after schema invalidation to avoid flashing stale tables.
716+
if coordinator?.isSwitchingDatabase != true {
717+
coordinator?.reloadSidebar()
718+
}
715719
coordinator?.initRedisKeyTreeIfNeeded()
716720
}
717721
}

TablePro/Views/Sidebar/SidebarView.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ struct SidebarView: View {
117117
}
118118
.onChange(of: tables) { _, newTables in
119119
let hasSession = DatabaseManager.shared.activeSessions[connectionId] != nil
120-
if newTables.isEmpty && hasSession && !viewModel.isLoading {
120+
if newTables.isEmpty && hasSession && !viewModel.isLoading
121+
&& coordinator?.isSwitchingDatabase != true
122+
{
121123
viewModel.loadTables()
122124
}
123125
}
@@ -159,7 +161,7 @@ struct SidebarView: View {
159161
}
160162

161163
private var hasActiveConnection: Bool {
162-
viewModel.isLoading || DatabaseManager.shared.driver(for: connectionId) != nil
164+
viewModel.isLoading
163165
}
164166

165167
private var loadingState: some View {

0 commit comments

Comments
 (0)