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:
@@ -63,9 +63,73 @@ struct WorkbenchQueryTab: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
struct SQLHistoryEntry: Identifiable, Codable {
|
||||
var id = UUID()
|
||||
var executedAt = Date()
|
||||
var connectionName: String
|
||||
var database: String
|
||||
var sql: String
|
||||
var succeeded: Bool
|
||||
var detail: String
|
||||
}
|
||||
|
||||
struct SavedSQLQuery: Identifiable, Codable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var sql: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published private(set) var queryHistory: [SQLHistoryEntry] = []
|
||||
@Published private(set) var savedQueries: [SavedSQLQuery] = []
|
||||
private let historyKey = "WorkbenchReplacement.queryHistory.v1"
|
||||
private let savedQueriesKey = "WorkbenchReplacement.savedQueries.v1"
|
||||
private let maxHistoryEntries = 200
|
||||
|
||||
func saveCurrentQuery(named name: String) {
|
||||
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !clean.isEmpty, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
savedQueries.insert(SavedSQLQuery(name: clean, sql: sql), at: 0)
|
||||
persistSavedQueries()
|
||||
}
|
||||
|
||||
func openSavedQuery(_ entry: SavedSQLQuery) {
|
||||
newQueryTab()
|
||||
sql = entry.sql
|
||||
if let id = activeQueryID, let index = queryTabs.firstIndex(where: { $0.id == id }) {
|
||||
queryTabs[index].title = entry.name
|
||||
queryTabs[index].sql = entry.sql
|
||||
}
|
||||
}
|
||||
|
||||
func deleteSavedQuery(_ id: UUID) {
|
||||
savedQueries.removeAll { $0.id == id }
|
||||
persistSavedQueries()
|
||||
}
|
||||
|
||||
func clearQueryHistory() {
|
||||
queryHistory.removeAll()
|
||||
UserDefaults.standard.removeObject(forKey: historyKey)
|
||||
}
|
||||
|
||||
private func persistSavedQueries() {
|
||||
if let data = try? JSONEncoder().encode(savedQueries) {
|
||||
UserDefaults.standard.set(data, forKey: savedQueriesKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func recordQuery(_ statement: String, profile: ConnectionProfile, database: String, succeeded: Bool, detail: String) {
|
||||
queryHistory.insert(SQLHistoryEntry(connectionName: profile.name, database: database, sql: statement, succeeded: succeeded, detail: detail), at: 0)
|
||||
if queryHistory.count > maxHistoryEntries {
|
||||
queryHistory.removeLast(queryHistory.count - maxHistoryEntries)
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(queryHistory) {
|
||||
UserDefaults.standard.set(data, forKey: historyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Published var selectedID: UUID?
|
||||
@Published var queryTabs: [WorkbenchQueryTab] = [
|
||||
WorkbenchQueryTab(
|
||||
@@ -79,12 +143,22 @@ final class DatabaseModel: ObservableObject {
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var schemaViews: [String: [String]] = [:]
|
||||
@Published var schemaColumns: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var queryDurationText = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: historyKey),
|
||||
let entries = try? JSONDecoder().decode([SQLHistoryEntry].self, from: data) {
|
||||
queryHistory = Array(entries.prefix(maxHistoryEntries))
|
||||
}
|
||||
if let data = UserDefaults.standard.data(forKey: savedQueriesKey),
|
||||
let entries = try? JSONDecoder().decode([SavedSQLQuery].self, from: data) {
|
||||
savedQueries = entries
|
||||
}
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
@@ -250,6 +324,27 @@ final class DatabaseModel: ObservableObject {
|
||||
schemaTables[database] = tables
|
||||
schemaViews[database] = views
|
||||
|
||||
// Cache column names for SQL completion. The lookup is read-only,
|
||||
// scoped to the selected schema and uses the current connection.
|
||||
// Failures (e.g. metadata permissions) do not prevent table browsing.
|
||||
do {
|
||||
let quotedSchema = database.replacingOccurrences(of: "'", with: "''")
|
||||
let columnResult = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: database,
|
||||
password: password,
|
||||
sql: "SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(quotedSchema)' ORDER BY TABLE_NAME, ORDINAL_POSITION"
|
||||
)
|
||||
var names = Set<String>()
|
||||
for row in columnResult.rows where row.count >= 2 {
|
||||
names.insert(row[1])
|
||||
names.insert("\(row[0]).\(row[1])")
|
||||
}
|
||||
schemaColumns[database] = names.sorted()
|
||||
} catch {
|
||||
schemaColumns[database] = []
|
||||
}
|
||||
|
||||
status = "Loaded \(tables.count) tables and \(views.count) views from \(database)"
|
||||
} catch {
|
||||
status = "Unable to load tables: \(error.localizedDescription)"
|
||||
@@ -296,10 +391,16 @@ final class DatabaseModel: ObservableObject {
|
||||
}
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
func runSQL(statement: String) {
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
execute(statement, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
if !listingDatabases { queryDurationText = "Running…" }
|
||||
let queryStarted = Date()
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
@@ -317,10 +418,17 @@ final class DatabaseModel: ObservableObject {
|
||||
queryTabs[index].result = parsed
|
||||
}
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
recordQuery(statement, profile: profile, database: activeDB, succeeded: true, detail: status)
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
if !listingDatabases {
|
||||
recordQuery(statement, profile: profile, database: activeDB, succeeded: false, detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
if !listingDatabases {
|
||||
queryDurationText = String(format: "%.3f s", Date().timeIntervalSince(queryStarted))
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user