Improved progress display
Previously, the progress bar updated only after completing an entire table. For large tables such as LIBARC, this could make the application appear to have stopped responding. Phase 10 updates the interface while rows are being exported.
This commit is contained in:
@@ -36,6 +36,8 @@ struct MainView: View {
|
||||
private let resultPageSize = 1_000
|
||||
@State private var showingEditor = false
|
||||
@State private var showingDatabaseTransfer = false
|
||||
@State private var tableSearchTarget: WorkbenchTableTarget? = nil
|
||||
@State private var tableImportTarget: WorkbenchTableTarget? = nil
|
||||
@State private var schemaEditorTarget: SchemaEditorTarget? = nil
|
||||
@State private var objectEditorTarget: WorkbenchObjectEditorTarget? = nil
|
||||
@State private var showingQueryPlan = false
|
||||
@@ -115,6 +117,15 @@ struct MainView: View {
|
||||
} message: {
|
||||
Text("Save the current SQL in your local query library.")
|
||||
}
|
||||
.sheet(item: $tableSearchTarget) { target in
|
||||
WorkbenchTableSearchSheet(model: model, target: target) { sql in
|
||||
revealSchemaSQL(sql)
|
||||
model.runSQL()
|
||||
}
|
||||
}
|
||||
.sheet(item: $tableImportTarget) { target in
|
||||
WorkbenchCSVImportSheet(model: model, target: target)
|
||||
}
|
||||
.sheet(isPresented: $showingDatabaseTransfer) {
|
||||
WorkbenchDatabaseTransferView(model: model)
|
||||
}
|
||||
@@ -499,6 +510,9 @@ struct MainView: View {
|
||||
Button("Create Table…") {
|
||||
schemaEditorTarget = SchemaEditorTarget(database: db, table: nil)
|
||||
}
|
||||
Button("Import CSV into New Table…") {
|
||||
tableImportTarget = .init(database: db, table: nil)
|
||||
}
|
||||
Button("Refresh All") {
|
||||
model.loadDatabases()
|
||||
model.loadTables(database: db)
|
||||
@@ -640,6 +654,13 @@ struct MainView: View {
|
||||
table: table
|
||||
)
|
||||
}
|
||||
Button("Search Table Data…") {
|
||||
tableSearchTarget = .init(database: database, table: table)
|
||||
}
|
||||
Button("Import CSV Data…") {
|
||||
tableImportTarget = .init(database: database, table: table)
|
||||
}
|
||||
Divider()
|
||||
Button("Edit Table Schema…") {
|
||||
schemaEditorTarget = SchemaEditorTarget(database: database, table: table)
|
||||
}
|
||||
@@ -5534,3 +5555,288 @@ private struct WorkbenchSchemaManagementSheet: View {
|
||||
submitted = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 59: Table data search and CSV import
|
||||
|
||||
private struct WorkbenchTableTarget: Identifiable {
|
||||
let id = UUID()
|
||||
let database: String
|
||||
let table: String?
|
||||
}
|
||||
|
||||
private enum WorkbenchSQLValue {
|
||||
static func identifier(_ name: String) -> String {
|
||||
"`" + name.replacingOccurrences(of: "`", with: "``") + "`"
|
||||
}
|
||||
static func literal(_ value: String) -> String {
|
||||
// MySQL backslash escaping; use the connection's SQL mode for interpretation.
|
||||
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "''")
|
||||
.replacingOccurrences(of: "\0", with: "\\0")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: "\r", with: "\\r")
|
||||
return "'" + escaped + "'"
|
||||
}
|
||||
}
|
||||
|
||||
private struct WorkbenchTableSearchSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: DatabaseModel
|
||||
let target: WorkbenchTableTarget
|
||||
let execute: (String) -> Void
|
||||
@State private var columns: [String] = []
|
||||
@State private var column = ""
|
||||
@State private var term = ""
|
||||
@State private var mode = "Contains"
|
||||
@State private var limit = 500
|
||||
@State private var error = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Search Table Data").font(.title2.bold())
|
||||
Text("\(target.database).\(target.table ?? "")").foregroundStyle(.secondary)
|
||||
if !error.isEmpty { Text(error).foregroundStyle(.red) }
|
||||
Picker("Column", selection: $column) {
|
||||
ForEach(columns, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Picker("Condition", selection: $mode) {
|
||||
ForEach(["Contains", "Equals", "Starts with", "Ends with"], id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
TextField("Search value", text: $term)
|
||||
Stepper("Maximum rows: \(limit)", value: $limit, in: 1...10000, step: 100)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Search") {
|
||||
let db = WorkbenchSQLValue.identifier(target.database)
|
||||
let tbl = WorkbenchSQLValue.identifier(target.table ?? "")
|
||||
let expression: String
|
||||
switch mode {
|
||||
case "Equals": expression = WorkbenchSQLValue.literal(term)
|
||||
case "Starts with": expression = WorkbenchSQLValue.literal(term + "%")
|
||||
case "Ends with": expression = WorkbenchSQLValue.literal("%" + term)
|
||||
default: expression = WorkbenchSQLValue.literal("%" + term + "%")
|
||||
}
|
||||
let op = mode == "Equals" ? "=" : "LIKE"
|
||||
execute("SELECT * FROM \(db).\(tbl) WHERE \(WorkbenchSQLValue.identifier(column)) \(op) \(expression) LIMIT \(limit);")
|
||||
dismiss()
|
||||
}
|
||||
.disabled(column.isEmpty)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 520)
|
||||
.task {
|
||||
guard let profile = model.selected, let table = target.table else { return }
|
||||
do {
|
||||
let sql = "SHOW COLUMNS FROM \(WorkbenchSQLValue.identifier(target.database)).\(WorkbenchSQLValue.identifier(table));"
|
||||
let result = try await NativeMySQLRunner.run(profile: profile, database: target.database,
|
||||
password: PasswordStore.read(profile.id), sql: sql)
|
||||
columns = result.rows.compactMap { $0.first }
|
||||
column = columns.first ?? ""
|
||||
} catch { error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WorkbenchCSVImportSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: DatabaseModel
|
||||
let target: WorkbenchTableTarget
|
||||
@State private var fileURL: URL?
|
||||
@State private var headers: [String] = []
|
||||
@State private var records: [[String]] = []
|
||||
@State private var selectedColumns: [String] = []
|
||||
@State private var availableColumns: [String] = []
|
||||
@State private var newTableName = "imported_data"
|
||||
@State private var firstRowHeaders = true
|
||||
@State private var spatialColumns: Set<Int> = []
|
||||
@State private var busy = false
|
||||
@State private var progress = 0
|
||||
@State private var error = ""
|
||||
@State private var completed = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Table Data Import Wizard").font(.title2.bold())
|
||||
Text("Schema: \(target.database)").foregroundStyle(.secondary)
|
||||
if target.table == nil {
|
||||
TextField("New table name", text: $newTableName)
|
||||
} else {
|
||||
Text("Destination table: \(target.table!)")
|
||||
}
|
||||
HStack {
|
||||
Button("Choose CSV File…") { chooseFile() }
|
||||
Text(fileURL?.lastPathComponent ?? "No file selected")
|
||||
.lineLimit(1).foregroundStyle(.secondary)
|
||||
}
|
||||
Toggle("First row contains column names", isOn: $firstRowHeaders)
|
||||
.onChange(of: firstRowHeaders) { _, _ in prepareMapping() }
|
||||
if !records.isEmpty {
|
||||
Text("\(dataRows.count) data rows · \(headers.count) columns").font(.caption)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(headers.indices, id: \.self) { index in
|
||||
HStack {
|
||||
Text(headers[index]).frame(width: 150, alignment: .leading).lineLimit(1)
|
||||
if target.table == nil {
|
||||
TextField("Destination column", text: $selectedColumns[index])
|
||||
.frame(width: 190)
|
||||
} else {
|
||||
Picker("Destination", selection: $selectedColumns[index]) {
|
||||
Text("Skip").tag("")
|
||||
ForEach(availableColumns, id: \.self) { Text($0).tag($0) }
|
||||
}.frame(width: 220)
|
||||
}
|
||||
Toggle("WKT geometry", isOn: Binding(
|
||||
get: { spatialColumns.contains(index) },
|
||||
set: { if $0 { spatialColumns.insert(index) } else { spatialColumns.remove(index) } }
|
||||
)).toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Text("Preview (first 5 rows)").font(.headline)
|
||||
ForEach(Array(dataRows.prefix(5).enumerated()), id: \.offset) { _, row in
|
||||
Text(row.joined(separator: " | "))
|
||||
.font(.system(.caption, design: .monospaced)).lineLimit(2)
|
||||
}
|
||||
}
|
||||
}.frame(maxHeight: 350)
|
||||
}
|
||||
if !error.isEmpty { Text(error).foregroundStyle(.red).textSelection(.enabled) }
|
||||
if busy { ProgressView(value: Double(progress), total: Double(max(1, dataRows.count))) }
|
||||
if completed { Label("Imported \(progress) rows", systemImage: "checkmark.circle.fill").foregroundStyle(.green) }
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }.disabled(busy)
|
||||
Button("Import \(dataRows.count) Rows") { Task { await importRows() } }
|
||||
.disabled(busy || records.isEmpty || dataRows.isEmpty || (target.table == nil && newTableName.isEmpty))
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(width: 740, height: 660)
|
||||
.task { await loadColumns() }
|
||||
}
|
||||
|
||||
private var dataRows: [[String]] { firstRowHeaders ? Array(records.dropFirst()) : records }
|
||||
|
||||
private func chooseFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.commaSeparatedText, .plainText]
|
||||
panel.allowsMultipleSelection = false
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
error = "CSV must be UTF-8 encoded."; return
|
||||
}
|
||||
let parsed = try WorkbenchCSVParser.parse(text)
|
||||
guard !parsed.isEmpty else { error = "CSV file is empty."; return }
|
||||
fileURL = url
|
||||
records = parsed
|
||||
error = ""
|
||||
progress = 0
|
||||
completed = false
|
||||
prepareMapping()
|
||||
} catch { error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func prepareMapping() {
|
||||
let width = records.map(\.count).max() ?? 0
|
||||
headers = (0..<width).map { index in
|
||||
firstRowHeaders && index < (records.first?.count ?? 0) && !(records.first?[index] ?? "").isEmpty
|
||||
? records[0][index] : "Column \(index + 1)"
|
||||
}
|
||||
selectedColumns = headers.enumerated().map { index, name in
|
||||
if target.table == nil { return name }
|
||||
return availableColumns.first(where: { $0.caseInsensitiveCompare(name) == .orderedSame })
|
||||
?? (index < availableColumns.count ? availableColumns[index] : "")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadColumns() async {
|
||||
guard let profile = model.selected, let table = target.table else { return }
|
||||
do {
|
||||
let sql = "SHOW COLUMNS FROM \(WorkbenchSQLValue.identifier(target.database)).\(WorkbenchSQLValue.identifier(table));"
|
||||
let result = try await NativeMySQLRunner.run(profile: profile, database: target.database,
|
||||
password: PasswordStore.read(profile.id), sql: sql)
|
||||
availableColumns = result.rows.compactMap(\.first)
|
||||
prepareMapping()
|
||||
} catch { error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func importRows() async {
|
||||
guard let profile = model.selected else { error = "Select a MySQL connection."; return }
|
||||
let mappings = selectedColumns.enumerated().filter { !$0.element.isEmpty }
|
||||
guard !mappings.isEmpty else { error = "Map at least one column."; return }
|
||||
let destinations = mappings.map(\.element)
|
||||
guard Set(destinations.map { $0.lowercased() }).count == destinations.count else {
|
||||
error = "Each destination column must be unique."; return
|
||||
}
|
||||
let table = target.table ?? newTableName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !table.isEmpty else { error = "Enter a table name."; return }
|
||||
busy = true; completed = false; error = ""; progress = 0
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let qualified = "\(WorkbenchSQLValue.identifier(target.database)).\(WorkbenchSQLValue.identifier(table))"
|
||||
do {
|
||||
if target.table == nil {
|
||||
let definitions = mappings.map { index, name in
|
||||
"\(WorkbenchSQLValue.identifier(name)) \(spatialColumns.contains(index) ? "GEOMETRY" : "TEXT") NULL"
|
||||
}.joined(separator: ", ")
|
||||
_ = try await NativeMySQLRunner.run(profile: profile, database: target.database,
|
||||
password: password, sql: "CREATE TABLE \(qualified) (\(definitions));")
|
||||
}
|
||||
let names = destinations.map(WorkbenchSQLValue.identifier).joined(separator: ", ")
|
||||
for row in dataRows {
|
||||
let values = mappings.map { index, _ -> String in
|
||||
let value = index < row.count ? row[index] : ""
|
||||
let literal = WorkbenchSQLValue.literal(value)
|
||||
return spatialColumns.contains(index) ? "ST_GeomFromText(\(literal))" : literal
|
||||
}.joined(separator: ", ")
|
||||
_ = try await NativeMySQLRunner.run(profile: profile, database: target.database,
|
||||
password: password, sql: "INSERT INTO \(qualified) (\(names)) VALUES (\(values));")
|
||||
progress += 1
|
||||
}
|
||||
completed = true
|
||||
model.loadTables(database: target.database)
|
||||
} catch {
|
||||
error = "Stopped after \(progress) rows: \(error.localizedDescription). Previously inserted rows may remain."
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
private enum WorkbenchCSVParser {
|
||||
enum CSVError: LocalizedError {
|
||||
case unterminatedQuote
|
||||
var errorDescription: String? { "CSV contains an unterminated quoted field." }
|
||||
}
|
||||
static func parse(_ text: String) throws -> [[String]] {
|
||||
var rows: [[String]] = []
|
||||
var row: [String] = []
|
||||
var field = ""
|
||||
var quoted = false
|
||||
let chars = Array(text)
|
||||
var i = 0
|
||||
while i < chars.count {
|
||||
let c = chars[i]
|
||||
if quoted {
|
||||
if c == "\"" {
|
||||
if i + 1 < chars.count && chars[i + 1] == "\"" { field.append("\""); i += 1 }
|
||||
else { quoted = false }
|
||||
} else { field.append(c) }
|
||||
} else if c == "\"" && field.isEmpty { quoted = true }
|
||||
else if c == "," { row.append(field); field = "" }
|
||||
else if c == "\n" {
|
||||
row.append(field.trimmingCharacters(in: .init(charactersIn: "\r")))
|
||||
rows.append(row); row = []; field = ""
|
||||
} else { field.append(c) }
|
||||
i += 1
|
||||
}
|
||||
if quoted { throw CSVError.unterminatedQuote }
|
||||
if !row.isEmpty || !field.isEmpty { row.append(field); rows.append(row) }
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user