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:
@@ -2,6 +2,7 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Classic Workbench Colours
|
||||
|
||||
@@ -25,8 +26,16 @@ private enum WB {
|
||||
struct MainView: View {
|
||||
@EnvironmentObject private var model: DatabaseModel
|
||||
|
||||
@State private var selectedSQL = ""
|
||||
@State private var resultFilter = ""
|
||||
@State private var resultSortColumn = 0
|
||||
@State private var resultSortAscending = true
|
||||
@State private var showingEditor = false
|
||||
@State private var showingDatabaseTransfer = false
|
||||
@State private var showingQueryHistory = false
|
||||
@State private var showingSavedQueries = false
|
||||
@State private var showingSaveQuery = false
|
||||
@State private var newSavedQueryName = ""
|
||||
|
||||
@State private var editing = ConnectionProfile()
|
||||
@State private var editingPassword = ""
|
||||
@@ -74,6 +83,19 @@ struct MainView: View {
|
||||
model.loadDatabases()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingQueryHistory) {
|
||||
SQLHistorySheet(model: model)
|
||||
}
|
||||
.sheet(isPresented: $showingSavedQueries) {
|
||||
SavedSQLQueriesSheet(model: model)
|
||||
}
|
||||
.alert("Save SQL Query", isPresented: $showingSaveQuery) {
|
||||
TextField("Query name", text: $newSavedQueryName)
|
||||
Button("Save") { model.saveCurrentQuery(named: newSavedQueryName) }
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Save the current SQL in your local query library.")
|
||||
}
|
||||
.sheet(isPresented: $showingDatabaseTransfer) {
|
||||
WorkbenchDatabaseTransferView(model: model)
|
||||
}
|
||||
@@ -650,6 +672,14 @@ struct MainView: View {
|
||||
}
|
||||
.help("Execute SQL")
|
||||
|
||||
Button {
|
||||
model.runSQL(statement: selectedSQL)
|
||||
} label: {
|
||||
Label("Run Selection", systemImage: "selection.pin.in.out")
|
||||
}
|
||||
.disabled(selectedSQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.help("Execute selected SQL without modifying the editor")
|
||||
|
||||
Button {
|
||||
model.formatSQL()
|
||||
} label: {
|
||||
@@ -660,6 +690,30 @@ struct MainView: View {
|
||||
Divider()
|
||||
.frame(height: 17)
|
||||
|
||||
Button {
|
||||
showingQueryHistory = true
|
||||
} label: {
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.help("Query execution history")
|
||||
|
||||
Button {
|
||||
showingSavedQueries = true
|
||||
} label: {
|
||||
Label("Saved", systemImage: "bookmark")
|
||||
}
|
||||
.help("Open saved SQL queries")
|
||||
|
||||
Button {
|
||||
newSavedQueryName = ""
|
||||
showingSaveQuery = true
|
||||
} label: {
|
||||
Image(systemName: "bookmark.fill")
|
||||
}
|
||||
.help("Save current SQL query")
|
||||
|
||||
Divider().frame(height: 17)
|
||||
|
||||
Text("Default Schema:")
|
||||
.font(.system(size: 11))
|
||||
|
||||
@@ -696,7 +750,7 @@ struct MainView: View {
|
||||
.fill(WB.border)
|
||||
.frame(width: 1)
|
||||
|
||||
SQLTextView(text: $model.sql)
|
||||
SQLTextView(text: $model.sql, selectedSQL: $selectedSQL, completions: Array(Set(model.schemaTables.values.flatMap { $0 } + model.schemaViews.values.flatMap { $0 } + (model.schemaColumns[model.selectedDatabase] ?? []))).sorted())
|
||||
}
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
}
|
||||
@@ -747,6 +801,23 @@ struct MainView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if !model.result.columns.isEmpty {
|
||||
TextField("Filter rows", text: $resultFilter)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 170)
|
||||
Picker("Sort", selection: $resultSortColumn) {
|
||||
ForEach(model.result.columns.indices, id: \.self) { index in
|
||||
Text(model.result.columns[index]).tag(index)
|
||||
}
|
||||
}
|
||||
.frame(width: 155)
|
||||
Button(resultSortAscending ? "Ascending" : "Descending") {
|
||||
resultSortAscending.toggle()
|
||||
}
|
||||
Button("Export CSV") { exportResultCSV() }
|
||||
}
|
||||
Text(model.queryDurationText)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(model.result.message)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -772,10 +843,63 @@ struct MainView: View {
|
||||
|
||||
|
||||
private var resultGrid: some View {
|
||||
NativeResultGrid(
|
||||
columns: model.result.columns,
|
||||
rows: model.result.rows
|
||||
)
|
||||
NativeResultGrid(columns: model.result.columns, rows: displayedRows)
|
||||
}
|
||||
|
||||
private var displayedRows: [[String]] {
|
||||
let filtered = resultFilter.isEmpty ? model.result.rows : model.result.rows.filter { row in
|
||||
row.contains { $0.localizedCaseInsensitiveContains(resultFilter) }
|
||||
}
|
||||
guard !model.result.columns.isEmpty else { return filtered }
|
||||
let index = min(resultSortColumn, model.result.columns.count - 1)
|
||||
return filtered.sorted { left, right in
|
||||
let a = index < left.count ? left[index] : ""
|
||||
let b = index < right.count ? right[index] : ""
|
||||
let comparison = a.localizedStandardCompare(b)
|
||||
return resultSortAscending ? comparison == .orderedAscending : comparison == .orderedDescending
|
||||
}
|
||||
}
|
||||
|
||||
private func exportResultCSV() {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "query-results.csv"
|
||||
panel.allowedContentTypes = [.commaSeparatedText]
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
|
||||
// Write one CSV record at a time instead of constructing the entire
|
||||
// document as a single String. Only already-loaded result rows are saved.
|
||||
func csvField(_ value: String) -> String {
|
||||
"\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
||||
}
|
||||
func csvRecord(_ values: [String]) -> Data {
|
||||
Data((values.map(csvField).joined(separator: ",") + "\r\n").utf8)
|
||||
}
|
||||
|
||||
let rows = displayedRows
|
||||
let tempURL = url.deletingLastPathComponent()
|
||||
.appendingPathComponent(".\(UUID().uuidString).csv.tmp")
|
||||
do {
|
||||
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: tempURL)
|
||||
defer { try? handle.close() }
|
||||
try handle.write(contentsOf: Data([0xEF, 0xBB, 0xBF]))
|
||||
try handle.write(contentsOf: csvRecord(model.result.columns))
|
||||
for row in rows {
|
||||
try handle.write(contentsOf: csvRecord(row))
|
||||
}
|
||||
try handle.synchronize()
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
_ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: tempURL, to: url)
|
||||
}
|
||||
model.status = "Exported \(rows.count) rows to \(url.lastPathComponent)"
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
model.status = "CSV export failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private var actionOutput: some View {
|
||||
@@ -885,10 +1009,107 @@ struct MainView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 17: Query History and Saved Queries
|
||||
|
||||
private struct SQLHistorySheet: View {
|
||||
@ObservedObject var model: DatabaseModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var filter = ""
|
||||
|
||||
private var entries: [SQLHistoryEntry] {
|
||||
model.queryHistory.filter {
|
||||
filter.isEmpty || $0.sql.localizedCaseInsensitiveContains(filter) ||
|
||||
$0.connectionName.localizedCaseInsensitiveContains(filter)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Query History").font(.title2).bold()
|
||||
Spacer()
|
||||
Button("Clear History") { model.clearQueryHistory() }
|
||||
.disabled(model.queryHistory.isEmpty)
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
TextField("Filter SQL or connection", text: $filter)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
List(entries) { entry in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Image(systemName: entry.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(entry.succeeded ? .green : .red)
|
||||
Text(entry.executedAt, style: .date)
|
||||
Text(entry.executedAt, style: .time)
|
||||
Text(entry.connectionName).foregroundStyle(.secondary)
|
||||
Text(entry.database).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Open in New Tab") {
|
||||
model.newQueryTab()
|
||||
model.sql = entry.sql
|
||||
model.saveActiveQuery()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
Text(entry.sql).font(.system(.body, design: .monospaced))
|
||||
.lineLimit(3).textSelection(.enabled)
|
||||
Text(entry.detail).font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 740, minHeight: 460)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SavedSQLQueriesSheet: View {
|
||||
@ObservedObject var model: DatabaseModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var filter = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Saved SQL Queries").font(.title2).bold()
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
TextField("Filter saved queries", text: $filter)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
List(model.savedQueries.filter {
|
||||
filter.isEmpty || $0.name.localizedCaseInsensitiveContains(filter) ||
|
||||
$0.sql.localizedCaseInsensitiveContains(filter)
|
||||
}) { entry in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.name).font(.headline)
|
||||
Text(entry.sql).font(.system(.caption, design: .monospaced))
|
||||
.lineLimit(2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Open") {
|
||||
model.openSavedQuery(entry)
|
||||
dismiss()
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
model.deleteSavedQuery(entry.id)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
}.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 650, minHeight: 420)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Native SQL Text Editor
|
||||
|
||||
struct SQLTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var selectedSQL: String
|
||||
var completions: [String] = []
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
@@ -961,6 +1182,39 @@ struct SQLTextView: NSViewRepresentable {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
private let completionWords = [
|
||||
"SELECT", "FROM", "WHERE", "INSERT INTO", "UPDATE", "DELETE FROM",
|
||||
"CREATE TABLE", "ALTER TABLE", "DROP TABLE", "JOIN", "LEFT JOIN",
|
||||
"INNER JOIN", "GROUP BY", "ORDER BY", "HAVING", "LIMIT",
|
||||
"DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX", "AS",
|
||||
"AND", "OR", "NOT", "NULL", "IS NULL", "LIKE", "IN",
|
||||
"BETWEEN", "EXISTS", "UNION", "WITH", "CASE", "WHEN", "THEN",
|
||||
"ELSE", "END", "SHOW TABLES", "SHOW DATABASES", "DESCRIBE",
|
||||
"EXPLAIN", "PRIMARY KEY", "FOREIGN KEY", "INDEX", "VALUES"
|
||||
]
|
||||
|
||||
func textView(_ textView: NSTextView, completions words: [String],
|
||||
forPartialWordRange charRange: NSRange,
|
||||
indexOfSelectedItem index: UnsafeMutablePointer<Int>?) -> [String] {
|
||||
let text = textView.string as NSString
|
||||
guard charRange.location != NSNotFound,
|
||||
NSMaxRange(charRange) <= text.length else { return [] }
|
||||
let prefix = text.substring(with: charRange)
|
||||
guard !prefix.isEmpty else { return [] }
|
||||
return Array(Set(completionWords + parent.completions))
|
||||
.filter { $0.range(of: prefix, options: [.anchored, .caseInsensitive]) != nil }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
func textViewDidChangeSelection(_ notification: Notification) {
|
||||
guard let editor = notification.object as? NSTextView else { return }
|
||||
let range = editor.selectedRange()
|
||||
let value = editor.string as NSString
|
||||
let selection = range.length > 0 && NSMaxRange(range) <= value.length
|
||||
? value.substring(with: range) : ""
|
||||
if parent.selectedSQL != selection { parent.selectedSQL = selection }
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let editor = notification.object as? NSTextView else {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user