diff --git a/Echo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Echo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index be236576d..abc6344c5 100644 --- a/Echo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Echo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -43,7 +43,7 @@ "location" : "https://github.com/tashda/postgres-wire", "state" : { "branch" : "dev", - "revision" : "07840f432d33964c6330448ab1562f026d103cd1" + "revision" : "b4375fee0985ce8c60e40245cfcf09af4bed572d" } }, { @@ -70,7 +70,7 @@ "location" : "https://github.com/tashda/sqlserver-nio", "state" : { "branch" : "dev", - "revision" : "4663b84bcc8a630ebf2510b68daad7708aab801f" + "revision" : "cd1cf5e220a49e7e1561bbab8e0851342c7c51fd" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/supabase/supabase-swift", "state" : { - "revision" : "ed9042e60b57257e531331da365955a5c0568b12", - "version" : "2.43.0" + "revision" : "17261e93c60aa721e3c17312bfeb2ae6de3d6f8a", + "version" : "2.43.1" } }, { diff --git a/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/Contents.json b/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/Contents.json new file mode 100644 index 000000000..21539d66e --- /dev/null +++ b/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "sql25Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "sql25Icon 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "sql25Icon 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon 1.png b/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon 1.png new file mode 100644 index 000000000..bbfed2028 Binary files /dev/null and b/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon 1.png differ diff --git a/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon 2.png b/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon 2.png new file mode 100644 index 000000000..bbfed2028 Binary files /dev/null and b/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon 2.png differ diff --git a/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon.png b/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon.png new file mode 100644 index 000000000..bbfed2028 Binary files /dev/null and b/Echo/Assets.xcassets/MicrosoftSQLServer.imageset/sql25Icon.png differ diff --git a/Echo/Assets.xcassets/MySQL.imageset/Contents.json b/Echo/Assets.xcassets/MySQL.imageset/Contents.json new file mode 100644 index 000000000..fb9e1f6af --- /dev/null +++ b/Echo/Assets.xcassets/MySQL.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "mysql.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "mysql 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mysql 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Echo/Assets.xcassets/MySQL.imageset/mysql 1.png b/Echo/Assets.xcassets/MySQL.imageset/mysql 1.png new file mode 100644 index 000000000..8d928778e Binary files /dev/null and b/Echo/Assets.xcassets/MySQL.imageset/mysql 1.png differ diff --git a/Echo/Assets.xcassets/MySQL.imageset/mysql 2.png b/Echo/Assets.xcassets/MySQL.imageset/mysql 2.png new file mode 100644 index 000000000..8d928778e Binary files /dev/null and b/Echo/Assets.xcassets/MySQL.imageset/mysql 2.png differ diff --git a/Echo/Assets.xcassets/MySQL.imageset/mysql.png b/Echo/Assets.xcassets/MySQL.imageset/mysql.png new file mode 100644 index 000000000..8d928778e Binary files /dev/null and b/Echo/Assets.xcassets/MySQL.imageset/mysql.png differ diff --git a/Echo/Assets.xcassets/PostgreSQL.imageset/Contents.json b/Echo/Assets.xcassets/PostgreSQL.imageset/Contents.json new file mode 100644 index 000000000..97512522b --- /dev/null +++ b/Echo/Assets.xcassets/PostgreSQL.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "PostgreSQL-Logo.wine.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "PostgreSQL-Logo.wine 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "PostgreSQL-Logo.wine 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine 1.png b/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine 1.png new file mode 100644 index 000000000..cfe687a73 Binary files /dev/null and b/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine 1.png differ diff --git a/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine 2.png b/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine 2.png new file mode 100644 index 000000000..cfe687a73 Binary files /dev/null and b/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine 2.png differ diff --git a/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine.png b/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine.png new file mode 100644 index 000000000..cfe687a73 Binary files /dev/null and b/Echo/Assets.xcassets/PostgreSQL.imageset/PostgreSQL-Logo.wine.png differ diff --git a/Echo/Assets.xcassets/SQLite.imageset/Contents.json b/Echo/Assets.xcassets/SQLite.imageset/Contents.json new file mode 100644 index 000000000..8125df0c5 --- /dev/null +++ b/Echo/Assets.xcassets/SQLite.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "SQLite.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "SQLite 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "SQLite 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Echo/Assets.xcassets/SQLite.imageset/SQLite 1.png b/Echo/Assets.xcassets/SQLite.imageset/SQLite 1.png new file mode 100644 index 000000000..d9ab81926 Binary files /dev/null and b/Echo/Assets.xcassets/SQLite.imageset/SQLite 1.png differ diff --git a/Echo/Assets.xcassets/SQLite.imageset/SQLite 2.png b/Echo/Assets.xcassets/SQLite.imageset/SQLite 2.png new file mode 100644 index 000000000..d9ab81926 Binary files /dev/null and b/Echo/Assets.xcassets/SQLite.imageset/SQLite 2.png differ diff --git a/Echo/Assets.xcassets/SQLite.imageset/SQLite.png b/Echo/Assets.xcassets/SQLite.imageset/SQLite.png new file mode 100644 index 000000000..d9ab81926 Binary files /dev/null and b/Echo/Assets.xcassets/SQLite.imageset/SQLite.png differ diff --git a/Echo/Sources/Core/DatabaseEngine/DatabaseProtocols.swift b/Echo/Sources/Core/DatabaseEngine/DatabaseProtocols.swift index 3e50314d9..0e2d40f5b 100644 --- a/Echo/Sources/Core/DatabaseEngine/DatabaseProtocols.swift +++ b/Echo/Sources/Core/DatabaseEngine/DatabaseProtocols.swift @@ -17,6 +17,7 @@ public protocol DatabaseSession: Sendable { func getTableSchema(_ tableName: String, schemaName: String?) async throws -> [ColumnInfo] func getObjectDefinition(objectName: String, schemaName: String, objectType: SchemaObjectInfo.ObjectType, database: String?) async throws -> String func executeUpdate(_ sql: String) async throws -> Int + func executeUpdatesAtomically(_ statements: [String]) async throws func renameTable(schema: String?, oldName: String, newName: String) async throws func dropTable(schema: String?, name: String, ifExists: Bool) async throws func truncateTable(schema: String?, name: String) async throws @@ -159,6 +160,12 @@ public extension DatabaseSession { try await simpleQuery(sql, progressHandler: progressHandler) } + func executeUpdatesAtomically(_ statements: [String]) async throws { + for statement in statements { + _ = try await executeUpdate(statement) + } + } + func rebuildIndex(schema: String, table: String, index: String) async throws -> DatabaseMaintenanceResult { throw DatabaseError.queryError("Index rebuild is not supported for this database type") } diff --git a/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/MSSQLDedicatedQuerySession+Queries.swift b/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/MSSQLDedicatedQuerySession+Queries.swift index ece03412a..87db7b11b 100644 --- a/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/MSSQLDedicatedQuerySession+Queries.swift +++ b/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/MSSQLDedicatedQuerySession+Queries.swift @@ -20,21 +20,14 @@ extension MSSQLDedicatedQuerySession { if let raw = connection.decodeLastSensitivityClassification() { queryResult.dataClassification = extractClassification(from: raw, columnCount: queryResult.columns.count) } - queryResult.serverMessages = executionResult.messages - .filter { $0.kind == .info } - .map { message in - ServerMessage( - kind: .info, - number: message.number, - message: message.message, - state: message.state, - severity: message.severity - ) - } + queryResult.serverMessages = executionResult.echoServerMessages() return queryResult } func simpleQuery(_ sql: String, progressHandler: QueryProgressHandler?) async throws -> QueryResultSet { + if QueryStatementClassifier.isLikelyMessageOnlyStatement(sql, databaseType: .microsoftSQL) { + return try await simpleQuery(sql) + } guard let progressHandler else { return try await simpleQuery(sql) } @@ -60,6 +53,17 @@ extension MSSQLDedicatedQuerySession { return Int(try await connection.execute(sql).rowCount ?? 0) } + func executeUpdatesAtomically(_ statements: [String]) async throws { + guard !statements.isEmpty else { return } + + let connection = try await readyConnection() + try await connection.withTransaction { transactionConnection in + for statement in statements { + _ = try await transactionConnection.execute(statement) + } + } + } + private func streamQueryWithProgress( _ sql: String, progressHandler: @escaping QueryProgressHandler @@ -247,7 +251,15 @@ extension MSSQLDedicatedQuerySession { number: message.number, message: message.message, state: message.state, - severity: message.severity + severity: message.severity, + serverName: message.serverName, + procedureName: message.procedureName, + lineNumber: message.lineNumber, + category: "Server Response", + metadata: [ + "source": "sqlserver-nio", + "token": "INFO" + ] ) } ) diff --git a/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/SQLServerExecutionResult+RawMessages.swift b/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/SQLServerExecutionResult+RawMessages.swift new file mode 100644 index 000000000..edd673f87 --- /dev/null +++ b/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/SQLServerExecutionResult+RawMessages.swift @@ -0,0 +1,48 @@ +import Foundation +import SQLServerKit + +extension SQLServerExecutionResult { + func echoServerMessages() -> [ServerMessage] { + let infoAndErrorMessages = messages.map { message in + ServerMessage( + kind: message.kind == .error ? .error : .info, + number: message.number, + message: message.message, + state: message.state, + severity: message.severity, + serverName: message.serverName.isEmpty ? nil : message.serverName, + procedureName: message.procedureName.isEmpty ? nil : message.procedureName, + lineNumber: message.lineNumber, + category: "Server Response", + metadata: [ + "source": "sqlserver-nio", + "token": message.kind == .error ? "ERROR" : "INFO" + ] + ) + } + + let completionMessages = done.map { done in + let status = String(format: "0x%04X", done.status) + let curCmd = String(format: "0x%04X", done.curCmd) + let text = "DONE kind=\(done.kind.rawValue) status=\(status) curCmd=\(curCmd) rowCount=\(done.rowCount)" + return ServerMessage( + kind: .info, + number: 0, + message: text, + state: 0, + severity: 0, + category: "Driver Response", + metadata: [ + "source": "sqlserver-nio", + "token": "DONE", + "kind": done.kind.rawValue, + "status": status, + "curCmd": curCmd, + "rowCount": "\(done.rowCount)" + ] + ) + } + + return infoAndErrorMessages + completionMessages + } +} diff --git a/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/SQLServerSessionAdapter+Queries.swift b/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/SQLServerSessionAdapter+Queries.swift index 9e03c6d29..b430d32b5 100644 --- a/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/SQLServerSessionAdapter+Queries.swift +++ b/Echo/Sources/Core/DatabaseEngine/Dialects/MSSQL/Modules/SQLServerSessionAdapter+Queries.swift @@ -13,21 +13,14 @@ extension SQLServerSessionAdapter { if let raw = result.classification { queryResult.dataClassification = extractClassification(from: raw, columnCount: queryResult.columns.count) } - queryResult.serverMessages = result.execResult.messages - .filter { $0.kind == .info } - .map { msg in - ServerMessage( - kind: .info, - number: msg.number, - message: msg.message, - state: msg.state, - severity: msg.severity - ) - } + queryResult.serverMessages = result.execResult.echoServerMessages() return queryResult } func simpleQuery(_ sql: String, progressHandler: QueryProgressHandler?) async throws -> QueryResultSet { + if QueryStatementClassifier.isLikelyMessageOnlyStatement(sql, databaseType: .microsoftSQL) { + return try await simpleQuery(sql) + } guard let progressHandler else { return try await simpleQuery(sql) } @@ -48,6 +41,16 @@ extension SQLServerSessionAdapter { return Int(result.rowCount ?? 0) } + func executeUpdatesAtomically(_ statements: [String]) async throws { + guard !statements.isEmpty else { return } + + try await client.transactions.executeInTransaction { + for statement in statements { + _ = try await self.client.execute(statement) + } + } + } + func renameTable(schema: String?, oldName: String, newName: String) async throws { try await client.admin.renameTable( name: oldName, @@ -258,7 +261,15 @@ extension SQLServerSessionAdapter { number: msg.number, message: msg.message, state: msg.state, - severity: msg.severity + severity: msg.severity, + serverName: msg.serverName, + procedureName: msg.procedureName, + lineNumber: msg.lineNumber, + category: "Server Response", + metadata: [ + "source": "sqlserver-nio", + "token": "INFO" + ] ) } diff --git a/Echo/Sources/Core/DatabaseEngine/Dialects/MySQL/Modules/MySQLSession+Queries.swift b/Echo/Sources/Core/DatabaseEngine/Dialects/MySQL/Modules/MySQLSession+Queries.swift index 6b74c2715..a2ec0209a 100644 --- a/Echo/Sources/Core/DatabaseEngine/Dialects/MySQL/Modules/MySQLSession+Queries.swift +++ b/Echo/Sources/Core/DatabaseEngine/Dialects/MySQL/Modules/MySQLSession+Queries.swift @@ -8,6 +8,10 @@ extension MySQLSession { } func simpleQuery(_ sql: String, progressHandler: QueryProgressHandler?) async throws -> QueryResultSet { + if QueryStatementClassifier.isLikelyMessageOnlyStatement(sql, databaseType: .mysql) { + return try await executeSimpleQuery(sql) + } + guard let progressHandler else { return try await executeSimpleQuery(sql) } @@ -110,7 +114,7 @@ extension MySQLSession { private func executeSimpleQuery(_ sql: String) async throws -> QueryResultSet { do { let result = try await client.query(sql) - return makeResultSet(from: result.rows) + return makeResultSet(from: result.rows, metadata: result.metadata) } catch { throw DatabaseError.queryError(error.localizedDescription) } @@ -165,7 +169,7 @@ extension MySQLSession { } } - private func makeResultSet(from rows: [MySQLRow]) -> QueryResultSet { + private func makeResultSet(from rows: [MySQLRow], metadata: MySQLWireQueryMetadata? = nil) -> QueryResultSet { let columns = rows.first.map { makeColumnInfo(from: $0.columnDefinitions) } ?? [] let previewRows = rows.map { row in row.values.indices.map { index in @@ -174,9 +178,18 @@ extension MySQLSession { } return QueryResultSet( - columns: columns.isEmpty ? [ColumnInfo(name: "result", dataType: "text")] : columns, + columns: columns, rows: previewRows, - totalRowCount: rows.count + totalRowCount: rows.count, + commandTag: metadata.map(commandResponse(from:)) ) } + + private func commandResponse(from metadata: MySQLWireQueryMetadata) -> String { + var segments = ["affectedRows=\(metadata.affectedRows)"] + if let lastInsertID = metadata.lastInsertID { + segments.append("lastInsertID=\(lastInsertID)") + } + return segments.joined(separator: ", ") + } } diff --git a/Echo/Sources/Core/DatabaseEngine/Dialects/MySQL/MySQLToolLocator.swift b/Echo/Sources/Core/DatabaseEngine/Dialects/MySQL/MySQLToolLocator.swift index 8aac82748..b3cfabfd3 100644 --- a/Echo/Sources/Core/DatabaseEngine/Dialects/MySQL/MySQLToolLocator.swift +++ b/Echo/Sources/Core/DatabaseEngine/Dialects/MySQL/MySQLToolLocator.swift @@ -30,7 +30,15 @@ nonisolated struct MySQLToolLocator { } private static func locateTool(name: String, customPath: String?) -> URL? { - for directory in searchDirectories(customPath: customPath) { + // When a custom path is explicitly provided, restrict the search to that + // directory only. The caller chose a specific tool location — do not fall + // through to system paths or `which`. + if let customPath, !customPath.isEmpty { + let tool = URL(fileURLWithPath: customPath).appendingPathComponent(name) + return FileManager.default.isExecutableFile(atPath: tool.path) ? tool : nil + } + + for directory in searchDirectories() { let tool = URL(fileURLWithPath: directory).appendingPathComponent(name) if FileManager.default.isExecutableFile(atPath: tool.path) { return tool @@ -58,11 +66,8 @@ nonisolated struct MySQLToolLocator { } } - private static func searchDirectories(customPath: String?) -> [String] { + private static func searchDirectories() -> [String] { var directories: [String] = [] - if let customPath, !customPath.isEmpty { - directories.append(customPath) - } let env = ProcessInfo.processInfo.environment if let envPath = env["ECHO_MYSQL_TOOL_PATH"], !envPath.isEmpty { diff --git a/Echo/Sources/Core/DatabaseEngine/Dialects/Postgres/Modules/PostgresDatabase.swift b/Echo/Sources/Core/DatabaseEngine/Dialects/Postgres/Modules/PostgresDatabase.swift index d6f754218..4c24e6367 100644 --- a/Echo/Sources/Core/DatabaseEngine/Dialects/Postgres/Modules/PostgresDatabase.swift +++ b/Echo/Sources/Core/DatabaseEngine/Dialects/Postgres/Modules/PostgresDatabase.swift @@ -113,6 +113,9 @@ final class PostgresSession: DatabaseSession { } func simpleQuery(_ sql: String, progressHandler: QueryProgressHandler?) async throws -> QueryResultSet { + if QueryStatementClassifier.isLikelyMessageOnlyStatement(sql, databaseType: .postgresql) { + return try await executeSimpleQuery(sql) + } if let progressHandler { let sanitized = sanitizeSQL(sql) return try await streamQuery(sanitizedSQL: sanitized, progressHandler: progressHandler, modeOverride: nil) @@ -122,6 +125,9 @@ final class PostgresSession: DatabaseSession { } func simpleQuery(_ sql: String, executionMode: ResultStreamingExecutionMode?, progressHandler: QueryProgressHandler?) async throws -> QueryResultSet { + if QueryStatementClassifier.isLikelyMessageOnlyStatement(sql, databaseType: .postgresql) { + return try await executeSimpleQuery(sql) + } if let progressHandler { let sanitized = sanitizeSQL(sql) return try await streamQuery(sanitizedSQL: sanitized, progressHandler: progressHandler, modeOverride: executionMode) @@ -132,7 +138,7 @@ final class PostgresSession: DatabaseSession { private func executeSimpleQuery(_ sql: String) async throws -> QueryResultSet { do { - let result = try await client.simpleQuery(sql) + let result = try await client.simpleQueryResult(sql) var columns: [ColumnInfo] = [] var rows: [[String?]] = [] @@ -140,7 +146,7 @@ final class PostgresSession: DatabaseSession { let formatter = PostgresCellFormatter() - for try await row in result { + for row in result.rows { if columns.isEmpty { let wireColumns = PostgresRowExtractor.columns(from: row) columns.reserveCapacity(wireColumns.count) @@ -169,13 +175,25 @@ final class PostgresSession: DatabaseSession { return QueryResultSet( columns: resolvedColumns, - rows: rows + rows: rows, + commandTag: rawCommandTag(from: result.metadata) ) } catch { throw normalizeError(error, contextSQL: sql) } } + private func rawCommandTag(from metadata: WireQueryMetadata) -> String { + var segments = [metadata.command] + if let oid = metadata.oid { + segments.append(String(oid)) + } + if let rows = metadata.rows { + segments.append(String(rows)) + } + return segments.joined(separator: " ") + } + func queryWithPaging(_ sql: String, limit: Int, offset: Int) async throws -> QueryResultSet { let pagedSQL = "\(sql) LIMIT \(limit) OFFSET \(offset)" return try await simpleQuery(pagedSQL) diff --git a/Echo/Sources/Core/DatabaseEngine/Dialects/Postgres/PostgresTerminalLauncher.swift b/Echo/Sources/Core/DatabaseEngine/Dialects/Postgres/PostgresTerminalLauncher.swift new file mode 100644 index 000000000..072fd07cb --- /dev/null +++ b/Echo/Sources/Core/DatabaseEngine/Dialects/Postgres/PostgresTerminalLauncher.swift @@ -0,0 +1,72 @@ +import Foundation +import OSLog + +/// Launches the native psql CLI in Terminal.app, pre-connected to a specific database. +nonisolated struct PostgresTerminalLauncher { + + private static let logger = Logger(subsystem: "com.echo.app", category: "PostgresTerminalLauncher") + + /// Opens Terminal.app with psql connected to the given database. + /// Uses `PostgresToolLocator` to find the psql binary on the user's system. + /// + /// - Parameters: + /// - host: The database server hostname. + /// - port: The database server port. + /// - username: The username for authentication. + /// - database: The database to connect to. + /// - customToolPath: Optional custom directory containing psql. + /// - Returns: `true` if Terminal was opened, `false` if psql was not found. + @discardableResult + static func openInTerminal( + host: String, + port: Int, + username: String, + database: String, + customToolPath: String? = nil + ) async -> Bool { + guard let psqlURL = PostgresToolLocator.psqlURL(customPath: customToolPath) else { + logger.warning("psql binary not found on this system") + return false + } + + let psqlPath = psqlURL.path + + // Build the psql command with connection arguments. + // Quote all arguments to handle special characters. + var parts = [shellQuote(psqlPath)] + parts.append("-h \(shellQuote(host))") + parts.append("-p \(shellQuote(String(port)))") + parts.append("-U \(shellQuote(username))") + parts.append(shellQuote(database)) + + let command = parts.joined(separator: " ") + + // Use AppleScript to open Terminal.app and run the command. + // This is the standard macOS pattern for launching CLI tools from GUI apps. + let script = """ + tell application "Terminal" + activate + do script "\(command.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))" + end tell + """ + + let appleScript = NSAppleScript(source: script) + var errorDict: NSDictionary? + appleScript?.executeAndReturnError(&errorDict) + + if let error = errorDict { + logger.error("Failed to open Terminal: \(error)") + return false + } + + logger.info("Opened psql in Terminal for \(username)@\(host):\(port)/\(database)") + return true + } + + private static func shellQuote(_ value: String) -> String { + if value.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." || $0 == "/" }) { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Echo/Sources/Core/DatabaseEngine/QueryResultsModels.swift b/Echo/Sources/Core/DatabaseEngine/QueryResultsModels.swift index 5c457cf4b..b8947bd13 100644 --- a/Echo/Sources/Core/DatabaseEngine/QueryResultsModels.swift +++ b/Echo/Sources/Core/DatabaseEngine/QueryResultsModels.swift @@ -15,8 +15,21 @@ public struct ServerMessage: Sendable { public let serverName: String? public let procedureName: String? public let lineNumber: Int32? + public let category: String? + public let metadata: [String: String] - public nonisolated init(kind: Kind, number: Int32, message: String, state: UInt8, severity: UInt8, serverName: String? = nil, procedureName: String? = nil, lineNumber: Int32? = nil) { + public nonisolated init( + kind: Kind, + number: Int32, + message: String, + state: UInt8, + severity: UInt8, + serverName: String? = nil, + procedureName: String? = nil, + lineNumber: Int32? = nil, + category: String? = nil, + metadata: [String: String] = [:] + ) { self.kind = kind self.number = number self.message = message @@ -25,6 +38,8 @@ public struct ServerMessage: Sendable { self.serverName = serverName self.procedureName = procedureName self.lineNumber = lineNumber + self.category = category + self.metadata = metadata } } diff --git a/Echo/Sources/Core/DatabaseEngine/QueryStatementClassifier.swift b/Echo/Sources/Core/DatabaseEngine/QueryStatementClassifier.swift new file mode 100644 index 000000000..7f676ffbe --- /dev/null +++ b/Echo/Sources/Core/DatabaseEngine/QueryStatementClassifier.swift @@ -0,0 +1,46 @@ +import Foundation + +enum QueryStatementClassifier { + static func isLikelyMessageOnlyStatement(_ sql: String, databaseType: DatabaseType) -> Bool { + let normalized = normalizedSQL(sql) + guard let keyword = leadingKeyword(in: normalized) else { return false } + + if normalized.contains(" RETURNING ") || normalized.contains(" OUTPUT ") { + return false + } + + switch keyword { + case "ALTER", "CREATE", "DROP", "RENAME", "TRUNCATE", + "INSERT", "UPDATE", "DELETE", "MERGE", + "GRANT", "REVOKE", "COMMENT", + "USE", "SET", + "BEGIN", "START", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE", + "VACUUM", "ANALYZE", "REINDEX", "CLUSTER", + "LISTEN", "UNLISTEN", "NOTIFY": + return true + case "CALL": + return databaseType == .postgresql + default: + return false + } + } + + private static func normalizedSQL(_ sql: String) -> String { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let collapsed = trimmed.replacingOccurrences( + of: #"\s+"#, + with: " ", + options: .regularExpression + ) + return " \(collapsed.uppercased()) " + } + + private static func leadingKeyword(in normalizedSQL: String) -> String? { + normalizedSQL + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: " ", maxSplits: 1) + .first + .map(String.init) + } +} diff --git a/Echo/Sources/Features/Account/Domain/AppleSignInCoordinator.swift b/Echo/Sources/Features/Account/Domain/AppleSignInCoordinator.swift index b62babfd3..884bb80f9 100644 --- a/Echo/Sources/Features/Account/Domain/AppleSignInCoordinator.swift +++ b/Echo/Sources/Features/Account/Domain/AppleSignInCoordinator.swift @@ -4,18 +4,24 @@ import AuthenticationServices /// Wraps ASAuthorizationController in an async interface. final class AppleSignInCoordinator: NSObject, ASAuthorizationControllerDelegate { private var continuation: CheckedContinuation? + private var controller: ASAuthorizationController? /// Triggers the Sign in with Apple flow and returns the credential. func signIn() async throws -> ASAuthorizationAppleIDCredential { + guard continuation == nil else { + throw AuthError.unknown("Apple sign-in is already in progress.") + } + let provider = ASAuthorizationAppleIDProvider() let request = provider.createRequest() request.requestedScopes = [.fullName, .email] let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = self + self.controller = controller return try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation + register(continuation) controller.performRequests() } } @@ -27,31 +33,55 @@ final class AppleSignInCoordinator: NSObject, ASAuthorizationControllerDelegate didCompleteWithAuthorization authorization: ASAuthorization ) { guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { - Task { @MainActor in - continuation?.resume(throwing: AuthError.unknown("Unexpected credential type.")) - continuation = nil - } + resumeFromDelegate(with: .failure(AuthError.unknown("Unexpected credential type."))) return } - Task { @MainActor in - continuation?.resume(returning: credential) - continuation = nil - } + resumeFromDelegate(with: .success(credential)) } nonisolated func authorizationController( controller: ASAuthorizationController, didCompleteWithError error: any Error ) { - let authError: AuthError + resumeFromDelegate(with: .failure(Self.mapAuthError(error))) + } + + func register(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func complete(with result: Result) { + let continuation = self.continuation + self.continuation = nil + controller = nil + + switch result { + case .success(let credential): + continuation?.resume(returning: credential) + case .failure(let error): + continuation?.resume(throwing: error) + } + } + + nonisolated static func mapAuthError(_ error: any Error) -> AuthError { if let asError = error as? ASAuthorizationError, asError.code == .canceled { - authError = .cancelled - } else { - authError = .unknown(error.localizedDescription) + return .cancelled + } + return .unknown(error.localizedDescription) + } + + nonisolated private func resumeFromDelegate( + with result: Result + ) { + if Thread.isMainThread { + MainActor.assumeIsolated { + complete(with: result) + } + return } - Task { @MainActor in - continuation?.resume(throwing: authError) - continuation = nil + + Task(priority: .userInitiated) { @MainActor in + complete(with: result) } } } diff --git a/Echo/Sources/Features/Account/Domain/AuthState.swift b/Echo/Sources/Features/Account/Domain/AuthState.swift index 5b7aafd87..dc61782aa 100644 --- a/Echo/Sources/Features/Account/Domain/AuthState.swift +++ b/Echo/Sources/Features/Account/Domain/AuthState.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import Supabase /// Observable auth state that drives the Account settings UI. /// Lives as a singleton on AppDirector and is injected into the environment. @@ -46,14 +47,26 @@ final class AuthState { do { let user = try await tokenStore.loadUser() let tokens = try await tokenStore.loadTokens() - if let user, tokens != nil { + if let user, let tokens, await ensureSupabaseSession(using: tokens) { currentUser = user + } else if user != nil || tokens != nil { + try? await tokenStore.clearAll() } } catch { // No saved session — stay signed out } } + @discardableResult + func ensureSupabaseSession() async -> Bool { + do { + guard let tokens = try await tokenStore.loadTokens() else { return false } + return await ensureSupabaseSession(using: tokens) + } catch { + return false + } + } + // MARK: - Sign In with Apple func signInWithApple(identityToken: Data, authorizationCode: Data, fullName: PersonNameComponents?) async { @@ -146,16 +159,26 @@ final class AuthState { // MARK: - Delete Account func deleteAccount() async throws { + error = nil isLoading = true defer { isLoading = false } - guard let tokens = try await tokenStore.loadTokens() else { - throw AuthError.notAuthenticated - } + do { + guard let tokens = try await tokenStore.loadTokens() else { + throw AuthError.notAuthenticated + } - try await backend.deleteAccount(accessToken: tokens.accessToken) - try await tokenStore.clearAll() - currentUser = nil + try await backend.deleteAccount(accessToken: tokens.accessToken) + try await tokenStore.clearAll() + currentUser = nil + } catch let authError as AuthError { + error = authError + throw authError + } catch { + let authError = AuthError.unknown(error.localizedDescription) + self.error = authError + throw authError + } } // MARK: - Update Profile @@ -201,4 +224,26 @@ final class AuthState { self.error = .unknown(error.localizedDescription) } } + + private func ensureSupabaseSession(using tokens: AuthTokens) async -> Bool { + guard let client = SupabaseConfig.sharedClient else { return true } + + do { + _ = try await client.auth.session + return true + } catch Supabase.AuthError.sessionMissing { + do { + guard let refreshToken = tokens.refreshToken else { return false } + _ = try await client.auth.setSession( + accessToken: tokens.accessToken, + refreshToken: refreshToken + ) + return true + } catch { + return false + } + } catch { + return true + } + } } diff --git a/Echo/Sources/Features/Account/Domain/SupabaseAuthBackend.swift b/Echo/Sources/Features/Account/Domain/SupabaseAuthBackend.swift index bad1a43ef..06ec3918d 100644 --- a/Echo/Sources/Features/Account/Domain/SupabaseAuthBackend.swift +++ b/Echo/Sources/Features/Account/Domain/SupabaseAuthBackend.swift @@ -120,9 +120,20 @@ nonisolated struct SupabaseAuthBackend: AuthBackend { // MARK: - Account Management func deleteAccount(accessToken: String) async throws { - // Supabase doesn't have a client-side delete — requires a server function or admin API - // For now, sign out. Full deletion requires a server-side Edge Function. - try await client.auth.signOut() + do { + let response: DeleteAccountResponse = try await client.functions.invoke( + "delete-account", + options: FunctionInvokeOptions( + body: DeleteAccountRequest() + ) + ) + + if response.success != true { + throw AuthError.unknown(response.error ?? "Delete account failed.") + } + } catch { + throw mapDeleteAccountError(error) + } } func linkAccount(method: AuthMethod, accessToken: String, payload: Data) async throws -> AuthUser { @@ -195,3 +206,61 @@ nonisolated struct SupabaseAuthBackend: AuthBackend { private extension Auth.User { // Supabase SDK uses AnyJSON for metadata } + +private extension SupabaseAuthBackend { + struct DeleteAccountRequest: Encodable {} + + struct DeleteAccountResponse: Decodable { + let success: Bool + let error: String? + } + + func mapDeleteAccountError(_ error: any Error) -> AuthError { + if let functionsError = error as? FunctionsError { + switch functionsError { + case .relayError: + return .unknown( + "Delete Account failed because the Supabase Edge Function relay could not reach 'delete-account'." + ) + case .httpError(let code, let data): + let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if code == 404 || code == 503 { + return .unknown( + "Delete Account requires a deployed Supabase Edge Function named 'delete-account'." + ) + } + + if let body, !body.isEmpty, let serverMessage = extractDeleteAccountMessage(from: body) { + return .unknown(serverMessage) + } + + return .unknown("Delete Account failed with HTTP \(code).") + } + } + + let message = String(describing: error) + if message.localizedCaseInsensitiveContains("delete-account") { + return .unknown( + "Delete Account requires a deployed Supabase Edge Function named 'delete-account'." + ) + } + return .unknown(message) + } + + func extractDeleteAccountMessage(from body: String) -> String? { + if let data = body.data(using: .utf8), + let payload = try? JSONDecoder().decode(DeleteAccountResponse.self, from: data), + let error = payload.error, + !error.isEmpty { + return error + } + + if body.isEmpty { + return nil + } + + return body + } +} diff --git a/Echo/Sources/Features/Account/Sync/E2E/E2EEnrollmentManager.swift b/Echo/Sources/Features/Account/Sync/E2E/E2EEnrollmentManager.swift index ce990db39..bf0c8dde5 100644 --- a/Echo/Sources/Features/Account/Sync/E2E/E2EEnrollmentManager.swift +++ b/Echo/Sources/Features/Account/Sync/E2E/E2EEnrollmentManager.swift @@ -86,6 +86,11 @@ final class E2EEnrollmentManager { error = nil + await checkEnrollmentStatus() + if isEnrolled { + throw E2EError.enrollmentFailed("Credential sync is already set up for this account. Enter your existing master password to unlock it, or use recovery if you forgot it.") + } + // 1. Generate salts let masterSalt = crypto.generateSalt() let recoverySalt = crypto.generateSalt() @@ -130,7 +135,8 @@ final class E2EEnrollmentManager { let userID = try await client.auth.session.user.id // Update profile - struct ProfileUpdate: Encodable { + struct ProfileEnrollmentRow: Encodable { + let id: UUID let e2e_enrolled: Bool let e2e_salt: String // base64 let e2e_wrapped_master_key: String // base64 @@ -138,14 +144,14 @@ final class E2EEnrollmentManager { let e2e_recovery_salt: String // base64 } try await client.from("profiles") - .update(ProfileUpdate( + .upsert(ProfileEnrollmentRow( + id: userID, e2e_enrolled: true, e2e_salt: masterSalt.base64EncodedString(), e2e_wrapped_master_key: wrappedMasterKey.base64EncodedString(), e2e_recovery_key_hash: recoveryHashHex, e2e_recovery_salt: recoverySalt.base64EncodedString() - )) - .eq("id", value: userID) + ), onConflict: "id") .execute() // Upload wrapped project keys @@ -165,7 +171,7 @@ final class E2EEnrollmentManager { project_id: serverProjectID, wrapped_key: wpk.wrappedKey.base64EncodedString(), nonce: Data().base64EncodedString() - )) + ), onConflict: "user_id,project_id") .execute() } @@ -288,7 +294,7 @@ final class E2EEnrollmentManager { project_id: serverProjectID, wrapped_key: rewrapped.base64EncodedString(), nonce: Data().base64EncodedString() - )) + ), onConflict: "user_id,project_id") .execute() } @@ -296,16 +302,17 @@ final class E2EEnrollmentManager { let newWrappedMasterKey = try crypto.wrapKey(newMasterKey, with: recoveryKEK) // 9. Update server - struct ProfileUpdate: Encodable { + struct ProfileRecoveryRow: Encodable { + let id: UUID let e2e_salt: String let e2e_wrapped_master_key: String } try await client.from("profiles") - .update(ProfileUpdate( + .upsert(ProfileRecoveryRow( + id: userID, e2e_salt: newSalt.base64EncodedString(), e2e_wrapped_master_key: newWrappedMasterKey.base64EncodedString() - )) - .eq("id", value: userID) + ), onConflict: "id") .execute() // 10. Store new Master Key locally diff --git a/Echo/Sources/Features/Account/Sync/SyncAdapter.swift b/Echo/Sources/Features/Account/Sync/SyncAdapter.swift index bcf7bca72..4e5184a43 100644 --- a/Echo/Sources/Features/Account/Sync/SyncAdapter.swift +++ b/Echo/Sources/Features/Account/Sync/SyncAdapter.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation /// Converts between Echo domain models and SyncDocument format. @@ -284,19 +285,15 @@ struct SyncAdapter: Sendable { /// Deterministic ID for a project's settings document. func settingsDocumentID(for projectID: UUID) -> UUID { - // Use a namespace UUID derived from the project ID so it's stable let input = "settings:\(projectID.uuidString)" - let hash = Array(input.utf8).withUnsafeBufferPointer { buffer -> [UInt8] in - var result = [UInt8](repeating: 0, count: 16) - for (i, byte) in buffer.enumerated() { - result[i % 16] ^= byte - } - return result - } - return UUID(uuid: (hash[0], hash[1], hash[2], hash[3], - hash[4], hash[5], hash[6], hash[7], - hash[8], hash[9], hash[10], hash[11], - hash[12], hash[13], hash[14], hash[15])) + let digest = SHA256.hash(data: Data(input.utf8)) + var bytes = Array(digest.prefix(16)) + bytes[6] = (bytes[6] & 0x0F) | 0x50 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return UUID(uuid: (bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15])) } // MARK: - Field Helpers diff --git a/Echo/Sources/Features/Account/Sync/SyncCheckpointStore.swift b/Echo/Sources/Features/Account/Sync/SyncCheckpointStore.swift index 36e24ce12..bd59cb2de 100644 --- a/Echo/Sources/Features/Account/Sync/SyncCheckpointStore.swift +++ b/Echo/Sources/Features/Account/Sync/SyncCheckpointStore.swift @@ -25,6 +25,10 @@ actor SyncCheckpointStore { checkpoints[projectID]?.checkpoint ?? 0 } + func hasCheckpoint(for projectID: UUID) -> Bool { + checkpoints[projectID] != nil + } + func update(projectID: UUID, checkpoint: UInt64) throws { checkpoints[projectID] = SyncCheckpoint( projectID: projectID, diff --git a/Echo/Sources/Features/Account/Sync/SyncClient.swift b/Echo/Sources/Features/Account/Sync/SyncClient.swift index 2fbec439b..b06b910c6 100644 --- a/Echo/Sources/Features/Account/Sync/SyncClient.swift +++ b/Echo/Sources/Features/Account/Sync/SyncClient.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import os.log import Supabase /// HTTP client for the Supabase sync RPC endpoints. @@ -9,6 +10,7 @@ import Supabase /// JWT injection and token refresh automatically. nonisolated final class SyncClient: Sendable { private let client: SupabaseClient + private let logger = Logger(subsystem: "dev.echodb.echo", category: "sync-client") init?() { guard let client = SupabaseConfig.sharedClient else { return nil } @@ -60,15 +62,42 @@ nonisolated final class SyncClient: Sendable { // MARK: - Push /// Push local changes to the server. - func push(changes: [SyncDocument]) async throws -> SyncPushResponse { + func push(changes: [SyncDocument], projectID: UUID) async throws -> SyncPushResponse { let params = SyncPushParams(p_changes: changes) - let response: SyncPushResponse = try await client.rpc( - "sync_push", - params: params - ).execute().value + do { + let response: SyncPushResponse = try await client.rpc( + "sync_push", + params: params + ).execute().value - return response + return response + } catch let error as PostgrestError where shouldRecoverFromDuplicateDocument(error) { + let removedCount = try await removeStaleDocumentsConflicting(with: changes, currentProjectID: projectID) + guard removedCount > 0 else { throw error } + + logger.warning("Removed \(removedCount) stale sync documents after duplicate-key conflict; retrying push") + + let response: SyncPushResponse = try await client.rpc( + "sync_push", + params: params + ).execute().value + + return response + } + } + + // MARK: - Pre-flight Check + + /// Check how many sync documents exist on the server for a given project. + /// Used to determine whether a merge strategy prompt is needed. + func cloudDocumentCount(projectID: UUID) async throws -> Int { + let response = try await client.from("sync_documents") + .select("*", head: true, count: .exact) + .eq("project_id", value: projectID) + .eq("is_deleted", value: false) + .execute() + return response.count ?? 0 } // MARK: - Project Registration @@ -93,6 +122,41 @@ nonisolated final class SyncClient: Sendable { )) .execute() } + + private func shouldRecoverFromDuplicateDocument(_ error: PostgrestError) -> Bool { + error.message.contains("sync_documents_pkey") + || (error.detail?.contains("sync_documents_pkey") ?? false) + } + + private func removeStaleDocumentsConflicting(with changes: [SyncDocument], currentProjectID: UUID) async throws -> Int { + let ids = Array(Set(changes.map(\.id))) + guard !ids.isEmpty else { return 0 } + + struct ExistingDocumentRow: Decodable { + let id: UUID + let project_id: UUID + let is_deleted: Bool + } + + let existingRows: [ExistingDocumentRow] = try await client.from("sync_documents") + .select("id, project_id, is_deleted") + .in("id", values: ids) + .execute() + .value + + let staleIDs = Array(Set(existingRows.lazy + .filter { $0.project_id != currentProjectID || $0.is_deleted } + .map(\.id))) + + guard !staleIDs.isEmpty else { return 0 } + + _ = try await client.from("sync_documents") + .delete(returning: .minimal) + .in("id", values: staleIDs) + .execute() + + return staleIDs.count + } } // MARK: - RPC Parameter Types diff --git a/Echo/Sources/Features/Account/Sync/SyncEngine+InitialSync.swift b/Echo/Sources/Features/Account/Sync/SyncEngine+InitialSync.swift new file mode 100644 index 000000000..9b5ffc0ac --- /dev/null +++ b/Echo/Sources/Features/Account/Sync/SyncEngine+InitialSync.swift @@ -0,0 +1,27 @@ +import Foundation + +extension SyncEngine { + func nextStartupRequirement() async throws -> (project: Project, summary: SyncDataSummary, action: SyncStartupAction)? { + guard let projectStore else { return nil } + + for project in projectStore.projects where project.isSyncEnabled { + let hasCheckpoint = await checkpointStore.hasCheckpoint(for: project.id) + let summary = try await checkSyncDataSummary(for: project) + let action = summary.startupAction(hasCheckpoint: hasCheckpoint) + if action != .none { + return (project, summary, action) + } + } + + return nil + } + + func hasPendingMergeDecision() async -> Bool { + do { + guard let requirement = try await nextStartupRequirement() else { return false } + return requirement.action == .promptForMerge + } catch { + return false + } + } +} diff --git a/Echo/Sources/Features/Account/Sync/SyncEngine.swift b/Echo/Sources/Features/Account/Sync/SyncEngine.swift index 8b654746d..a796175e2 100644 --- a/Echo/Sources/Features/Account/Sync/SyncEngine.swift +++ b/Echo/Sources/Features/Account/Sync/SyncEngine.swift @@ -1,6 +1,7 @@ import Foundation import Observation import os.log +import Supabase /// Orchestrates cloud sync between local Echo stores and the Supabase backend. /// @@ -27,7 +28,7 @@ final class SyncEngine { @ObservationIgnored private let syncClient: SyncClient @ObservationIgnored private let adapter: SyncAdapter @ObservationIgnored private let merger: SyncMerger - @ObservationIgnored private let checkpointStore: SyncCheckpointStore + @ObservationIgnored let checkpointStore: SyncCheckpointStore @ObservationIgnored private let dirtyTracker: SyncDirtyTracker @ObservationIgnored private let fieldEncryptor = E2EFieldEncryptor() @@ -116,6 +117,12 @@ final class SyncEngine { return } + if await hasPendingMergeDecision() { + logger.info("Sync is waiting for a merge decision before the initial sync can continue") + status = .idle + return + } + isSyncing = true status = .syncing @@ -147,6 +154,9 @@ final class SyncEngine { } catch is CancellationError { logger.debug("Sync cancelled") status = .idle + } catch Supabase.AuthError.sessionMissing { + logger.info("Skipping sync because the auth session is not ready yet") + status = .idle } catch { logger.error("Sync failed: \(error.localizedDescription)") status = .error(error.localizedDescription) @@ -231,7 +241,7 @@ final class SyncEngine { } else { let existing = connectionStore.connections.first { $0.id == doc.id } var connection = try adapter.applyToConnection(doc, existing: existing) - applyEncryptedCredentials(from: doc, projectID: project.id, keychainID: &connection.keychainIdentifier) + applyEncryptedCredentials(from: doc, projectID: project.id, keychainID: &connection.keychainIdentifier, displayName: connection.connectionName) try await connectionStore.updateConnection(connection) } @@ -254,7 +264,7 @@ final class SyncEngine { } else { let existing = connectionStore.identities.first { $0.id == doc.id } var identity = try adapter.applyToIdentity(doc, existing: existing) - applyEncryptedCredentials(from: doc, projectID: project.id, keychainID: &identity.keychainIdentifier) + applyEncryptedCredentials(from: doc, projectID: project.id, keychainID: &identity.keychainIdentifier, displayName: identity.name) try await connectionStore.updateIdentity(identity) } @@ -372,7 +382,7 @@ final class SyncEngine { guard !documents.isEmpty else { return } - let response = try await syncClient.push(changes: documents) + let response = try await syncClient.push(changes: documents, projectID: serverProjectID) logger.info("Pushed \(response.accepted) documents") if !response.conflicts.isEmpty { @@ -386,7 +396,8 @@ final class SyncEngine { /// Add encrypted password field to a sync document if E2E is active. private func addEncryptedCredentials(to doc: inout SyncDocument, keychainID: String?, projectID: UUID, hlc: UInt64) throws { - guard let keyStore = e2eKeyStore, keyStore.isUnlocked, + guard SyncPreferences.isCredentialSyncEnabled, + let keyStore = e2eKeyStore, keyStore.isUnlocked, let projectKey = keyStore.projectKey(for: projectID), let keychainID, !keychainID.isEmpty else { return } @@ -406,13 +417,16 @@ final class SyncEngine { // MARK: - E2E Credential Decryption (Pull) /// Decrypt and store credentials from a pulled sync document. - private func applyEncryptedCredentials(from doc: SyncDocument, projectID: UUID, keychainID: inout String?) { - guard let keyStore = e2eKeyStore, keyStore.isUnlocked, + /// If the local Keychain already has a different password, a conflict is recorded + /// instead of silently overwriting. + private func applyEncryptedCredentials(from doc: SyncDocument, projectID: UUID, keychainID: inout String?, displayName: String = "") { + guard SyncPreferences.isCredentialSyncEnabled, + let keyStore = e2eKeyStore, keyStore.isUnlocked, let projectKey = keyStore.projectKey(for: projectID), let encField = doc.fields["encryptedPassword"], encField.isEncrypted else { return } do { - let password = try fieldEncryptor.decryptField( + let cloudPassword = try fieldEncryptor.decryptField( field: encField, key: projectKey, collection: doc.collection, @@ -427,7 +441,26 @@ final class SyncEngine { } let vault = KeychainVault() - try vault.setPassword(password, account: keychainID!) + + // Check for conflict: local Keychain has a different password + if let localPassword = try? vault.getPassword(account: keychainID!), + !localPassword.isEmpty, + localPassword != cloudPassword { + // Record the conflict for user resolution + let name = displayName.isEmpty ? doc.id.uuidString : displayName + pendingCredentialConflicts.append(CredentialConflict( + id: doc.id, + collection: doc.collection, + displayName: name, + localPassword: localPassword, + cloudPassword: cloudPassword + )) + logger.info("Credential conflict detected for \(doc.id) — deferring to user") + return + } + + // No conflict — apply cloud password + try vault.setPassword(cloudPassword, account: keychainID!) } catch { logger.error("Failed to decrypt credential for \(doc.id): \(error.localizedDescription)") } @@ -435,13 +468,70 @@ final class SyncEngine { // MARK: - Initial Upload + /// Check whether the server already has data for this project and build a summary. + /// The caller uses this to decide whether to show a merge strategy prompt. + func checkSyncDataSummary(for project: Project) async throws -> SyncDataSummary { + guard let connectionStore else { + return SyncDataSummary(localConnections: 0, localIdentities: 0, localFolders: 0, localBookmarks: 0, cloudDocuments: 0) + } + + let userID = try await syncClient.currentUserID() + let serverID = syncClient.serverProjectID(localID: project.id, userID: userID) + let cloudCount = try await syncClient.cloudDocumentCount(projectID: serverID) + + return SyncDataSummary( + localConnections: connectionStore.connections.filter { $0.projectID == project.id }.count, + localIdentities: connectionStore.identities.filter { $0.projectID == project.id }.count, + localFolders: connectionStore.folders.filter { $0.projectID == project.id }.count, + localBookmarks: project.bookmarks.count, + cloudDocuments: cloudCount + ) + } + /// Upload all local data for a project to the server for the first time. /// Called when a user enables sync on an existing project. - func performInitialUpload(for project: Project) async throws { - guard let connectionStore, let projectStore else { return } + /// + /// - Parameter strategy: How to handle existing data on both sides. + /// - `.uploadLocal`: Push local data to cloud (original behavior). + /// - `.useCloud`: Pull cloud data and replace local state. Local-only items are deleted. + /// - `.merge`: Push local, then pull cloud — LWW resolves conflicts. + func performInitialUpload(for project: Project, strategy: SyncMergeStrategy = .merge) async throws { + guard let projectStore else { return } + + // Register project on server first (using per-user server ID) + let userID = try await syncClient.currentUserID() + let serverID = syncClient.serverProjectID(localID: project.id, userID: userID) + let sortOrder = projectStore.projects.firstIndex(where: { $0.id == project.id }) ?? 0 + try await syncClient.upsertProject(serverID: serverID, userID: userID, name: project.name, sortOrder: sortOrder) + + switch strategy { + case .uploadLocal: + try await uploadLocalData(for: project) + try await pullChanges(for: project, serverProjectID: serverID) + logger.info("Initial upload (uploadLocal) complete for '\(project.name)'") + + case .useCloud: + // Delete local data for this project, then pull everything from cloud + try await deleteLocalProjectData(for: project) + try await pullChanges(for: project, serverProjectID: serverID) + logger.info("Initial sync (useCloud) complete for '\(project.name)'") + + case .merge: + // Upload local first, then pull cloud — LWW resolves conflicts at field level + try await uploadLocalData(for: project) + try await pullChanges(for: project, serverProjectID: serverID) + logger.info("Initial sync (merge) complete for '\(project.name)'") + } + } + + /// Push all local data for a project to the server. + private func uploadLocalData(for project: Project) async throws { + guard let connectionStore else { return } var documents: [SyncDocument] = [] let hlc = clock.now() + let userID = try await syncClient.currentUserID() + let serverProjectID = syncClient.serverProjectID(localID: project.id, userID: userID) // Project itself documents.append(try adapter.toSyncDocument(project, hlc: hlc)) @@ -449,7 +539,9 @@ final class SyncEngine { // Connections belonging to this project let projectConnections = connectionStore.connections.filter { $0.projectID == project.id } for conn in projectConnections { - documents.append(try adapter.toSyncDocument(conn, hlc: hlc)) + var doc = try adapter.toSyncDocument(conn, hlc: hlc) + try addEncryptedCredentials(to: &doc, keychainID: conn.keychainIdentifier, projectID: project.id, hlc: hlc) + documents.append(doc) } // Folders belonging to this project @@ -461,7 +553,9 @@ final class SyncEngine { // Identities belonging to this project let projectIdentities = connectionStore.identities.filter { $0.projectID == project.id } for identity in projectIdentities { - documents.append(try adapter.toSyncDocument(identity, hlc: hlc)) + var doc = try adapter.toSyncDocument(identity, hlc: hlc) + try addEncryptedCredentials(to: &doc, keychainID: identity.keychainIdentifier, projectID: project.id, hlc: hlc) + documents.append(doc) } // Bookmarks @@ -476,21 +570,79 @@ final class SyncEngine { guard !documents.isEmpty else { return } - // Register project on server first (using per-user server ID) - let userID = try await syncClient.currentUserID() - let serverID = syncClient.serverProjectID(localID: project.id, userID: userID) - let sortOrder = projectStore.projects.firstIndex(where: { $0.id == project.id }) ?? 0 - try await syncClient.upsertProject(serverID: serverID, userID: userID, name: project.name, sortOrder: sortOrder) - // Push in batches of 100 let batchSize = 100 for batchStart in stride(from: 0, to: documents.count, by: batchSize) { let batchEnd = min(batchStart + batchSize, documents.count) let batch = Array(documents[batchStart..? private var idleTimerTask: Task? + private var triggerTask: Task? private var isRunning = false // MARK: - Init @@ -47,6 +48,8 @@ final class SyncScheduler { /// Stop the scheduler. Called on sign-out. func stop() { isRunning = false + triggerTask?.cancel() + triggerTask = nil debounceTask?.cancel() debounceTask = nil idleTimerTask?.cancel() @@ -60,13 +63,12 @@ final class SyncScheduler { func scheduleSync() { guard isRunning else { return } debounceTask?.cancel() - let interval = changeDebounceInterval - let engineRef = syncEngine - debounceTask = Task.detached { + debounceTask = Task { [weak self] in + guard let self else { return } do { - try await Task.sleep(for: .seconds(interval)) - guard !Task.isCancelled else { return } - await engineRef?.syncNow() + try await Task.sleep(for: .seconds(self.changeDebounceInterval)) + guard !Task.isCancelled, self.isRunning else { return } + await self.syncEngine?.syncNow() } catch { // Task was cancelled — another change came in } @@ -83,22 +85,22 @@ final class SyncScheduler { // MARK: - Private private func triggerSync() { - let engineRef = syncEngine - Task.detached { - await engineRef?.syncNow() + triggerTask?.cancel() + triggerTask = Task { [weak self] in + guard let self, self.isRunning else { return } + await self.syncEngine?.syncNow() } } private func startIdleTimer() { idleTimerTask?.cancel() - let interval = idleSyncInterval - let engineRef = syncEngine - idleTimerTask = Task.detached { + idleTimerTask = Task { [weak self] in + guard let self else { return } while !Task.isCancelled { do { - try await Task.sleep(for: .seconds(interval)) - guard !Task.isCancelled else { break } - await engineRef?.syncNow() + try await Task.sleep(for: .seconds(self.idleSyncInterval)) + guard !Task.isCancelled, self.isRunning else { break } + await self.syncEngine?.syncNow() } catch { break } diff --git a/Echo/Sources/Features/Account/Sync/SyncTypes.swift b/Echo/Sources/Features/Account/Sync/SyncTypes.swift index 70d38aeab..1ee7735ad 100644 --- a/Echo/Sources/Features/Account/Sync/SyncTypes.swift +++ b/Echo/Sources/Features/Account/Sync/SyncTypes.swift @@ -28,7 +28,7 @@ enum SyncCollection: String, Codable, Sendable, CaseIterable { switch self { case .connections: "Server addresses, ports, and connection options" case .folders: "Folder structure for organizing connections" - case .identities: "Saved login names (passwords stay in Keychain)" + case .identities: "Saved login names and identity metadata" case .projects: "Project names and configuration" case .settings: "App preferences and editor settings" case .bookmarks: "Saved queries and snippets" @@ -88,6 +88,81 @@ enum SyncPreferences { } return Set(stored.compactMap { SyncCollection(rawValue: $0) }).union(alwaysEnabled) } + + // MARK: - Credential Sync Toggle + + private static let credentialSyncKey = "sync.credentialSyncEnabled" + + static var isCredentialSyncEnabled: Bool { + // Default: true (enabled once E2E is enrolled) + UserDefaults.standard.object(forKey: credentialSyncKey) as? Bool ?? true + } + + static func setCredentialSyncEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: credentialSyncKey) + } +} + +// MARK: - Sync Merge Strategy + +/// How to handle the first sync when both local and cloud data exist for a project. +enum SyncMergeStrategy: Sendable { + /// Combine local and cloud data. Conflicts resolved by most recent change (LWW). + case merge + /// Replace local data with what's in the cloud. Local-only items are deleted. + case useCloud + /// Push local data to cloud, overwriting cloud versions where they conflict. + case uploadLocal +} + +enum SyncStartupAction: Sendable, Equatable { + case none + case promptForMerge + case pullCloud + case uploadLocal +} + +/// Summary of data counts for a project, used to inform the merge strategy prompt. +struct SyncDataSummary: Sendable { + let localConnections: Int + let localIdentities: Int + let localFolders: Int + let localBookmarks: Int + let cloudDocuments: Int + + var hasLocalData: Bool { + localConnections + localIdentities + localFolders + localBookmarks > 0 + } + + var hasCloudData: Bool { + cloudDocuments > 0 + } + + var needsMergeDecision: Bool { + hasLocalData && hasCloudData + } + + var localTotal: Int { + localConnections + localIdentities + localFolders + localBookmarks + } + + func startupAction(hasCheckpoint: Bool) -> SyncStartupAction { + guard !hasCheckpoint else { return .none } + if needsMergeDecision { return .promptForMerge } + if hasCloudData { return .pullCloud } + if hasLocalData { return .uploadLocal } + return .none + } +} + +/// A credential conflict detected during pull — local Keychain has a different +/// password than what the cloud has for the same connection/identity. +struct CredentialConflict: Identifiable, Sendable, Equatable { + let id: UUID + let collection: SyncCollection + let displayName: String + let localPassword: String + let cloudPassword: String } // MARK: - Sync Field diff --git a/Echo/Sources/Features/Account/Views/CredentialConflictSheet.swift b/Echo/Sources/Features/Account/Views/CredentialConflictSheet.swift new file mode 100644 index 000000000..f00980af4 --- /dev/null +++ b/Echo/Sources/Features/Account/Views/CredentialConflictSheet.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// Presented when pulling credentials from the cloud detects that the local Keychain +/// has different passwords for the same connections/identities. +struct CredentialConflictSheet: View { + let conflicts: [CredentialConflict] + let onResolve: (Bool) -> Void // true = use cloud, false = keep local + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + VStack(alignment: .leading, spacing: SpacingTokens.sm) { + Label("Credential Conflict", systemImage: "exclamationmark.lock") + .font(TypographyTokens.headline) + + Text("\(conflicts.count) credential\(conflicts.count == 1 ? "" : "s") differ between this device and the cloud. Which version would you like to keep?") + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Text.secondary) + } + .padding(.bottom, SpacingTokens.xs) + + // List affected items + ForEach(conflicts) { conflict in + Label { + Text(conflict.displayName) + } icon: { + Image(systemName: conflict.collection == .connections ? "externaldrive" : "person.crop.circle") + .foregroundStyle(ColorTokens.Text.secondary) + } + } + } + + Section { + Button { + onResolve(true) + dismiss() + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Use Cloud Passwords") + Text("Replace local passwords with the cloud versions. Use this if your cloud data is more up to date.") + .font(TypographyTokens.detail) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } icon: { + Image(systemName: "icloud.and.arrow.down") + .foregroundStyle(ColorTokens.Text.secondary) + .frame(width: 20) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button { + onResolve(false) + dismiss() + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Keep Local Passwords") + Text("Keep the passwords already on this device. They will be uploaded to the cloud on the next sync.") + .font(TypographyTokens.detail) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } icon: { + Image(systemName: "laptopcomputer") + .foregroundStyle(ColorTokens.Text.secondary) + .frame(width: 20) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + .frame(width: 420, height: min(CGFloat(300 + conflicts.count * 30), 500)) + } +} diff --git a/Echo/Sources/Features/Account/Views/E2EEnrollmentView.swift b/Echo/Sources/Features/Account/Views/E2EEnrollmentView.swift index dd1537a53..9cb3c39a2 100644 --- a/Echo/Sources/Features/Account/Views/E2EEnrollmentView.swift +++ b/Echo/Sources/Features/Account/Views/E2EEnrollmentView.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers /// Step 1: Master password → Step 2: Recovery key display → Step 3: Confirmation. struct E2EEnrollmentView: View { @Bindable var enrollmentManager: E2EEnrollmentManager + let onComplete: () async -> Void @Environment(\.dismiss) private var dismiss @State private var step: EnrollmentStep = .password @@ -32,58 +33,60 @@ struct E2EEnrollmentView: View { doneStep } } - .frame(width: 460, height: 400) + .frame(width: 520, height: sheetHeight) } // MARK: - Step 1: Master Password private var passwordStep: some View { - Form { - Section { - VStack(alignment: .leading, spacing: SpacingTokens.sm) { - Label("Create Master Password", systemImage: "lock.shield") - .font(TypographyTokens.headline) - - Text("This password encrypts your database credentials before they leave this device. Echo cannot reset it.") - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Text.secondary) - } - .padding(.bottom, SpacingTokens.xs) - - SecureField("", text: $password, prompt: Text("Master password")) - - SecureField("", text: $confirmPassword, prompt: Text("Confirm master password")) - - if let errorMessage { - Text(errorMessage) - .font(TypographyTokens.detail) - .foregroundStyle(ColorTokens.Status.error) - } - } - - Section { - HStack { - Button("Cancel") { dismiss() } - - Spacer() + SheetLayout( + title: "Create Master Password", + icon: "lock.shield", + subtitle: "This password encrypts your database credentials before they leave this device. Echo cannot reset it.", + primaryAction: "Continue", + canSubmit: canContinue, + isSubmitting: isProcessing, + errorMessage: errorMessage, + onSubmit: { await beginEnrollment() }, + onCancel: { dismiss() } + ) { + Form { + Section { + PropertyRow(title: "Master Password") { + SecureField("", text: $password, prompt: Text("At least 8 characters")) + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + } - Button("Continue") { - Task { await beginEnrollment() } + PropertyRow(title: "Confirm Password") { + SecureField("", text: $confirmPassword, prompt: Text("Re-enter password")) + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) } - .buttonStyle(.bordered) - .keyboardShortcut(.defaultAction) - .disabled(!canContinue || isProcessing) + } footer: { + Text("Use a password you can remember. If you forget it, only your recovery key can restore access.") } } + .formStyle(.grouped) + .scrollContentBackground(.hidden) } - .formStyle(.grouped) - .scrollContentBackground(.hidden) } private var canContinue: Bool { password.count >= 8 && password == confirmPassword } + private var sheetHeight: CGFloat { + switch step { + case .password: + 320 + case .recoveryKey: + 520 + case .done: + 320 + } + } + private func saveRecoveryKeyToFile() { let panel = NSSavePanel() panel.nameFieldStringValue = "Echo Recovery Key.txt" @@ -115,6 +118,7 @@ struct E2EEnrollmentView: View { do { recoveryWords = try await enrollmentManager.enroll(password: password) + SyncPreferences.setCredentialSyncEnabled(true) step = .recoveryKey } catch { errorMessage = error.localizedDescription @@ -124,69 +128,84 @@ struct E2EEnrollmentView: View { // MARK: - Step 2: Recovery Key private var recoveryKeyStep: some View { - Form { - Section { - VStack(alignment: .leading, spacing: SpacingTokens.sm) { - Label("Save Your Recovery Key", systemImage: "key") - .font(TypographyTokens.headline) - - Text("If you forget your master password, this is the **only way** to recover your encrypted credentials. Write it down and store it somewhere safe.") - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Text.secondary) + SheetLayoutCustomFooter(title: "Save Your Recovery Key") { + VStack(alignment: .leading, spacing: SpacingTokens.md) { + HStack(spacing: SpacingTokens.sm) { + Image(systemName: "key") + .font(.system(size: 18)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Color.accentColor, in: .rect(cornerRadius: ShapeTokens.CornerRadius.medium)) + + VStack(alignment: .leading, spacing: SpacingTokens.xxs2) { + Text("Save Your Recovery Key") + .font(TypographyTokens.prominent.weight(.semibold)) + + Text("If you forget your master password, this is the only way to recover your encrypted credentials. Write it down and store it somewhere safe.") + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Text.secondary) + } + + Spacer() } + .padding(.horizontal, SpacingTokens.lg) + .padding(.top, SpacingTokens.md) .padding(.bottom, SpacingTokens.xs) - // 3 columns × 8 rows grid - Grid(alignment: .leading, horizontalSpacing: SpacingTokens.lg, verticalSpacing: SpacingTokens.xs) { - ForEach(0..<8, id: \.self) { row in - GridRow { - ForEach(0..<3, id: \.self) { col in - let idx = row * 3 + col - HStack(spacing: 4) { - Text("\(idx + 1).") - .font(TypographyTokens.detailMono) - .foregroundStyle(ColorTokens.Text.tertiary) - .frame(width: 22, alignment: .trailing) - Text(recoveryWords[idx]) - .font(TypographyTokens.codeMedium) + Divider() + + VStack(alignment: .leading, spacing: SpacingTokens.md) { + Grid(alignment: .leading, horizontalSpacing: SpacingTokens.lg, verticalSpacing: SpacingTokens.xs) { + ForEach(0..<8, id: \.self) { row in + GridRow { + ForEach(0..<3, id: \.self) { col in + let idx = row * 3 + col + HStack(spacing: 4) { + Text("\(idx + 1).") + .font(TypographyTokens.detailMono) + .foregroundStyle(ColorTokens.Text.tertiary) + .frame(width: 22, alignment: .trailing) + Text(recoveryWords[idx]) + .font(TypographyTokens.codeMedium) + } + .frame(minWidth: 118, alignment: .leading) } - .frame(minWidth: 110, alignment: .leading) } } } - } - .padding(SpacingTokens.md) - .background(ColorTokens.Background.secondary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + .padding(SpacingTokens.md) + .background(ColorTokens.Background.secondary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) - HStack(spacing: SpacingTokens.sm) { - Button("Copy to Clipboard") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(recoveryWords.joined(separator: " "), forType: .string) - } + HStack(spacing: SpacingTokens.sm) { + Button("Copy to Clipboard") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(recoveryWords.joined(separator: " "), forType: .string) + } - Button("Save to File…") { - saveRecoveryKeyToFile() + Button("Save to File…") { + saveRecoveryKeyToFile() + } } + .font(TypographyTokens.formDescription) + + Toggle("I have saved this recovery key in a safe place", isOn: $savedRecoveryKey) + .toggleStyle(.checkbox) + .font(TypographyTokens.formLabel) } - .font(TypographyTokens.formDescription) + .padding(.horizontal, SpacingTokens.lg) + .padding(.bottom, SpacingTokens.lg) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } footer: { + Button("Back") { step = .password } - Section { - Toggle("I have saved this recovery key in a safe place", isOn: $savedRecoveryKey) - .toggleStyle(.checkbox) + Spacer() - HStack { - Button("Back") { step = .password } - Spacer() - Button("Finish") { step = .done } - .buttonStyle(.bordered) - .keyboardShortcut(.defaultAction) - .disabled(!savedRecoveryKey) - } - } + Button("Finish") { step = .done } + .buttonStyle(.bordered) + .keyboardShortcut(.defaultAction) + .disabled(!savedRecoveryKey) } - .formStyle(.grouped) - .scrollContentBackground(.hidden) } // MARK: - Step 3: Done @@ -210,7 +229,10 @@ struct E2EEnrollmentView: View { Spacer() - Button("Done") { dismiss() } + Button("Done") { + Task { await onComplete() } + dismiss() + } .buttonStyle(.bordered) .keyboardShortcut(.defaultAction) .padding(.bottom, SpacingTokens.lg) diff --git a/Echo/Sources/Features/Account/Views/E2ERecoveryView.swift b/Echo/Sources/Features/Account/Views/E2ERecoveryView.swift index 4029c672f..0156bb115 100644 --- a/Echo/Sources/Features/Account/Views/E2ERecoveryView.swift +++ b/Echo/Sources/Features/Account/Views/E2ERecoveryView.swift @@ -1,8 +1,10 @@ import SwiftUI +import UniformTypeIdentifiers /// Recovery flow: enter 24-word mnemonic + set a new master password. struct E2ERecoveryView: View { @Bindable var enrollmentManager: E2EEnrollmentManager + let onRecovered: () async -> Void @Environment(\.dismiss) private var dismiss @State private var mnemonicText = "" @@ -11,6 +13,7 @@ struct E2ERecoveryView: View { @State private var isProcessing = false @State private var errorMessage: String? @State private var isRecovered = false + @State private var showRecoveryFileImporter = false var body: some View { if isRecovered { @@ -43,6 +46,10 @@ struct E2ERecoveryView: View { Text("Separate words with spaces") .font(TypographyTokens.detail) .foregroundStyle(ColorTokens.Text.tertiary) + + Button("Import Recovery Key File…") { + showRecoveryFileImporter = true + } } Section("New Master Password") { @@ -74,6 +81,17 @@ struct E2ERecoveryView: View { .formStyle(.grouped) .scrollContentBackground(.hidden) .frame(width: 460, height: 420) + .fileImporter( + isPresented: $showRecoveryFileImporter, + allowedContentTypes: [.plainText] + ) { result in + switch result { + case .success(let url): + importRecoveryKey(from: url) + case .failure(let error): + errorMessage = error.localizedDescription + } + } } private var canRecover: Bool { @@ -92,12 +110,42 @@ struct E2ERecoveryView: View { do { try await enrollmentManager.recover(mnemonic: words, newPassword: newPassword) + SyncPreferences.setCredentialSyncEnabled(true) + await onRecovered() isRecovered = true } catch { errorMessage = error.localizedDescription } } + private func importRecoveryKey(from url: URL) { + let didAccess = url.startAccessingSecurityScopedResource() + defer { + if didAccess { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let content = try String(contentsOf: url, encoding: .utf8) + let words = content + .lowercased() + .split(whereSeparator: { !$0.isLetter }) + .map(String.init) + .filter { $0.count > 1 } + + guard words.count >= 24 else { + errorMessage = "The selected file does not contain a valid 24-word recovery key." + return + } + + mnemonicText = Array(words.suffix(24)).joined(separator: " ") + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + private var recoveredContent: some View { VStack(spacing: SpacingTokens.lg) { Spacer() diff --git a/Echo/Sources/Features/Account/Views/E2EUnlockView.swift b/Echo/Sources/Features/Account/Views/E2EUnlockView.swift index 89db884f1..c112e1fc0 100644 --- a/Echo/Sources/Features/Account/Views/E2EUnlockView.swift +++ b/Echo/Sources/Features/Account/Views/E2EUnlockView.swift @@ -4,6 +4,7 @@ import SwiftUI /// Shown when E2E is enrolled but the master key isn't in the local Keychain. struct E2EUnlockView: View { @Bindable var enrollmentManager: E2EEnrollmentManager + let onUnlock: () async -> Void @Environment(\.dismiss) private var dismiss @State private var password = "" @@ -13,52 +14,50 @@ struct E2EUnlockView: View { @State private var attemptCount = 0 var body: some View { - Form { - Section { - VStack(alignment: .leading, spacing: SpacingTokens.sm) { - Label("Unlock Credentials", systemImage: "lock.shield") - .font(TypographyTokens.headline) - - Text("Enter your master password to decrypt your synced credentials on this device.") - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Text.secondary) - } - - SecureField("", text: $password, prompt: Text("Master password")) - .onSubmit { Task { await unlock() } } - - if let errorMessage { - Text(errorMessage) - .font(TypographyTokens.detail) - .foregroundStyle(ColorTokens.Status.error) + SheetLayoutCustomFooter(title: "Unlock Credentials") { + Form { + Section { + PropertyRow( + title: "Master Password", + subtitle: "Enter your master password to decrypt synced credentials on this Mac." + ) { + SecureField("", text: $password, prompt: Text("Enter password")) + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + .onSubmit { Task { await unlock() } } + } } } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } footer: { + if let errorMessage { + Text(errorMessage) + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Status.error) + .lineLimit(2) + } - Section { - HStack { - Button("Skip") { dismiss() } + Button("Skip") { dismiss() } - if attemptCount >= 2 { - Button("Forgot Password?") { showRecovery = true } - .font(TypographyTokens.formDescription) - } + if attemptCount >= 2 { + Button("Forgot Password?") { showRecovery = true } + } - Spacer() + Spacer() - Button("Unlock") { - Task { await unlock() } - } - .buttonStyle(.bordered) - .keyboardShortcut(.defaultAction) - .disabled(password.isEmpty || isProcessing) - } + Button("Unlock") { + Task { await unlock() } } + .buttonStyle(.bordered) + .keyboardShortcut(.defaultAction) + .disabled(password.isEmpty || isProcessing) } - .formStyle(.grouped) - .scrollContentBackground(.hidden) .frame(width: 400, height: 260) .sheet(isPresented: $showRecovery) { - E2ERecoveryView(enrollmentManager: enrollmentManager) + E2ERecoveryView(enrollmentManager: enrollmentManager) { + await onUnlock() + } } } @@ -69,6 +68,8 @@ struct E2EUnlockView: View { do { try await enrollmentManager.unlock(password: password) + SyncPreferences.setCredentialSyncEnabled(true) + await onUnlock() dismiss() } catch { attemptCount += 1 diff --git a/Echo/Sources/Features/Account/Views/SignedInAccountCard+Bindings.swift b/Echo/Sources/Features/Account/Views/SignedInAccountCard+Bindings.swift new file mode 100644 index 000000000..41a6a7186 --- /dev/null +++ b/Echo/Sources/Features/Account/Views/SignedInAccountCard+Bindings.swift @@ -0,0 +1,104 @@ +import SwiftUI + +extension AccountDetailSheet { + + // MARK: - Credential Sync Description + + var credentialSyncDescription: String { + if isCheckingEnrollment { + return "Checking status…" + } + if e2eManager.isEnrolled { + if e2eManager.isUnlocked { + return "Passwords encrypted end-to-end" + } else { + return "Enter your master password to sync passwords" + } + } + return "Encrypt passwords before syncing" + } + + // MARK: - Credential Sync Binding + + var credentialSyncBinding: Binding { + Binding( + get: { + e2eManager.isEnrolled && SyncPreferences.isCredentialSyncEnabled + }, + set: { newValue in + if newValue { + if e2eManager.isEnrolled { + if e2eManager.isUnlocked { + SyncPreferences.setCredentialSyncEnabled(true) + } else { + showE2EUnlock = true + } + } else { + showE2EEnrollment = true + } + } else { + SyncPreferences.setCredentialSyncEnabled(false) + } + } + ) + } + + // MARK: - Project Sync Binding + + func projectSyncBinding(for project: Project) -> Binding { + Binding( + get: { project.isSyncEnabled }, + set: { newValue in + if newValue { + // Enabling sync — check if server already has data + Task { await enableSyncOnProject(project) } + } else { + // Disabling sync — simple toggle + guard let store = AppDirector.shared.projectStore as ProjectStore?, + var updated = store.projects.first(where: { $0.id == project.id }) else { return } + updated.isSyncEnabled = false + Task { try? await store.updateProject(updated) } + } + } + ) + } + + /// Enable sync on a project, checking for server data first. + /// If both local and cloud data exist, shows the merge strategy prompt. + func enableSyncOnProject(_ project: Project) async { + guard let syncEngine = AppDirector.shared.syncEngine else { return } + + do { + let summary = try await syncEngine.checkSyncDataSummary(for: project) + + if summary.needsMergeDecision { + // Both sides have data — show merge strategy prompt + mergeStrategySummary = summary + mergeStrategyProject = project + showMergeStrategySheet = true + } else if summary.hasCloudData { + // Only cloud has data — pull from cloud + _ = await performSyncWithStrategy(project: project, strategy: .useCloud) + } else { + // Only local data or both empty — upload local + _ = await performSyncWithStrategy(project: project, strategy: .uploadLocal) + } + } catch { + // Fallback: just enable and upload (original behavior) + _ = await performSyncWithStrategy(project: project, strategy: .uploadLocal) + } + } + + // MARK: - Sync Collection Binding + + var hasSyncEnabledProjects: Bool { + AppDirector.shared.projectStore.projects.contains { $0.isSyncEnabled } + } + + func syncCollectionBinding(for collection: SyncCollection) -> Binding { + Binding( + get: { SyncPreferences.isEnabled(collection) }, + set: { SyncPreferences.setEnabled(collection, enabled: $0) } + ) + } +} diff --git a/Echo/Sources/Features/Account/Views/SignedInAccountCard+Components.swift b/Echo/Sources/Features/Account/Views/SignedInAccountCard+Components.swift new file mode 100644 index 000000000..2333e2aa0 --- /dev/null +++ b/Echo/Sources/Features/Account/Views/SignedInAccountCard+Components.swift @@ -0,0 +1,121 @@ +import SwiftUI + +extension SignedInAccountCard { + + // MARK: - Sync Summary (inline, one line) + + @ViewBuilder + var syncSummary: some View { + if let engine = syncEngine { + HStack(spacing: 4) { + switch engine.status { + case .idle: + if let lastSync = engine.lastSyncedAt { + Image(systemName: "checkmark.icloud") + .foregroundStyle(ColorTokens.Status.success) + Text("Synced \(lastSync, format: .relative(presentation: .named))") + } else { + Image(systemName: "icloud") + .foregroundStyle(ColorTokens.Text.tertiary) + Text("Sync available") + } + case .syncing: + ProgressView() + .controlSize(.mini) + Text("Syncing…") + case .error: + Image(systemName: "exclamationmark.icloud") + .foregroundStyle(ColorTokens.Status.error) + Text("Sync error — tap to retry") + case .offline: + Image(systemName: "icloud.slash") + .foregroundStyle(ColorTokens.Text.tertiary) + Text("Offline") + case .disabled: + Image(systemName: "icloud.slash") + .foregroundStyle(ColorTokens.Text.tertiary) + Text("Sync disabled") + } + } + .font(TypographyTokens.detail) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } + + @ViewBuilder + func syncRefreshButton(_ engine: SyncEngine) -> some View { + Button { + Task(name: "account-card-sync-refresh") { + await engine.syncNow() + } + } label: { + ZStack { + Circle() + .fill(Color.primary.opacity(isRefreshHovered ? 0.08 : 0)) + + Group { + if engine.status == .syncing { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: "arrow.clockwise") + .imageScale(.medium) + } + } + .foregroundStyle(ColorTokens.Text.primary) + } + .frame(width: 32, height: 32) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .glassEffect(.regular.interactive(), in: .circle) + .help(engine.status == .syncing ? "Syncing" : "Sync Now") + .disabled(engine.status == .syncing || engine.status == .disabled) + .onHover { isRefreshHovered = $0 } + } + + // MARK: - Avatar + + @ViewBuilder + var accountAvatar: some View { + if let avatarURL = authState.currentUser?.avatarURL { + AsyncImage(url: avatarURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: 48, height: 48) + .clipShape(Circle()) + default: + initialsAvatar + } + } + } else { + initialsAvatar + } + } + + var initialsAvatar: some View { + ZStack { + Circle() + .fill(.quaternary) + .frame(width: 48, height: 48) + + Text(avatarInitials) + .font(TypographyTokens.statNumber) + .foregroundStyle(ColorTokens.Text.secondary) + } + } + + var avatarInitials: String { + let name = authState.currentUser?.displayName + ?? authState.currentUser?.email + ?? "U" + let parts = name.split(separator: " ") + if parts.count >= 2 { + return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased() + } + return String(name.prefix(2)).uppercased() + } +} diff --git a/Echo/Sources/Features/Account/Views/SignedInAccountCard+DetailSections.swift b/Echo/Sources/Features/Account/Views/SignedInAccountCard+DetailSections.swift new file mode 100644 index 000000000..adb0a776d --- /dev/null +++ b/Echo/Sources/Features/Account/Views/SignedInAccountCard+DetailSections.swift @@ -0,0 +1,135 @@ +import SwiftUI + +extension AccountDetailSheet { + + // MARK: - Profile + + var profileSection: some View { + Section("Profile") { + // Name + if isEditingName { + HStack { + TextField("", text: $editedName, prompt: Text("Your name")) + .textFieldStyle(.roundedBorder) + .onSubmit { saveDisplayName() } + + Button("Save") { saveDisplayName() } + .buttonStyle(.bordered) + .keyboardShortcut(.defaultAction) + .controlSize(.small) + .disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty) + + Button("Cancel") { isEditingName = false } + .controlSize(.small) + } + } else { + PropertyRow(title: "Name") { + HStack(spacing: SpacingTokens.xs) { + Text(authState.currentUser?.displayName ?? "Not set") + .foregroundStyle(authState.currentUser?.displayName != nil ? ColorTokens.Text.primary : ColorTokens.Text.tertiary) + Button { + editedName = authState.currentUser?.displayName ?? "" + isEditingName = true + } label: { + Image(systemName: "pencil") + .font(TypographyTokens.detail) + .foregroundStyle(ColorTokens.Text.tertiary) + } + .buttonStyle(.plain) + } + } + } + + // Email + if let email = authState.currentUser?.email { + PropertyRow(title: "Email") { + Text(email) + .foregroundStyle(ColorTokens.Text.secondary) + } + } + + // Auth Method + if let method = authState.currentUser?.authMethod { + PropertyRow(title: "Sign-in method") { + HStack(spacing: 4) { + authMethodIcon(method) + Text(method.displayName) + } + } + } + } + } + + // MARK: - Actions + + var actionsSection: some View { + Section { + HStack { + Button("Sign Out") { + Task { + await authState.signOut() + dismiss() + } + } + + Spacer() + + Button("Delete Account", role: .destructive) { + showDeleteConfirmation = true + } + .font(TypographyTokens.formDescription) + .buttonStyle(.bordered) + .tint(ColorTokens.Status.error) + } + .alert("Delete Account", isPresented: $showDeleteConfirmation) { + Button("Delete", role: .destructive) { + Task { + do { + try await authState.deleteAccount() + dismiss() + } catch { + return + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete your account and all synced data. This action cannot be undone.") + } + + if let error = authState.error { + Text(error.localizedDescription) + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Status.error) + } + } + } + + // MARK: - Auth Method Icon + + @ViewBuilder + func authMethodIcon(_ method: AuthMethod) -> some View { + switch method { + case .google: + Image("GoogleLogo") + .resizable() + .scaledToFit() + .frame(width: 13, height: 13) + case .apple: + Image(systemName: "apple.logo") + .font(TypographyTokens.detail) + case .email: + Image(systemName: "envelope.fill") + .font(TypographyTokens.detail) + } + } + + // MARK: - Helpers + + func saveDisplayName() { + let name = editedName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + isEditingName = false + Task { await authState.updateDisplayName(name) } + } +} diff --git a/Echo/Sources/Features/Account/Views/SignedInAccountCard+DetailSheet.swift b/Echo/Sources/Features/Account/Views/SignedInAccountCard+DetailSheet.swift new file mode 100644 index 000000000..cc45e820c --- /dev/null +++ b/Echo/Sources/Features/Account/Views/SignedInAccountCard+DetailSheet.swift @@ -0,0 +1,150 @@ +import SwiftUI + +// MARK: - Account Detail Sheet + +struct AccountDetailSheet: View { + @Bindable var authState: AuthState + var syncEngine: SyncEngine? + @Environment(\.dismiss) var dismiss + + @State var isEditingName = false + @State var editedName = "" + @State var showDeleteConfirmation = false + @State var showE2EEnrollment = false + @State var showE2EUnlock = false + @State var isCheckingEnrollment = false + + // Merge strategy prompt state + @State var mergeStrategySummary: SyncDataSummary? + @State var mergeStrategyProject: Project? + @State var showMergeStrategySheet = false + + // Credential conflict state + @State var showCredentialConflictSheet = false + + var e2eManager: E2EEnrollmentManager { + AppDirector.shared.e2eEnrollmentManager + } + + var body: some View { + Form { + profileSection + + if let syncEngine { + projectsSyncSection + syncSection(syncEngine) + } + + actionsSection + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .frame(width: 440, height: 700) + .task { await prepareSheetState() } + .onChange(of: syncEngine?.pendingCredentialConflicts.count ?? 0) { + if let conflicts = syncEngine?.pendingCredentialConflicts, !conflicts.isEmpty { + showCredentialConflictSheet = true + } + } + .sheet(isPresented: $showE2EEnrollment) { + E2EEnrollmentView(enrollmentManager: e2eManager) { + await handleCredentialSetupCompletion() + } + } + .sheet(isPresented: $showE2EUnlock) { + E2EUnlockView(enrollmentManager: e2eManager) { + await handleCredentialSetupCompletion() + } + } + .sheet(isPresented: $showMergeStrategySheet) { + if let summary = mergeStrategySummary, let project = mergeStrategyProject { + SyncMergeStrategySheet(summary: summary, projectName: project.name) { strategy in + Task { + let succeeded = await performSyncWithStrategy(project: project, strategy: strategy) + if succeeded { + await presentNextStartupRequirementIfNeeded() + } + } + } + } + } + .sheet(isPresented: $showCredentialConflictSheet) { + if let conflicts = syncEngine?.pendingCredentialConflicts, !conflicts.isEmpty { + CredentialConflictSheet(conflicts: conflicts) { useCloud in + syncEngine?.resolveAllCredentialConflicts(useCloud: useCloud) + } + } + } + } + + func prepareSheetState() async { + await refreshEnrollmentStatus() + if let conflicts = syncEngine?.pendingCredentialConflicts, !conflicts.isEmpty { + showCredentialConflictSheet = true + } + if e2eManager.isEnrolled && SyncPreferences.isCredentialSyncEnabled && !e2eManager.isUnlocked { + showE2EUnlock = true + return + } + await presentNextStartupRequirementIfNeeded() + } + + func refreshEnrollmentStatus() async { + isCheckingEnrollment = true + await e2eManager.checkEnrollmentStatus() + await e2eManager.tryAutoUnlock() + isCheckingEnrollment = false + } + + func handleCredentialSetupCompletion() async { + await presentNextStartupRequirementIfNeeded() + if !showMergeStrategySheet { + await syncEngine?.syncNow() + } + } + + func presentNextStartupRequirementIfNeeded() async { + guard let syncEngine else { return } + + do { + guard let requirement = try await syncEngine.nextStartupRequirement() else { return } + switch requirement.action { + case .promptForMerge: + mergeStrategySummary = requirement.summary + mergeStrategyProject = requirement.project + showMergeStrategySheet = true + case .pullCloud: + let succeeded = await performSyncWithStrategy(project: requirement.project, strategy: .useCloud) + if succeeded { + await presentNextStartupRequirementIfNeeded() + } + case .uploadLocal: + let succeeded = await performSyncWithStrategy(project: requirement.project, strategy: .uploadLocal) + if succeeded { + await presentNextStartupRequirementIfNeeded() + } + case .none: + break + } + } catch { + return + } + } + + func performSyncWithStrategy(project: Project, strategy: SyncMergeStrategy) async -> Bool { + guard let syncEngine else { return false } + do { + // Enable sync on the project first + if let store = AppDirector.shared.projectStore as ProjectStore?, + var updated = store.projects.first(where: { $0.id == project.id }) { + updated.isSyncEnabled = true + try await store.updateProject(updated) + } + try await syncEngine.performInitialUpload(for: project, strategy: strategy) + return true + } catch { + // Error will be reflected in sync status + return false + } + } +} diff --git a/Echo/Sources/Features/Account/Views/SignedInAccountCard+SyncSections.swift b/Echo/Sources/Features/Account/Views/SignedInAccountCard+SyncSections.swift new file mode 100644 index 000000000..187bf8715 --- /dev/null +++ b/Echo/Sources/Features/Account/Views/SignedInAccountCard+SyncSections.swift @@ -0,0 +1,124 @@ +import SwiftUI + +extension AccountDetailSheet { + + // MARK: - Sync + + func syncSection(_ engine: SyncEngine) -> some View { + Section { + PropertyRow(title: "Status", subtitle: syncStatusDescription(engine)) { + syncStatusAccessory(engine) + } + + credentialSyncToggle + + ForEach(SyncCollection.userToggleable, id: \.self) { collection in + PropertyRow( + title: collection.displayName, + subtitle: syncCollectionDescription(collection) + ) { + Toggle("", isOn: syncCollectionBinding(for: collection)) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + } + } header: { + Text("Cloud Sync") + } + } + + // MARK: - Credential Sync Toggle + + var credentialSyncToggle: some View { + PropertyRow(title: "Credentials", subtitle: credentialSyncDescription) { + HStack(spacing: SpacingTokens.xs) { + if isCheckingEnrollment { + ProgressView() + .controlSize(.mini) + } else if e2eManager.isEnrolled && !e2eManager.isUnlocked && SyncPreferences.isCredentialSyncEnabled { + Image(systemName: "lock.fill") + .font(TypographyTokens.detail) + .foregroundStyle(ColorTokens.Text.tertiary) + } + + Toggle("", isOn: credentialSyncBinding) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + } + } + + // MARK: - Projects + + var projectsSyncSection: some View { + Section("Projects") { + let projects = AppDirector.shared.projectStore.projects + if projects.isEmpty { + Text("No projects") + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Text.tertiary) + } else { + ForEach(projects) { project in + Toggle(isOn: projectSyncBinding(for: project)) { + Label { + Text(project.name) + } icon: { + Image(systemName: project.iconName ?? "folder.fill") + .foregroundStyle(project.color) + } + } + .toggleStyle(.switch) + .controlSize(.small) + } + } + } + } + + private func syncCollectionDescription(_ collection: SyncCollection) -> String { + switch collection { + case .identities: + if SyncPreferences.isCredentialSyncEnabled { + return "Saved login names and identity metadata. Passwords are synced through Credentials." + } + return "Saved login names and identity metadata. Passwords stay only on this Mac until Credentials is enabled." + default: + return collection.displayDescription + } + } + + private func syncStatusDescription(_ engine: SyncEngine) -> String { + switch engine.status { + case .idle: + if let lastSync = engine.lastSyncedAt { + return "Last synced \(lastSync.formatted(.relative(presentation: .named)))" + } + if hasSyncEnabledProjects { + return "Ready to sync your enabled projects." + } + return "No projects are currently selected for cloud sync." + case .syncing: + return "Syncing your selected projects now." + case .error(let message): + return message + case .offline: + return "Cloud sync is unavailable while Echo is offline." + case .disabled: + return "Sign in to enable cloud sync." + } + } + + @ViewBuilder + private func syncStatusAccessory(_ engine: SyncEngine) -> some View { + if engine.status.isSyncing { + ProgressView() + .controlSize(.small) + } else { + Button("Sync Now") { + Task { await engine.syncNow() } + } + .disabled(!hasSyncEnabledProjects) + } + } +} diff --git a/Echo/Sources/Features/Account/Views/SignedInAccountCard.swift b/Echo/Sources/Features/Account/Views/SignedInAccountCard.swift index e7770a366..9510603b4 100644 --- a/Echo/Sources/Features/Account/Views/SignedInAccountCard.swift +++ b/Echo/Sources/Features/Account/Views/SignedInAccountCard.swift @@ -7,6 +7,8 @@ struct SignedInAccountCard: View { var syncEngine: SyncEngine? @State private var showAccountSheet = false + @State private var lastAutoPresentedUserID: String? + @State var isRefreshHovered = false var body: some View { Section { @@ -17,461 +19,69 @@ struct SignedInAccountCard: View { .sheet(isPresented: $showAccountSheet) { AccountDetailSheet(authState: authState, syncEngine: syncEngine) } + .task(id: authState.currentUser?.userID) { + await presentAccountSheetIfSetupNeedsAttention() + } } // MARK: - Clickable Row private var accountRow: some View { - Button { - showAccountSheet = true - } label: { - HStack(spacing: SpacingTokens.md) { - accountAvatar - - VStack(alignment: .leading, spacing: 2) { - Text(authState.currentUser?.displayName ?? "Echo User") - .font(TypographyTokens.prominent) - .foregroundStyle(ColorTokens.Text.primary) - - if let email = authState.currentUser?.email { - Text(email) - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Text.secondary) - } - - syncSummary - } - - Spacer() - - Image(systemName: "chevron.right") - .font(TypographyTokens.labelBold) - .foregroundStyle(ColorTokens.Text.quaternary) - } - .padding(.vertical, SpacingTokens.xs) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - // MARK: - Sync Summary (inline, one line) - - @ViewBuilder - private var syncSummary: some View { - if let engine = syncEngine { - HStack(spacing: 4) { - switch engine.status { - case .idle: - if let lastSync = engine.lastSyncedAt { - Image(systemName: "checkmark.icloud") - .foregroundStyle(ColorTokens.Status.success) - Text("Synced \(lastSync, format: .relative(presentation: .named))") - } else { - Image(systemName: "icloud") - .foregroundStyle(ColorTokens.Text.tertiary) - Text("Sync available") - } - case .syncing: - ProgressView() - .controlSize(.mini) - Text("Syncing…") - case .error: - Image(systemName: "exclamationmark.icloud") - .foregroundStyle(ColorTokens.Status.error) - Text("Sync error — tap to retry") - case .offline: - Image(systemName: "icloud.slash") - .foregroundStyle(ColorTokens.Text.tertiary) - Text("Offline") - case .disabled: - Image(systemName: "icloud.slash") - .foregroundStyle(ColorTokens.Text.tertiary) - Text("Sync disabled") - } - } - .font(TypographyTokens.detail) - .foregroundStyle(ColorTokens.Text.tertiary) - } - } - - // MARK: - Avatar - - @ViewBuilder - private var accountAvatar: some View { - if let avatarURL = authState.currentUser?.avatarURL { - AsyncImage(url: avatarURL) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: 48, height: 48) - .clipShape(Circle()) - default: - initialsAvatar - } - } - } else { - initialsAvatar - } - } - - private var initialsAvatar: some View { - ZStack { - Circle() - .fill(.quaternary) - .frame(width: 48, height: 48) - - Text(avatarInitials) - .font(TypographyTokens.statNumber) - .foregroundStyle(ColorTokens.Text.secondary) - } - } - - private var avatarInitials: String { - let name = authState.currentUser?.displayName - ?? authState.currentUser?.email - ?? "U" - let parts = name.split(separator: " ") - if parts.count >= 2 { - return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased() - } - return String(name.prefix(2)).uppercased() - } -} - -// MARK: - Account Detail Sheet - -private struct AccountDetailSheet: View { - @Bindable var authState: AuthState - var syncEngine: SyncEngine? - @Environment(\.dismiss) private var dismiss - - @State private var isEditingName = false - @State private var editedName = "" - @State private var showDeleteConfirmation = false - @State private var showE2EEnrollment = false - @State private var showE2EUnlock = false - - var body: some View { - Form { - profileSection - - if let syncEngine { - projectsSyncSection - syncSection(syncEngine) - credentialSyncSection - } - - actionsSection - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - .frame(width: 440, height: 700) - .sheet(isPresented: $showE2EEnrollment) { - E2EEnrollmentView(enrollmentManager: AppDirector.shared.e2eEnrollmentManager) - } - .sheet(isPresented: $showE2EUnlock) { - E2EUnlockView(enrollmentManager: AppDirector.shared.e2eEnrollmentManager) - } - } - - // MARK: - Profile - - private var profileSection: some View { - Section("Profile") { - // Name - if isEditingName { - HStack { - TextField("", text: $editedName, prompt: Text("Your name")) - .textFieldStyle(.roundedBorder) - .onSubmit { saveDisplayName() } - - Button("Save") { saveDisplayName() } - .buttonStyle(.bordered) - .keyboardShortcut(.defaultAction) - .controlSize(.small) - .disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty) - - Button("Cancel") { isEditingName = false } - .controlSize(.small) - } - } else { - PropertyRow(title: "Name") { - HStack(spacing: SpacingTokens.xs) { - Text(authState.currentUser?.displayName ?? "Not set") - .foregroundStyle(authState.currentUser?.displayName != nil ? ColorTokens.Text.primary : ColorTokens.Text.tertiary) - Button { - editedName = authState.currentUser?.displayName ?? "" - isEditingName = true - } label: { - Image(systemName: "pencil") - .font(TypographyTokens.detail) - .foregroundStyle(ColorTokens.Text.tertiary) - } - .buttonStyle(.plain) - } - } - } - - // Email - if let email = authState.currentUser?.email { - PropertyRow(title: "Email") { - Text(email) - .foregroundStyle(ColorTokens.Text.secondary) - } - } - - // Auth Method - if let method = authState.currentUser?.authMethod { - PropertyRow(title: "Sign-in method") { - HStack(spacing: 4) { - authMethodIcon(method) - Text(method.displayName) - } - } - } - } - } + ZStack(alignment: .trailing) { + Button { + showAccountSheet = true + } label: { + HStack(spacing: SpacingTokens.md) { + accountAvatar - // MARK: - Sync - - private func syncSection(_ engine: SyncEngine) -> some View { - Section("Cloud Sync") { - // Status + Sync Now - HStack { - Label { - switch engine.status { - case .idle: - if let lastSync = engine.lastSyncedAt { - Text("Last synced \(lastSync, format: .relative(presentation: .named))") - } else if hasSyncEnabledProjects { - Text("Sync enabled") - } else { - Text("No projects synced") + VStack(alignment: .leading, spacing: 2) { + Text(authState.currentUser?.displayName ?? "Echo User") + .font(TypographyTokens.prominent) + .foregroundStyle(ColorTokens.Text.primary) + + if let email = authState.currentUser?.email { + Text(email) + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Text.secondary) } - case .syncing: - Text("Syncing…") - case .error(let message): - Text(message) - .lineLimit(2) - case .offline: - Text("Offline") - case .disabled: - Text("Sync disabled") - } - } icon: { - switch engine.status { - case .idle: - Image(systemName: "checkmark.icloud") - .foregroundStyle(ColorTokens.Status.success) - case .syncing: - ProgressView() - .controlSize(.small) - case .error: - Image(systemName: "exclamationmark.icloud") - .foregroundStyle(ColorTokens.Status.error) - case .offline, .disabled: - Image(systemName: "icloud.slash") - .foregroundStyle(ColorTokens.Text.tertiary) - } - } - .font(TypographyTokens.formDescription) - .foregroundStyle(engine.status.isError ? ColorTokens.Status.error : ColorTokens.Text.secondary) - - Spacer() - - Button("Sync Now") { - Task { await engine.syncNow() } - } - .font(TypographyTokens.formDescription) - .disabled(engine.status.isSyncing) - } - // Collection toggles - ForEach(SyncCollection.userToggleable, id: \.self) { collection in - Toggle(isOn: syncCollectionBinding(for: collection)) { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(collection.displayName) - Text(collection.displayDescription) - .font(TypographyTokens.detail) - .foregroundStyle(ColorTokens.Text.tertiary) - } - } icon: { - Image(systemName: collection.systemImage) - .foregroundStyle(ColorTokens.Text.secondary) + syncSummary } - } - .toggleStyle(.switch) - .controlSize(.small) - } - } - } - // MARK: - Projects - - private var projectsSyncSection: some View { - Section("Projects") { - let projects = AppDirector.shared.projectStore.projects - if projects.isEmpty { - Text("No projects") - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Text.tertiary) - } else { - ForEach(projects) { project in - Toggle(isOn: projectSyncBinding(for: project)) { - Label { - Text(project.name) - } icon: { - Image(systemName: project.iconName ?? "folder.fill") - .foregroundStyle(project.color) - } - } - .toggleStyle(.switch) - .controlSize(.small) - } - } - } - } + Spacer() - private func projectSyncBinding(for project: Project) -> Binding { - Binding( - get: { project.isSyncEnabled }, - set: { newValue in - guard let store = AppDirector.shared.projectStore as ProjectStore?, - var updated = store.projects.first(where: { $0.id == project.id }) else { return } - updated.isSyncEnabled = newValue - Task { - try? await store.updateProject(updated) - if newValue, let syncEngine = AppDirector.shared.syncEngine { - try? await syncEngine.performInitialUpload(for: updated) - } + Image(systemName: "chevron.right") + .font(TypographyTokens.labelBold) + .foregroundStyle(ColorTokens.Text.quaternary) } + .contentShape(Rectangle()) } - ) - } + .buttonStyle(.plain) - private var hasSyncEnabledProjects: Bool { - AppDirector.shared.projectStore.projects.contains { $0.isSyncEnabled } - } - - private func syncCollectionBinding(for collection: SyncCollection) -> Binding { - Binding( - get: { SyncPreferences.isEnabled(collection) }, - set: { SyncPreferences.setEnabled(collection, enabled: $0) } - ) - } - - // MARK: - Actions - - private var actionsSection: some View { - Section { - HStack { - Button("Sign Out") { - Task { - await authState.signOut() - dismiss() - } - } - - Spacer() - - Button("Delete Account", role: .destructive) { - showDeleteConfirmation = true - } - .font(TypographyTokens.formDescription) - } - .alert("Delete Account", isPresented: $showDeleteConfirmation) { - Button("Delete", role: .destructive) { - Task { - try? await authState.deleteAccount() - dismiss() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will permanently delete your account and all synced data. This action cannot be undone.") + if let syncEngine { + syncRefreshButton(syncEngine) + .padding(.trailing, 28) } } + .padding(.vertical, SpacingTokens.xs) } - // MARK: - Credential Sync (E2E) - - private var credentialSyncSection: some View { - let manager = AppDirector.shared.e2eEnrollmentManager - return Section("Credential Sync") { - if manager.isEnrolled { - if manager.isUnlocked { - Label { - Text("Passwords encrypted and synced") - } icon: { - Image(systemName: "lock.shield.fill") - .foregroundStyle(ColorTokens.Status.success) - } - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Text.secondary) - } else { - HStack { - Label { - Text("Locked — enter master password") - } icon: { - Image(systemName: "lock.fill") - .foregroundStyle(ColorTokens.Text.tertiary) - } - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Text.secondary) - - Spacer() - - Button("Unlock") { showE2EUnlock = true } - .font(TypographyTokens.formDescription) - } - } - } else { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("End-to-end encryption") - .font(TypographyTokens.formDescription) - Text("Encrypt database passwords before syncing") - .font(TypographyTokens.detail) - .foregroundStyle(ColorTokens.Text.tertiary) - } + private func presentAccountSheetIfSetupNeedsAttention() async { + guard let userID = authState.currentUser?.userID, + lastAutoPresentedUserID != userID else { return } - Spacer() + let e2eManager = AppDirector.shared.e2eEnrollmentManager + await e2eManager.checkEnrollmentStatus() + await e2eManager.tryAutoUnlock() - Button("Set Up") { showE2EEnrollment = true } - .font(TypographyTokens.formDescription) - } - } - } - } - - // MARK: - Auth Method Icon - - @ViewBuilder - private func authMethodIcon(_ method: AuthMethod) -> some View { - switch method { - case .google: - Image("GoogleLogo") - .resizable() - .scaledToFit() - .frame(width: 13, height: 13) - case .apple: - Image(systemName: "apple.logo") - .font(TypographyTokens.detail) - case .email: - Image(systemName: "envelope.fill") - .font(TypographyTokens.detail) - } - } + let needsCredentialUnlock = e2eManager.isEnrolled + && SyncPreferences.isCredentialSyncEnabled + && !e2eManager.isUnlocked + let needsMergeDecision = await syncEngine?.hasPendingMergeDecision() ?? false - // MARK: - Helpers + guard needsCredentialUnlock || needsMergeDecision else { return } - private func saveDisplayName() { - let name = editedName.trimmingCharacters(in: .whitespaces) - guard !name.isEmpty else { return } - isEditingName = false - Task { await authState.updateDisplayName(name) } + lastAutoPresentedUserID = userID + showAccountSheet = true } } diff --git a/Echo/Sources/Features/Account/Views/SyncMergeStrategySheet.swift b/Echo/Sources/Features/Account/Views/SyncMergeStrategySheet.swift new file mode 100644 index 000000000..bdbfc9161 --- /dev/null +++ b/Echo/Sources/Features/Account/Views/SyncMergeStrategySheet.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// Presented when a user enables sync on a project that has both local and cloud data. +/// Lets the user choose how to reconcile the two data sets. +struct SyncMergeStrategySheet: View { + let summary: SyncDataSummary + let projectName: String + let onChoose: (SyncMergeStrategy) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + VStack(alignment: .leading, spacing: SpacingTokens.sm) { + Label("Sync Conflict", systemImage: "arrow.triangle.2.circlepath") + .font(TypographyTokens.headline) + + Text("**\(projectName)** has data both on this device and in the cloud. How would you like to proceed?") + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Text.secondary) + } + .padding(.bottom, SpacingTokens.xs) + + // Data summary + HStack(spacing: SpacingTokens.lg) { + dataSummaryColumn( + title: "This Device", + icon: "laptopcomputer", + count: summary.localTotal + ) + + Divider() + .frame(height: 40) + + dataSummaryColumn( + title: "Cloud", + icon: "icloud", + count: summary.cloudDocuments + ) + } + .padding(.vertical, SpacingTokens.xs) + } + + Section { + strategyButton( + title: "Merge Both", + description: "Combine local and cloud data. If the same item exists in both, the most recent version wins.", + icon: "arrow.triangle.merge", + strategy: .merge + ) + + strategyButton( + title: "Use Cloud", + description: "Replace local data with what's in the cloud. Local-only items will be removed.", + icon: "icloud.and.arrow.down", + strategy: .useCloud + ) + + strategyButton( + title: "Upload Local", + description: "Push local data to the cloud, overwriting cloud versions where they conflict.", + icon: "icloud.and.arrow.up", + strategy: .uploadLocal + ) + } + + Section { + Button("Cancel") { dismiss() } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + .frame(width: 420, height: 460) + } + + // MARK: - Components + + private func dataSummaryColumn(title: String, icon: String, count: Int) -> some View { + VStack(spacing: SpacingTokens.xs) { + Image(systemName: icon) + .font(TypographyTokens.prominent) + .foregroundStyle(ColorTokens.Text.secondary) + Text("\(count) items") + .font(TypographyTokens.labelBold) + Text(title) + .font(TypographyTokens.detail) + .foregroundStyle(ColorTokens.Text.tertiary) + } + .frame(maxWidth: .infinity) + } + + private func strategyButton(title: String, description: String, icon: String, strategy: SyncMergeStrategy) -> some View { + Button { + onChoose(strategy) + dismiss() + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(title) + Text(description) + .font(TypographyTokens.detail) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } icon: { + Image(systemName: icon) + .foregroundStyle(ColorTokens.Text.secondary) + .frame(width: 20) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/Echo/Sources/Features/ActivityMonitor/Domain/ActivityMonitorViewModel.swift b/Echo/Sources/Features/ActivityMonitor/Domain/ActivityMonitorViewModel.swift index 5a7d86dfc..c2a6800fd 100644 --- a/Echo/Sources/Features/ActivityMonitor/Domain/ActivityMonitorViewModel.swift +++ b/Echo/Sources/Features/ActivityMonitor/Domain/ActivityMonitorViewModel.swift @@ -15,6 +15,7 @@ final class ActivityMonitorViewModel { @ObservationIgnored private let monitor: any DatabaseActivityMonitoring @ObservationIgnored private let mysqlSession: MySQLSession? @ObservationIgnored private var streamTask: Task? + @ObservationIgnored var activityEngine: ActivityEngine? let connectionSessionID: UUID let connectionID: UUID let databaseType: DatabaseType diff --git a/Echo/Sources/Features/ActivityMonitor/Views/ActivityMonitorSharedComponents.swift b/Echo/Sources/Features/ActivityMonitor/Views/ActivityMonitorSharedComponents.swift index 6c220b5ce..39169bf11 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/ActivityMonitorSharedComponents.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/ActivityMonitorSharedComponents.swift @@ -136,9 +136,9 @@ struct SectionContainer: View { .padding(.leading, SpacingTokens.xxxs) content() .background(ColorTokens.Background.secondary.opacity(0.3)) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.medium)) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.medium) .stroke(ColorTokens.Text.primary.opacity(0.05), lineWidth: 1) ) } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityIO.swift b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityIO.swift index a0c278bcb..bf7d28560 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityIO.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityIO.swift @@ -132,10 +132,13 @@ struct MySQLActivityIO: View { private func load() async { isLoading = true errorMessage = nil + let handle = viewModel.activityEngine?.begin("Loading file IO stats", connectionSessionID: viewModel.connectionSessionID) do { report = try await viewModel.loadMySQLFileIO() + handle?.succeed() } catch { errorMessage = error.localizedDescription + handle?.fail(error.localizedDescription) } isLoading = false } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityInnoDB.swift b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityInnoDB.swift index b24693d67..c44213abd 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityInnoDB.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityInnoDB.swift @@ -122,12 +122,15 @@ struct MySQLActivityInnoDB: View { private func load() async { isLoading = true errorMessage = nil + let handle = viewModel.activityEngine?.begin("Loading InnoDB status", connectionSessionID: viewModel.connectionSessionID) do { let status = try await viewModel.loadMySQLInnoDBStatus() statusSections = parseInnoDBStatus(status.statusText) expandedSections = Set(statusSections.prefix(3).map(\.title)) + handle?.succeed() } catch { errorMessage = error.localizedDescription + handle?.fail(error.localizedDescription) } isLoading = false } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityQueries.swift b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityQueries.swift index 331b94e1b..8bc42e251 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityQueries.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityQueries.swift @@ -154,6 +154,7 @@ struct MySQLActivityQueries: View { private func load() async { isLoading = true errorMessage = nil + let handle = viewModel.activityEngine?.begin("Loading query analysis", connectionSessionID: viewModel.connectionSessionID) do { let result: MySQLPerformanceReport switch selectedReport { @@ -165,8 +166,10 @@ struct MySQLActivityQueries: View { result = try await viewModel.loadMySQLFullTableScans() } report = result + handle?.succeed() } catch { errorMessage = error.localizedDescription + handle?.fail(error.localizedDescription) } isLoading = false } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityReplication.swift b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityReplication.swift index bcdc92d45..b4ff43a8c 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityReplication.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityReplication.swift @@ -178,13 +178,16 @@ struct MySQLActivityReplication: View { private func load() async { isLoading = true errorMessage = nil + let handle = viewModel.activityEngine?.begin("Loading replication status", connectionSessionID: viewModel.connectionSessionID) do { async let replica = viewModel.loadMySQLReplicaStatus() async let primary = viewModel.loadMySQLPrimaryStatus() replicaStatus = try await replica primaryStatus = try await primary + handle?.succeed() } catch { errorMessage = error.localizedDescription + handle?.fail(error.localizedDescription) } isLoading = false } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityWaits.swift b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityWaits.swift index 39d561388..22773e1b7 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityWaits.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/MySQL/MySQLActivityWaits.swift @@ -166,6 +166,7 @@ struct MySQLActivityWaits: View { private func load() async { isLoading = true errorMessage = nil + let handle = viewModel.activityEngine?.begin("Loading wait stats", connectionSessionID: viewModel.connectionSessionID) do { switch selectedTab { case .global: @@ -178,8 +179,10 @@ struct MySQLActivityWaits: View { name: "innodb_lock_waits" ) } + handle?.succeed() } catch { errorMessage = error.localizedDescription + handle?.fail(error.localizedDescription) } isLoading = false } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityBGWriter.swift b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityBGWriter.swift index 3bbff7766..f89589b54 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityBGWriter.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityBGWriter.swift @@ -3,6 +3,7 @@ import PostgresKit struct PostgresActivityBGWriter: View { let connectionID: UUID + var activityEngine: ActivityEngine? @Environment(EnvironmentState.self) private var environmentState @State private var stats: PostgresBGWriterStats? @@ -110,11 +111,14 @@ struct PostgresActivityBGWriter: View { guard let session = environmentState.sessionGroup.sessionForConnection(connectionID), let pg = session.session as? PostgresSession else { return } isLoading = true + let handle = activityEngine?.begin("Loading BGWriter stats", connectionSessionID: connectionID) defer { isLoading = false } do { stats = try await pg.client.metadata.fetchBGWriterStats() + handle?.succeed() } catch { stats = nil + handle?.fail(error.localizedDescription) } } } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityConfiguration.swift b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityConfiguration.swift index 675ce3769..1a82bad19 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityConfiguration.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityConfiguration.swift @@ -3,6 +3,7 @@ import PostgresKit struct PostgresActivityConfiguration: View { let connectionID: UUID + var activityEngine: ActivityEngine? @Environment(EnvironmentState.self) private var environmentState @State private var settings: [PostgresServerSetting] = [] @@ -130,11 +131,14 @@ struct PostgresActivityConfiguration: View { guard let session = environmentState.sessionGroup.sessionForConnection(connectionID), let pg = session.session as? PostgresSession else { return } isLoading = true + let handle = activityEngine?.begin("Loading server settings", connectionSessionID: connectionID) defer { isLoading = false } do { settings = try await pg.client.metadata.listServerSettings() + handle?.succeed() } catch { settings = [] + handle?.fail(error.localizedDescription) } } } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityIOStats.swift b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityIOStats.swift index 27225f705..6fee90b88 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityIOStats.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityIOStats.swift @@ -3,6 +3,7 @@ import PostgresKit struct PostgresActivityIOStats: View { let connectionID: UUID + var activityEngine: ActivityEngine? @Environment(EnvironmentState.self) private var environmentState @State private var stats: [PostgresTableIOStats] = [] @@ -111,11 +112,14 @@ struct PostgresActivityIOStats: View { guard let session = environmentState.sessionGroup.sessionForConnection(connectionID), let pg = session.session as? PostgresSession else { return } isLoading = true + let handle = activityEngine?.begin("Loading IO stats", connectionSessionID: connectionID) defer { isLoading = false } do { stats = try await pg.client.metadata.listTableIOStats(schema: schemaFilter) + handle?.succeed() } catch { stats = [] + handle?.fail(error.localizedDescription) } } } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityMonitorView.swift b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityMonitorView.swift index ad332e0f9..1aed14499 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityMonitorView.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityMonitorView.swift @@ -170,15 +170,15 @@ struct PostgresActivityMonitorView: View { case .replication: PostgresActivityReplication(info: snap.replicationInfo, sortOrder: $replicationSortOrder) case .ioStats: - PostgresActivityIOStats(connectionID: viewModel.connectionID) + PostgresActivityIOStats(connectionID: viewModel.connectionID, activityEngine: viewModel.activityEngine) case .wal: - PostgresActivityWAL(connectionID: viewModel.connectionID) + PostgresActivityWAL(connectionID: viewModel.connectionID, activityEngine: viewModel.activityEngine) case .bgWriter: - PostgresActivityBGWriter(connectionID: viewModel.connectionID) + PostgresActivityBGWriter(connectionID: viewModel.connectionID, activityEngine: viewModel.activityEngine) case .preparedTxns: - PostgresActivityPreparedTxns(connectionID: viewModel.connectionID) + PostgresActivityPreparedTxns(connectionID: viewModel.connectionID, activityEngine: viewModel.activityEngine) case .configuration: - PostgresActivityConfiguration(connectionID: viewModel.connectionID) + PostgresActivityConfiguration(connectionID: viewModel.connectionID, activityEngine: viewModel.activityEngine) } } else { EmptyTablePlaceholder() diff --git a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityPreparedTxns.swift b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityPreparedTxns.swift index 990eed560..0ff4eaae3 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityPreparedTxns.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityPreparedTxns.swift @@ -3,6 +3,7 @@ import PostgresKit struct PostgresActivityPreparedTxns: View { let connectionID: UUID + var activityEngine: ActivityEngine? @Environment(EnvironmentState.self) private var environmentState @State private var transactions: [PostgresPreparedTransaction] = [] @@ -118,11 +119,14 @@ struct PostgresActivityPreparedTxns: View { guard let session = environmentState.sessionGroup.sessionForConnection(connectionID), let pg = session.session as? PostgresSession else { return } isLoading = true + let handle = activityEngine?.begin("Loading prepared transactions", connectionSessionID: connectionID) defer { isLoading = false } do { transactions = try await pg.client.metadata.listPreparedTransactions() + handle?.succeed() } catch { transactions = [] + handle?.fail(error.localizedDescription) } } diff --git a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityWAL.swift b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityWAL.swift index 5a9e49414..5b79a8df7 100644 --- a/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityWAL.swift +++ b/Echo/Sources/Features/ActivityMonitor/Views/Postgres/PostgresActivityWAL.swift @@ -3,6 +3,7 @@ import PostgresKit struct PostgresActivityWAL: View { let connectionID: UUID + var activityEngine: ActivityEngine? @Environment(EnvironmentState.self) private var environmentState @State private var stats: PostgresWALStats? @@ -118,11 +119,14 @@ struct PostgresActivityWAL: View { guard let session = environmentState.sessionGroup.sessionForConnection(connectionID), let pg = session.session as? PostgresSession else { return } isLoading = true + let handle = activityEngine?.begin("Loading WAL stats", connectionSessionID: connectionID) defer { isLoading = false } do { stats = try await pg.client.metadata.fetchWALStats() + handle?.succeed() } catch { stats = nil + handle?.fail(error.localizedDescription) } } } diff --git a/Echo/Sources/Features/AppHost/Domain/AppDirector.swift b/Echo/Sources/Features/AppHost/Domain/AppDirector.swift index fdb947371..14eac934e 100644 --- a/Echo/Sources/Features/AppHost/Domain/AppDirector.swift +++ b/Echo/Sources/Features/AppHost/Domain/AppDirector.swift @@ -38,6 +38,7 @@ final class AppDirector { @ObservationIgnored let resultSpoolManager: ResultSpooler @ObservationIgnored let diagramCacheStore: DiagramCacheStore @ObservationIgnored let diagramKeyStore: DiagramEncryptionKeyStore + @ObservationIgnored let objectBrowserCacheStore: ObjectBrowserCacheStore @ObservationIgnored let activityEngine: ActivityEngine @ObservationIgnored let authState: AuthState @ObservationIgnored let syncEngine: SyncEngine? @@ -64,8 +65,12 @@ final class AppDirector { let diagramConfig = DiagramCacheStore.Configuration(rootDirectory: cacheRoot) let keyStore = DiagramEncryptionKeyStore() let cacheManager = DiagramCacheStore(configuration: diagramConfig) + let objectBrowserCacheStore = ObjectBrowserCacheStore( + configuration: .init(rootDirectory: ObjectBrowserCacheStore.defaultRootDirectory()) + ) self.diagramCacheStore = cacheManager self.diagramKeyStore = keyStore + self.objectBrowserCacheStore = objectBrowserCacheStore // Initialize modular stores let projectRepository = ProjectRepository(diskStore: ProjectDiskStore()) @@ -78,7 +83,11 @@ final class AppDirector { ) self.connectionStore = ConnectionStore(repository: connectionRepository) self.identityRepository = IdentityRepository(connectionStore: connectionStore) - self.schemaDiscoveryEngine = MetadataDiscoveryEngine(identityRepository: identityRepository, connectionStore: connectionStore) + self.schemaDiscoveryEngine = MetadataDiscoveryEngine( + identityRepository: identityRepository, + connectionStore: connectionStore, + objectBrowserCacheStore: objectBrowserCacheStore + ) self.bookmarkRepository = BookmarkRepository() self.historyRepository = HistoryRepository() @@ -137,7 +146,8 @@ final class AppDirector { historyRepository: historyRepository, resultSpoolManager: resultSpoolManager, diagramCacheStore: cacheManager, - diagramKeyStore: keyStore + diagramKeyStore: keyStore, + objectBrowserCacheStore: objectBrowserCacheStore ) let projectStoreRef = self.projectStore @@ -155,6 +165,9 @@ final class AppDirector { schemaDiscoveryEngine.onEnqueuePrefetch = { @MainActor [weak self] session in await self?.environmentState.enqueuePrefetchForSessionIfNeeded(session) } + schemaDiscoveryEngine.cacheLimitProvider = { @MainActor [weak self] in + self?.projectStore.globalSettings.objectBrowserCacheMaxBytes ?? 512 * 1_024 * 1_024 + } // Setup cross-domain providers for DiagramBuilder after EnvironmentState is initialized diagramBuilder.globalSettingsProvider = { @MainActor [weak self] in @@ -192,6 +205,8 @@ final class AppDirector { print("Failed to load modular stores: \(error)") } + await environmentState.migrateLegacyObjectBrowserCachesIfNeeded() + await environmentState.load() await authState.restoreSession() @@ -353,6 +368,7 @@ final class AppDirector { private func startSync() async { guard let syncEngine else { return } + guard await authState.ensureSupabaseSession() else { return } // If the user changed since last sign-in, reset sync state let currentUserID = authState.currentUser?.userID diff --git a/Echo/Sources/Features/AppHost/Domain/State/AppState.swift b/Echo/Sources/Features/AppHost/Domain/State/AppState.swift index 98897e100..56c10e349 100644 --- a/Echo/Sources/Features/AppHost/Domain/State/AppState.swift +++ b/Echo/Sources/Features/AppHost/Domain/State/AppState.swift @@ -4,11 +4,16 @@ import SwiftUI /// Centralized application state management @Observable final class AppState: @unchecked Sendable { + struct StructureScriptPreviewData { + let statements: [String] + } + // MARK: - UI State var isLoading = false var currentError: DatabaseError? var showingError = false var activeSheet: ActiveSheet? + var structureScriptData: StructureScriptPreviewData? var showTabOverview = false var showInfoSidebar = false var workspaceSidebarVisibility: NavigationSplitViewVisibility = .automatic @@ -90,7 +95,13 @@ import SwiftUI activeSheet = sheet } + func showStructureScriptPreview(statements: [String]) { + structureScriptData = StructureScriptPreviewData(statements: statements) + activeSheet = .structureScriptPreview + } + func dismissSheet() { + structureScriptData = nil activeSheet = nil } @@ -136,6 +147,7 @@ enum ActiveSheet: String, Identifiable { case preferences case about case exportData + case structureScriptPreview var id: String { rawValue diff --git a/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState+Connections.swift b/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState+Connections.swift index 8d21694d7..1ac1486ca 100644 --- a/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState+Connections.swift +++ b/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState+Connections.swift @@ -6,6 +6,16 @@ extension EnvironmentState { // MARK: - Session Management func connect(to connection: SavedConnection) { + if let existingSession = sessionGroup.sessionForConnection(connection.id) { + sessionGroup.setActiveSession(existingSession.id) + connectionStore.selectedConnectionID = connection.id + navigationStore.navigationState.selectConnection(connection) + if let databaseName = existingSession.sidebarFocusedDatabase { + navigationStore.navigationState.selectDatabase(databaseName) + } + navigationStore.revealExplorerConnection(connection.id) + return + } connectToNewSession(to: connection) } @@ -145,6 +155,34 @@ extension EnvironmentState { await schemaDiscoveryEngine.preloadStructure(for: connection, overridePassword: overridePassword) } + func objectBrowserCacheUsageBytes() async -> UInt64 { + await objectBrowserCacheStore.currentUsageBytes() + } + + func clearObjectBrowserCache() async { + await objectBrowserCacheStore.removeAll() + + for index in connectionStore.connections.indices { + connectionStore.connections[index].cachedStructure = nil + connectionStore.connections[index].cachedStructureUpdatedAt = nil + } + try? await connectionStore.saveConnections() + + for session in sessionGroup.activeSessions { + session.clearMetadataCacheState() + } + } + + func migrateLegacyObjectBrowserCachesIfNeeded() async { + let limitBytes = projectStore.globalSettings.objectBrowserCacheMaxBytes + for connection in connectionStore.connections { + await objectBrowserCacheStore.migrateLegacyCacheIfNeeded( + from: connection, + limitBytes: limitBytes + ) + } + } + // MARK: - Bookmarks func bookmarks(for connectionID: UUID) -> [Bookmark] { diff --git a/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState+TabManagement.swift b/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState+TabManagement.swift index f170d49fe..e37f73671 100644 --- a/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState+TabManagement.swift +++ b/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState+TabManagement.swift @@ -369,6 +369,24 @@ extension EnvironmentState { } } + func openInPsql(for session: ConnectionSession? = nil, database: String? = nil) { + let targetSession = session ?? sessionGroup.activeSession ?? sessionGroup.activeSessions.first + guard let targetSession else { return } + let requestedDatabase = (database ?? targetSession.sidebarFocusedDatabase ?? targetSession.connection.database) + .trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveDatabase = requestedDatabase.isEmpty ? "postgres" : requestedDatabase + let connection = targetSession.connection + + Task { + await PostgresTerminalLauncher.openInTerminal( + host: connection.host, + port: connection.port, + username: connection.username, + database: effectiveDatabase + ) + } + } + func openJobQueueTab(for session: ConnectionSession, selectJobID: String? = nil) { // Reuse existing Jobs tab for this session if one exists if let existingTab = tabStore.tabs.first(where: { $0.kind == .jobQueue && $0.connectionSessionID == session.id }) { diff --git a/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState.swift b/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState.swift index a39b234d5..3cb50e9c3 100644 --- a/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState.swift +++ b/Echo/Sources/Features/AppHost/Domain/State/EnvironmentState.swift @@ -93,6 +93,7 @@ final class EnvironmentState { @ObservationIgnored let resultSpoolManager: ResultSpooler @ObservationIgnored let diagramCacheStore: DiagramCacheStore @ObservationIgnored let diagramKeyStore: DiagramEncryptionKeyStore + @ObservationIgnored let objectBrowserCacheStore: ObjectBrowserCacheStore @ObservationIgnored let dedicatedConnectionGate = AsyncSemaphore(limit: 3) @ObservationIgnored private var diagramRefreshTask: Task? @@ -114,7 +115,8 @@ final class EnvironmentState { historyRepository: HistoryRepository, resultSpoolManager: ResultSpooler, diagramCacheStore: DiagramCacheStore, - diagramKeyStore: DiagramEncryptionKeyStore + diagramKeyStore: DiagramEncryptionKeyStore, + objectBrowserCacheStore: ObjectBrowserCacheStore ) { self.projectStore = projectStore self.connectionStore = connectionStore @@ -130,6 +132,7 @@ final class EnvironmentState { self.resultSpoolManager = resultSpoolManager self.diagramCacheStore = diagramCacheStore self.diagramKeyStore = diagramKeyStore + self.objectBrowserCacheStore = objectBrowserCacheStore self.tabStore.delegate = self setupBindings() @@ -254,6 +257,17 @@ final class EnvironmentState { session: session, spoolManager: resultSpoolManager ) + if let cachedEntry = await objectBrowserCacheStore.entry(for: connection) { + connectionSession.databaseStructure = cachedEntry.structure + connectionSession.hydrateMetadataFreshnessFromCacheStructure() + connectionSession.structureLoadingState = .loading(progress: 0) + connectionSession.structureLoadingMessage = "Refreshing cached metadata…" + } else if let legacyStructure = connection.cachedStructure { + connectionSession.databaseStructure = legacyStructure + connectionSession.hydrateMetadataFreshnessFromCacheStructure() + connectionSession.structureLoadingState = .loading(progress: 0) + connectionSession.structureLoadingMessage = "Refreshing cached metadata…" + } // Transition: pending → active session pendingConnections.removeAll { $0.id == connection.id } @@ -264,13 +278,10 @@ final class EnvironmentState { detachedJobQueueViewModels.removeValue(forKey: oldSession.id) } - // Start structure load BEFORE adding session to the sidebar. - // This way the load runs in parallel with the sidebar rendering the new session. + sessionGroup.addSession(connectionSession) startStructureLoadTask(for: connectionSession) Task { await connectionSession.refreshPermissions() } connectionSession.startHealthCheck() - - sessionGroup.addSession(connectionSession) connectionStates[connection.id] = .connected recordRecentConnection(for: connection, databaseName: connectionSession.sidebarFocusedDatabase) notificationEngine?.post(category: .connectionConnected, message: "Connected to \(displayName)") diff --git a/Echo/Sources/Features/AppHost/Domain/State/NavigationStore.swift b/Echo/Sources/Features/AppHost/Domain/State/NavigationStore.swift index 8ec9d8b18..f6bf81d5e 100644 --- a/Echo/Sources/Features/AppHost/Domain/State/NavigationStore.swift +++ b/Echo/Sources/Features/AppHost/Domain/State/NavigationStore.swift @@ -8,6 +8,8 @@ final class NavigationStore { // MARK: - State var navigationState = NavigationState() var pendingExplorerFocus: ExplorerFocus? + var pendingExplorerRevealConnectionID: UUID? + var pendingExplorerRevealRequestID = 0 var isWorkspaceWindowKey = false var isManageConnectionsPresented = false var showNewProjectSheet = false @@ -30,6 +32,11 @@ final class NavigationStore { func clearExplorerFocus() { self.pendingExplorerFocus = nil } + + func revealExplorerConnection(_ connectionID: UUID) { + pendingExplorerRevealConnectionID = connectionID + pendingExplorerRevealRequestID &+= 1 + } func updateInspectorWidth(_ width: CGFloat, min minWidth: CGFloat, max maxWidth: CGFloat) { let clamped = max(minWidth, min(maxWidth, width)) diff --git a/Echo/Sources/Features/AppHost/EchoApp+ConnectMenu.swift b/Echo/Sources/Features/AppHost/EchoApp+ConnectMenu.swift index ca0eff3dc..7466bfbb5 100644 --- a/Echo/Sources/Features/AppHost/EchoApp+ConnectMenu.swift +++ b/Echo/Sources/Features/AppHost/EchoApp+ConnectMenu.swift @@ -150,7 +150,7 @@ struct ConnectMenuCommands: Commands { } private func availableDatabases(for session: ConnectionSession) -> [DatabaseInfo] { - let source = session.databaseStructure?.databases ?? session.connection.cachedStructure?.databases ?? [] + let source = session.databaseStructure?.databases ?? [] var deduplicated: [DatabaseInfo] = [] var seen: Set = [] @@ -206,20 +206,7 @@ struct ConnectMenuCommands: Commands { @ViewBuilder private func connectionIcon(for connection: SavedConnection) -> some View { - if let logoData = connection.logo, - let nsImage = NSImage(data: logoData) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) - } else { - Image(connection.databaseType.iconName) - .resizable() - .renderingMode(.template) - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - } + DatabaseTypeIcon(databaseType: connection.databaseType, presentation: .menu) } private func connectionMenuItems(parentID: UUID?, projectID: UUID?) -> AnyView { @@ -241,20 +228,7 @@ struct ConnectMenuCommands: Commands { Label { Text(displayName(for: connection)) } icon: { - if let logoData = connection.logo, - let nsImage = NSImage(data: logoData) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) - } else { - Image(connection.databaseType.iconName) - .resizable() - .renderingMode(.template) - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - } + DatabaseTypeIcon(databaseType: connection.databaseType, presentation: .menu) } } } diff --git a/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ConnectionDashboard/ConnectionDashboardView+Actions.swift b/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ConnectionDashboard/ConnectionDashboardView+Actions.swift index cdbe02843..bb0058516 100644 --- a/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ConnectionDashboard/ConnectionDashboardView+Actions.swift +++ b/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ConnectionDashboard/ConnectionDashboardView+Actions.swift @@ -46,6 +46,12 @@ struct ConnectionDashboardTools: View { } directAction: { environmentState.openPSQLTab(for: session) } + + DashboardToolCard(icon: "apple.terminal", label: "psql", menuItems: databases.map(\.name)) { db in + environmentState.openInPsql(for: session, database: db) + } directAction: { + environmentState.openInPsql(for: session) + } } // MARK: - MySQL diff --git a/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ConnectionDashboard/ConnectionDashboardView.swift b/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ConnectionDashboard/ConnectionDashboardView.swift index f5141c69a..3e675b464 100644 --- a/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ConnectionDashboard/ConnectionDashboardView.swift +++ b/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ConnectionDashboard/ConnectionDashboardView.swift @@ -65,11 +65,11 @@ struct ConnectionDashboardHeader: View { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(session.connection.color.opacity(0.1)) .frame(width: 48, height: 48) - Image(session.connection.databaseType.iconName) - .resizable() - .aspectRatio(contentMode: .fit) + DatabaseTypeIcon( + databaseType: session.connection.databaseType, + tint: session.connection.color + ) .frame(width: 24, height: 24) - .foregroundStyle(session.connection.color) } } } diff --git a/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ServerConnectionPlaceholderView.swift b/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ServerConnectionPlaceholderView.swift index 804e768b7..764cfe67d 100644 --- a/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ServerConnectionPlaceholderView.swift +++ b/Echo/Sources/Features/AppHost/Views/Tabs/EditorContainer/ServerConnectionPlaceholderView.swift @@ -136,15 +136,10 @@ private struct RecentConnectionRow: View { } private var icon: some View { - ZStack { - RoundedRectangle(cornerRadius: SpacingTokens.xs, style: .continuous) - .fill(iconColor.opacity(0.12)) - .frame(width: 32, height: 32) - Image(connection.databaseType.iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: SpacingTokens.md, height: SpacingTokens.md) - .foregroundStyle(iconColor) - } + DatabaseTypeIcon( + databaseType: connection.databaseType, + tint: iconColor, + presentation: .landingRecent + ) } } diff --git a/Echo/Sources/Features/AppHost/Views/Tabs/TabOverview/TabOverviewServerGroup.swift b/Echo/Sources/Features/AppHost/Views/Tabs/TabOverview/TabOverviewServerGroup.swift index 111c305b9..ebfb6e227 100644 --- a/Echo/Sources/Features/AppHost/Views/Tabs/TabOverview/TabOverviewServerGroup.swift +++ b/Echo/Sources/Features/AppHost/Views/Tabs/TabOverview/TabOverviewServerGroup.swift @@ -22,9 +22,11 @@ extension TabOverviewView { Text(group.connection.connectionName) .font(TypographyTokens.displayLarge.weight(.bold)) } icon: { - Image(systemName: group.connection.databaseType.iconName) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(isActiveServer ? ColorTokens.accent : ColorTokens.Text.secondary) + DatabaseTypeIcon( + databaseType: group.connection.databaseType, + tint: isActiveServer ? ColorTokens.accent : ColorTokens.Text.secondary + ) + .frame(width: SpacingTokens.md, height: SpacingTokens.md) } if group.connection.databaseType.isBeta { diff --git a/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceContentView.swift b/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceContentView.swift index 5e0c3fdcb..324521604 100644 --- a/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceContentView.swift +++ b/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceContentView.swift @@ -31,6 +31,38 @@ struct WorkspaceContentView: View { } } } + .sheet( + item: Binding( + get: { appState.activeSheet }, + set: { newValue in + if let newValue { + appState.activeSheet = newValue + } else { + appState.dismissSheet() + } + } + ) + ) { sheet in + switch sheet { + case .structureScriptPreview: + if let data = appState.structureScriptData { + StructureScriptPreviewSheet( + context: SQLPopoutContext( + sql: data.statements.joined(separator: "\n\n"), + title: "Script Preview" + ) + ) { sql, database in + if let session = environmentState.sessionGroup.sessionForConnection(tab.connection.id) { + environmentState.openQueryTab(for: session, presetQuery: sql, database: database) + } else { + environmentState.openQueryTab(presetQuery: sql, database: database) + } + } + } + default: + EmptyView() + } + } } // MARK: - Content Resolution (switch-based to help type-checker) @@ -40,7 +72,11 @@ struct WorkspaceContentView: View { switch tab.kind { case .structure: if let vm = tab.structureEditor { - TableStructureEditorView(tab: tab, viewModel: vm).background(ColorTokens.Background.primary) + ZStack { + TableStructureEditorView(tab: tab, viewModel: vm) + .background(ColorTokens.Background.primary) + TableStructureSheetHost(tab: tab, viewModel: vm) + } } case .diagram: if let vm = tab.diagram { diff --git a/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceTabContainerView+BatchExecution.swift b/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceTabContainerView+BatchExecution.swift index a73a6a105..6771b7e29 100644 --- a/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceTabContainerView+BatchExecution.swift +++ b/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceTabContainerView+BatchExecution.swift @@ -123,6 +123,7 @@ extension WorkspaceTabContainerView { await MainActor.run { queryState.errorMessage = nil + queryState.prefersMessagesAfterExecution = batchResultsPreferMessages(batches, databaseType: tab.connection.databaseType) queryState.startExecution() queryState.setExecutingTask(task) environmentState.dataInspectorContent = nil @@ -214,4 +215,10 @@ extension WorkspaceTabContainerView { state.batchResultMetadata = batchLabels } } + + private func batchResultsPreferMessages(_ batches: [String], databaseType: DatabaseType) -> Bool { + batches.allSatisfy { + QueryStatementClassifier.isLikelyMessageOnlyStatement($0, databaseType: databaseType) + } + } } diff --git a/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceTabContainerView+Execution.swift b/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceTabContainerView+Execution.swift index e5e927f01..a8b32f787 100644 --- a/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceTabContainerView+Execution.swift +++ b/Echo/Sources/Features/AppHost/Views/Tabs/WorkspaceContainer/WorkspaceTabContainerView+Execution.swift @@ -132,6 +132,12 @@ extension WorkspaceTabContainerView { } let inferredObject = inferPrimaryObjectName(from: effectiveSQL) + let prefersMessagesAfterExecution = + (tab.connection.databaseType == .microsoftSQL && queryState.statisticsEnabled) || + QueryStatementClassifier.isLikelyMessageOnlyStatement( + trimmedSQL, + databaseType: tab.connection.databaseType + ) await MainActor.run { queryState.updateClipboardObjectName(inferredObject) } @@ -204,31 +210,11 @@ extension WorkspaceTabContainerView { } await MainActor.run { - // Surface server info messages (statistics, warnings, etc.) - for serverMsg in result.serverMessages { - let severity: QueryExecutionMessage.Severity = serverMsg.kind == .error ? .error : .info - state.appendMessage( - message: serverMsg.message, - severity: severity, - metadata: serverMsg.number != 0 ? ["msgNumber": "\(serverMsg.number)"] : [:] - ) - } - - var metadata: [String: String] = [ - "rows": "\(result.rows.count)" - ] - let columnNames = result.columns.map(\.name).joined(separator: ", ") - if !columnNames.isEmpty { - metadata["columns"] = columnNames - } - if let commandTag = result.commandTag, !commandTag.isEmpty { - metadata["commandTag"] = commandTag - } - - state.appendMessage( - message: "Returned \(result.rows.count) row\(result.rows.count == 1 ? "" : "s")", - severity: .info, - metadata: metadata + appendResultMessages( + for: result, + originalSQL: trimmedSQL, + databaseType: tab.connection.databaseType, + state: state ) appState.addToQueryHistory( effectiveSQL, @@ -285,6 +271,7 @@ extension WorkspaceTabContainerView { await MainActor.run { queryState.errorMessage = nil + queryState.prefersMessagesAfterExecution = prefersMessagesAfterExecution queryState.startExecution() queryState.setExecutingTask(task) environmentState.dataInspectorContent = nil @@ -312,4 +299,78 @@ extension WorkspaceTabContainerView { return [:] } } + + @MainActor + private func appendResultMessages( + for result: QueryResultSet, + originalSQL: String, + databaseType: DatabaseType, + state: QueryEditorState + ) { + let isMessageOnlyStatement = QueryStatementClassifier.isLikelyMessageOnlyStatement( + originalSQL, + databaseType: databaseType + ) + + var emittedServerResponse = false + for serverMsg in result.serverMessages { + emittedServerResponse = true + let severity: QueryExecutionMessage.Severity = serverMsg.kind == .error ? .error : .info + var metadata = serverMsg.metadata + if serverMsg.number != 0 { + metadata["messageNumber"] = "\(serverMsg.number)" + } + if let serverName = serverMsg.serverName, !serverName.isEmpty { + metadata["server"] = serverName + } + state.appendMessage( + message: serverMsg.message, + severity: severity, + category: serverMsg.category ?? "Server Response", + procedure: serverMsg.procedureName, + line: serverMsg.lineNumber.map(Int.init), + metadata: metadata + ) + } + + if isMessageOnlyStatement, + let commandTag = result.commandTag?.trimmingCharacters(in: .whitespacesAndNewlines), + !commandTag.isEmpty { + emittedServerResponse = true + state.appendMessage( + message: commandTag, + severity: .info, + category: "Server Response", + metadata: ["commandTag": commandTag] + ) + } + + if isMessageOnlyStatement { + if !emittedServerResponse { + state.appendMessage( + message: "No additional server details were returned", + severity: .info, + category: "Server Response" + ) + } + return + } + + var metadata: [String: String] = [ + "rows": "\(result.rows.count)" + ] + let columnNames = result.columns.map(\.name).joined(separator: ", ") + if !columnNames.isEmpty { + metadata["columns"] = columnNames + } + if let commandTag = result.commandTag, !commandTag.isEmpty { + metadata["commandTag"] = commandTag + } + + state.appendMessage( + message: "Returned \(result.rows.count) row\(result.rows.count == 1 ? "" : "s")", + severity: .info, + metadata: metadata + ) + } } diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/Breadcrumbs/BreadcrumbToolbarContent+ConnectMenu.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/Breadcrumbs/BreadcrumbToolbarContent+ConnectMenu.swift index 886137c6c..b9d558bb7 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/Breadcrumbs/BreadcrumbToolbarContent+ConnectMenu.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/Breadcrumbs/BreadcrumbToolbarContent+ConnectMenu.swift @@ -65,8 +65,7 @@ final class ConnectToolbarMenuDelegate: NSObject { item.target = self item.representedObject = session item.state = isActive ? .on : .off - if let image = NSImage(named: conn.databaseType.iconName) { - image.size = NSSize(width: 16, height: 16) + if let image = conn.databaseType.menuIconImage() { item.image = image } else { item.image = NSImage(systemSymbolName: "server.rack", accessibilityDescription: nil) @@ -89,8 +88,7 @@ final class ConnectToolbarMenuDelegate: NSObject { item.target = self item.representedObject = conn item.state = .off - if let image = NSImage(named: conn.databaseType.iconName) { - image.size = NSSize(width: 16, height: 16) + if let image = conn.databaseType.menuIconImage() { item.image = image } else { item.image = NSImage(systemSymbolName: "server.rack", accessibilityDescription: nil) diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/Breadcrumbs/BreadcrumbToolbarContent+ConnectionsMenu.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/Breadcrumbs/BreadcrumbToolbarContent+ConnectionsMenu.swift index 3d6a9b109..a508d4de1 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/Breadcrumbs/BreadcrumbToolbarContent+ConnectionsMenu.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/Breadcrumbs/BreadcrumbToolbarContent+ConnectionsMenu.swift @@ -70,8 +70,7 @@ final class ConnectionsMenuDelegate: NSObject, NSMenuDelegate { item.target = self item.representedObject = session item.state = isActive ? .on : .off - if let image = NSImage(named: conn.databaseType.iconName) { - image.size = NSSize(width: 16, height: 16) + if let image = conn.databaseType.menuIconImage() { item.image = image } else { item.image = NSImage(systemSymbolName: "server.rack", accessibilityDescription: nil) @@ -122,8 +121,7 @@ final class ConnectionsMenuDelegate: NSObject, NSMenuDelegate { item.target = self item.representedObject = conn item.state = .off - if let image = NSImage(named: conn.databaseType.iconName) { - image.size = NSSize(width: 16, height: 16) + if let image = conn.databaseType.menuIconImage() { item.image = image } else { item.image = NSImage(systemSymbolName: "server.rack", accessibilityDescription: nil) diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/ConnectionToolbarMenuItems.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/ConnectionToolbarMenuItems.swift index ce6d5b3b0..a27858552 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/ConnectionToolbarMenuItems.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/ConnectionToolbarMenuItems.swift @@ -75,7 +75,7 @@ struct ConnectionToolbarMenuItems: View { private func connectionIcon(for connection: SavedConnection) -> ToolbarIcon { let assetName = connection.databaseType.iconName if hasImage(named: assetName) { - return .asset(assetName, isTemplate: false) + return .asset(assetName, isTemplate: connection.databaseType.usesTemplateIcon) } return .system("externaldrive") } diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsMenuBuilder.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsMenuBuilder.swift index 041f80031..c5891d321 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsMenuBuilder.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsMenuBuilder.swift @@ -100,12 +100,8 @@ enum ConnectionsMenuBuilder { item.state = .on } - if let image = NSImage(named: connection.databaseType.iconName) { - let sized = NSImage(size: NSSize(width: 16, height: 16), flipped: false) { rect in - image.draw(in: rect) - return true - } - item.image = sized + if let image = connection.databaseType.menuIconImage() { + item.image = image } else { item.image = NSImage(systemSymbolName: "server.rack", accessibilityDescription: nil)? .withSymbolConfiguration(.init(pointSize: 12, weight: .regular)) diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsPopoverController.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsPopoverController.swift index e1784a605..7f81c7af0 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsPopoverController.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsPopoverController.swift @@ -19,7 +19,7 @@ struct ConnectionsPopoverContent: View { PopoverConnectionRow( connection: conn, isSelected: conn.id == connectionStore.selectedConnectionID, - icon: iconImage(for: conn), + databaseType: conn.databaseType, displayName: displayName(for: conn) ) { environmentState.connect(to: conn) @@ -35,7 +35,7 @@ struct ConnectionsPopoverContent: View { PopoverConnectionRow( connection: conn, isSelected: conn.id == connectionStore.selectedConnectionID, - icon: iconImage(for: conn), + databaseType: conn.databaseType, displayName: displayName(for: conn) ) { environmentState.connect(to: conn) @@ -52,7 +52,7 @@ struct ConnectionsPopoverContent: View { PopoverConnectionRow( connection: conn, isSelected: conn.id == connectionStore.selectedConnectionID, - icon: iconImage(for: conn), + databaseType: conn.databaseType, displayName: displayName(for: conn) ) { environmentState.connect(to: conn) @@ -127,10 +127,6 @@ struct ConnectionsPopoverContent: View { .padding(.vertical, SpacingTokens.xxs2) } - private func iconImage(for connection: SavedConnection) -> NSImage? { - NSImage(named: connection.databaseType.iconName) - } - private func displayName(for connection: SavedConnection) -> String { let trimmed = connection.connectionName.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? connection.host : trimmed @@ -142,7 +138,7 @@ struct ConnectionsPopoverContent: View { private struct PopoverConnectionRow: View { let connection: SavedConnection let isSelected: Bool - let icon: NSImage? + let databaseType: DatabaseType let displayName: String let action: () -> Void @@ -156,17 +152,7 @@ private struct PopoverConnectionRow: View { .frame(width: 14) .opacity(isSelected ? 1 : 0) - if let icon { - Image(nsImage: icon) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } else { - Image(systemName: "server.rack") - .font(TypographyTokens.caption2) - .foregroundStyle(ColorTokens.Text.secondary) - .frame(width: 16, height: 16) - } + DatabaseTypeIcon(databaseType: databaseType, presentation: .menu) Text(displayName) .font(TypographyTokens.standard) diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsPopoverView.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsPopoverView.swift index a71a39d9f..1e6f9b286 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsPopoverView.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/Popovers/ConnectionsPopoverView.swift @@ -121,15 +121,7 @@ struct ConnectionsPopoverView: View { @ViewBuilder private func connectionIcon(_ connection: SavedConnection) -> some View { - if let image = NSImage(named: connection.databaseType.iconName) { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - Image(systemName: "server.rack") - .font(TypographyTokens.caption2) - .foregroundStyle(ColorTokens.Text.secondary) - } + DatabaseTypeIcon(databaseType: connection.databaseType, presentation: .menu) } // MARK: - Data diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/TableStructureToolbarItem.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/TableStructureToolbarItem.swift new file mode 100644 index 000000000..ff4eaadc8 --- /dev/null +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/TableStructureToolbarItem.swift @@ -0,0 +1,155 @@ +import SwiftUI + +/// Toolbar controls for Structure tab — shown only when active tab is a Structure editor. +/// Provides Add, Script, and Apply buttons with dynamic visibility based on pending changes. +struct TableStructureToolbarItem: View { + private struct ApplyReviewPresentation: Identifiable { + let id = UUID() + let tableName: String + let statements: [String] + } + + @Environment(TabStore.self) private var tabStore + @Environment(EnvironmentState.self) private var environmentState + @Environment(AppState.self) private var appState + @State private var applyReview: ApplyReviewPresentation? + + var body: some View { + if let tab = tabStore.activeTab, let vm = tab.structureEditor { + structureControls(viewModel: vm, tab: tab) + } else { + EmptyView() + } + } + + @ViewBuilder + private func structureControls(viewModel: TableStructureEditorViewModel, tab: WorkspaceTab) -> some View { + HStack(spacing: SpacingTokens.sm) { + addButton + .glassEffect(.regular.interactive()) + + if viewModel.hasPendingChanges { + HStack(spacing: SpacingTokens.none) { + scriptButton(viewModel: viewModel) + applyButton(viewModel: viewModel, tab: tab) + } + .glassEffect(.regular.interactive()) + } + } + .sheet(item: $applyReview) { review in + StructureApplyReviewSheet( + tableName: review.tableName, + statements: review.statements + ) { + await applyChanges(viewModel: viewModel, tab: tab) + } + } + } + + private var addButton: some View { + Menu { + Button { + if let vm = tabStore.activeTab?.structureEditor { + vm.requestAddAction(.column, section: .columns) + } + } label: { + Label("Add Column", systemImage: "tablecells") + } + + Button { + if let vm = tabStore.activeTab?.structureEditor { + vm.requestAddAction(.index, section: .indexes) + } + } label: { + Label("Add Index", systemImage: "list.bullet.rectangle") + } + + Divider() + + Button { + if let vm = tabStore.activeTab?.structureEditor { + vm.requestAddAction(.foreignKey, section: .relations) + } + } label: { + Label("Add Foreign Key", systemImage: "link") + } + + Button { + if let vm = tabStore.activeTab?.structureEditor { + vm.requestAddAction(.uniqueConstraint, section: .constraints) + } + } label: { + Label("Add Unique Constraint", systemImage: "checkmark.shield") + } + + Button { + if let vm = tabStore.activeTab?.structureEditor { + vm.requestAddAction(.checkConstraint, section: .constraints) + } + } label: { + Label("Add Check Constraint", systemImage: "checkmark.rectangle.stack") + } + } label: { + Label("Add", systemImage: "plus") + .labelStyle(.iconOnly) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .help("Add item to table structure") + .accessibilityLabel("Add") + } + + private func scriptButton(viewModel: TableStructureEditorViewModel) -> some View { + Button { + let statements = viewModel.generateStatements() + guard !statements.isEmpty else { return } + appState.showStructureScriptPreview(statements: statements) + } label: { + Label("Script", systemImage: "doc.text") + } + .labelStyle(.iconOnly) + .help("View generated SQL script") + .accessibilityLabel("View script") + .disabled(viewModel.isApplying) + } + + private func applyButton(viewModel: TableStructureEditorViewModel, tab: WorkspaceTab) -> some View { + Button { + let statements = viewModel.generateStatements() + guard !statements.isEmpty else { return } + applyReview = ApplyReviewPresentation(tableName: viewModel.tableName, statements: statements) + } label: { + Label("Apply", systemImage: "checkmark") + } + .labelStyle(.iconOnly) + .help("Apply changes to database") + .accessibilityLabel("Apply changes") + .disabled(viewModel.isApplying) + .keyboardShortcut(.return, modifiers: [.command, .shift]) + } + + private func applyChanges(viewModel: TableStructureEditorViewModel, tab: WorkspaceTab) async -> Bool { + await viewModel.applyChanges() + + if let error = viewModel.lastError { + environmentState.notificationEngine?.post( + category: .generalError, + message: error + ) + return false + } else if viewModel.lastSuccessMessage != nil { + environmentState.notificationEngine?.post( + category: .generalSuccess, + message: "Structure of \(viewModel.tableName) updated" + ) + await environmentState.refreshDatabaseStructure( + for: tab.connectionSessionID, + scope: .selectedDatabase, + databaseOverride: tab.connection.database.isEmpty ? nil : tab.connection.database + ) + return true + } + + return false + } +} diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+Connections.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+Connections.swift index 3d4535225..615e7b040 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+Connections.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+Connections.swift @@ -78,13 +78,10 @@ struct ConnectionsMenuButton: View { Button { environmentState.connectToNewSession(to: connection) } label: { - HStack { - if let image = NSImage(named: connection.databaseType.iconName) { - Image(nsImage: image) - } else { - Image(systemName: "server.rack") - } + Label { Text(connection.connectionName.isEmpty ? connection.host : connection.connectionName) + } icon: { + DatabaseTypeIcon(databaseType: connection.databaseType, presentation: .menu) } } } @@ -99,11 +96,7 @@ struct ConnectionsMenuButton: View { environmentState.sessionGroup.setActiveSession(session.id) } label: { HStack { - if let image = NSImage(named: conn.databaseType.iconName) { - Image(nsImage: image) - } else { - Image(systemName: "server.rack") - } + DatabaseTypeIcon(databaseType: conn.databaseType, presentation: .menu) Text(session.displayName) if isActive { Spacer() @@ -152,13 +145,10 @@ struct ToolbarFolderMenu: View { Button { onConnect(connection) } label: { - HStack { - if let image = NSImage(named: connection.databaseType.iconName) { - Image(nsImage: image) - } else { - Image(systemName: "server.rack") - } + Label { Text(connection.connectionName.isEmpty ? connection.host : connection.connectionName) + } icon: { + DatabaseTypeIcon(databaseType: connection.databaseType, presentation: .menu) } } } diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+PreviewData.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+PreviewData.swift index b34d26cd7..33a61ba61 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+PreviewData.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+PreviewData.swift @@ -39,6 +39,12 @@ struct WorkspaceToolbarPreviewData { )) let navigationStore = NavigationStore() let tabStore = TabStore() + let objectBrowserCacheStore = ObjectBrowserCacheStore( + configuration: .init( + rootDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent("EchoPreviewObjectBrowserCache", isDirectory: true) + ) + ) let environmentState = EnvironmentState( projectStore: projectStore, @@ -49,12 +55,17 @@ struct WorkspaceToolbarPreviewData { resultSpoolConfigCoordinator: ResultSpoolConfig(spoolManager: spoolManager), diagramBuilder: DiagramBuilder(cacheManager: diagramManager, keyStore: diagramKeyStore), identityRepository: IdentityRepository(connectionStore: connectionStore), - schemaDiscoveryEngine: MetadataDiscoveryEngine(identityRepository: IdentityRepository(connectionStore: connectionStore), connectionStore: connectionStore), + schemaDiscoveryEngine: MetadataDiscoveryEngine( + identityRepository: IdentityRepository(connectionStore: connectionStore), + connectionStore: connectionStore, + objectBrowserCacheStore: objectBrowserCacheStore + ), bookmarkRepository: BookmarkRepository(), historyRepository: HistoryRepository(), resultSpoolManager: spoolManager, diagramCacheStore: diagramManager, - diagramKeyStore: diagramKeyStore + diagramKeyStore: diagramKeyStore, + objectBrowserCacheStore: objectBrowserCacheStore ) let appState = AppState() let appearanceStore = AppearanceStore.shared diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+Recents.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+Recents.swift index 87d972bff..17f5cd2f3 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+Recents.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems+Recents.swift @@ -26,11 +26,7 @@ struct RecentConnectionsMenuButton: View { } } label: { HStack { - if let image = NSImage(named: record.databaseType.iconName) { - Image(nsImage: image) - } else { - Image(systemName: "server.rack") - } + DatabaseTypeIcon(databaseType: record.databaseType, presentation: .menu) let baseName = record.connectionName.isEmpty ? record.host : record.connectionName diff --git a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems.swift b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems.swift index ec1ece447..55431ee78 100644 --- a/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems.swift +++ b/Echo/Sources/Features/AppHost/Views/Toolbar/WorkspaceToolbarItems/WorkspaceToolbarItems.swift @@ -57,6 +57,12 @@ struct WorkspaceToolbarItems: CustomizableToolbarContent { @ToolbarContentBuilder private var contextActionItems: some CustomizableToolbarContent { + // Structure tab — Add/Script/Apply buttons + ToolbarItem(id: "workspace.primary.structure", placement: .primaryAction) { + TableStructureToolbarItem() + } + .sharedBackgroundVisibility(.hidden) + // Activity Monitor, Job Queue, Maintenance — tab-specific controls ToolbarItem(id: "workspace.primary.activitymonitor", placement: .primaryAction) { ActivityMonitorToolbarItem() diff --git a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+MetadataFreshness.swift b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+MetadataFreshness.swift new file mode 100644 index 000000000..043fb776a --- /dev/null +++ b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+MetadataFreshness.swift @@ -0,0 +1,99 @@ +import Foundation + +enum DatabaseMetadataFreshness: String, Codable, Sendable { + case listOnly + case cached + case refreshing + case live + case failed +} + +extension ConnectionSession { + func isRefreshingMetadata(forDatabase databaseName: String) -> Bool { + metadataFreshness(forDatabase: databaseName) == .refreshing + || schemaLoadsInFlight.contains(schemaLoadKey(databaseName)) + } + + func metadataFreshness(forDatabase databaseName: String) -> DatabaseMetadataFreshness { + metadataFreshnessByDatabase[schemaLoadKey(databaseName)] ?? .listOnly + } + + func hydrateMetadataFreshnessFromCacheStructure() { + guard let structure = databaseStructure else { + metadataFreshnessByDatabase.removeAll() + return + } + metadataFreshnessByDatabase = Self.makeMetadataFreshnessMap( + from: structure, + loadedState: .cached, + preserveExisting: false, + existing: [:] + ) + } + + func reconcileMetadataFreshnessFromLiveStructure( + markingLive databasesLoadedLive: Set = [] + ) { + guard let structure = databaseStructure else { + metadataFreshnessByDatabase.removeAll() + return + } + let normalizedLive = Set(databasesLoadedLive.map(schemaLoadKey)) + metadataFreshnessByDatabase = Self.makeMetadataFreshnessMap( + from: structure, + loadedState: .cached, + preserveExisting: true, + existing: metadataFreshnessByDatabase, + liveDatabases: normalizedLive + ) + } + + func markMetadataRefreshStarted(forDatabase databaseName: String) { + metadataFreshnessByDatabase[schemaLoadKey(databaseName)] = .refreshing + } + + func markMetadataRefreshCompleted(forDatabase databaseName: String, hasSchemas: Bool) { + metadataFreshnessByDatabase[schemaLoadKey(databaseName)] = hasSchemas ? .live : .listOnly + } + + func markMetadataRefreshFailed(forDatabase databaseName: String) { + metadataFreshnessByDatabase[schemaLoadKey(databaseName)] = .failed + } + + func clearMetadataCacheState() { + metadataFreshnessByDatabase.removeAll() + databaseStructure = nil + structureLoadingState = .idle + structureLoadingMessage = nil + schemaLoadsInFlight.removeAll() + } + + private static func makeMetadataFreshnessMap( + from structure: DatabaseStructure, + loadedState: DatabaseMetadataFreshness, + preserveExisting: Bool, + existing: [String: DatabaseMetadataFreshness], + liveDatabases: Set = [] + ) -> [String: DatabaseMetadataFreshness] { + var next: [String: DatabaseMetadataFreshness] = [:] + for database in structure.databases { + let key = database.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !key.isEmpty else { continue } + let hasSchemas = database.schemas.contains(where: { !$0.objects.isEmpty }) + if !hasSchemas { + next[key] = .listOnly + continue + } + if liveDatabases.contains(key) { + next[key] = .live + continue + } + if preserveExisting, let state = existing[key], state == .live || state == .refreshing { + next[key] = .live + continue + } + next[key] = loadedState + } + return next + } +} diff --git a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+SchemaLoading.swift b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+SchemaLoading.swift index 964665a06..8c2933cbc 100644 --- a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+SchemaLoading.swift +++ b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+SchemaLoading.swift @@ -16,9 +16,12 @@ extension ConnectionSession { func hasLoadedSchema(forDatabase databaseName: String) -> Bool { let normalizedName = normalizedDatabaseName(databaseName) guard !normalizedName.isEmpty else { return false } + let key = schemaLoadKey(normalizedName) + if metadataFreshnessByDatabase[key] == .listOnly { + return false + } return databaseStructure?.databases - .first(where: { normalizedDatabaseName($0.name).caseInsensitiveCompare(normalizedName) == .orderedSame })? - .schemas.isEmpty == false + .first(where: { normalizedDatabaseName($0.name).caseInsensitiveCompare(normalizedName) == .orderedSame }) != nil } func beginSchemaLoad(forDatabase databaseName: String) -> Bool { diff --git a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Monitor.swift b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Monitor.swift new file mode 100644 index 000000000..8fe689492 --- /dev/null +++ b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Monitor.swift @@ -0,0 +1,144 @@ +import Foundation +import SwiftUI +import SQLServerKit + +// MARK: - Monitor Tab Factory Methods + +extension ConnectionSession { + + /// Display label for the server in tab subtitles — prefers connection name, falls back to host. + var serverLabel: String { + let connName = connection.connectionName.trimmingCharacters(in: .whitespacesAndNewlines) + return connName.isEmpty ? connection.host : connName + } + + @discardableResult + func addActivityMonitorTab() throws -> WorkspaceTab { + // Reuse existing activity monitor tab if present + if let existing = queryTabs.first(where: { $0.activityMonitor != nil }) { + activeQueryTabID = existing.id + return existing + } + + let monitor = try session.makeActivityMonitor() + let interval = AppDirector.shared.projectStore.globalSettings.activityMonitorRefreshInterval + let viewModel = ActivityMonitorViewModel( + monitor: monitor, + mysqlSession: session as? MySQLSession, + connectionSessionID: self.id, + connectionID: connection.id, + databaseType: connection.databaseType, + refreshInterval: interval + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + + if let mssql = session as? MSSQLSession { + let xeVM = ExtendedEventsViewModel( + xeClient: mssql.extendedEvents, + connectionSessionID: id + ) + xeVM.activityEngine = AppDirector.shared.activityEngine + viewModel.extendedEventsVM = xeVM + viewModel.profilerVM = ProfilerViewModel( + profilerClient: mssql.profiler, + session: session, + connectionSessionID: id + ) + } + + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Activity Monitor", + content: .activityMonitor(viewModel), + activeDatabaseName: nil + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + func addExtendedEventsTab() -> WorkspaceTab? { + guard let mssql = session as? MSSQLSession else { return nil } + + // Reuse existing extended events tab if present + if let existing = queryTabs.first(where: { $0.extendedEventsVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = ExtendedEventsViewModel( + xeClient: mssql.extendedEvents, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Extended Events", + content: .extendedEvents(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + func addProfilerTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.profilerVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = ProfilerViewModel( + profilerClient: session.profiler, + session: session, + connectionSessionID: id + ) + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "SQL Profiler", + content: .profiler(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + func addResourceGovernorTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.resourceGovernorVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = ResourceGovernorViewModel( + rgClient: session.resourceGovernor, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Resource Governor", + content: .resourceGovernor(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } +} diff --git a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Security.swift b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Security.swift new file mode 100644 index 000000000..d289eb4cd --- /dev/null +++ b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Security.swift @@ -0,0 +1,142 @@ +import Foundation +import SwiftUI + +// MARK: - Security Tab Factory Methods + +extension ConnectionSession { + + @discardableResult + func addDatabaseSecurityTab(databaseName: String? = nil) -> WorkspaceTab { + if connection.databaseType == .postgresql { + return addPostgresDatabaseSecurityTab() + } + if connection.databaseType == .mysql { + return addMySQLDatabaseSecurityTab(databaseName: databaseName) + } + return addMSSQLDatabaseSecurityTab(databaseName: databaseName) + } + + @discardableResult + private func addMSSQLDatabaseSecurityTab(databaseName: String? = nil) -> WorkspaceTab { + let effectiveDatabase = databaseName ?? sidebarFocusedDatabase ?? connection.database + + if let existing = queryTabs.first(where: { $0.databaseSecurity != nil }) { + activeQueryTabID = existing.id + if let vm = existing.databaseSecurity, vm.selectedDatabase != effectiveDatabase { + existing.activeDatabaseName = effectiveDatabase.isEmpty ? nil : effectiveDatabase + Task { await vm.selectDatabase(effectiveDatabase) } + } + return existing + } + + let viewModel = DatabaseSecurityViewModel( + session: session, + connectionID: connection.id, + connectionSessionID: id, + initialDatabase: effectiveDatabase.isEmpty ? nil : effectiveDatabase + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + + let dbName = databaseName ?? sidebarFocusedDatabase + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Database Security", + content: .databaseSecurity(viewModel), + activeDatabaseName: (dbName?.isEmpty == false) ? dbName : nil + ) + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + private func addPostgresDatabaseSecurityTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.postgresSecurity != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = PostgresDatabaseSecurityViewModel( + session: session, + connectionID: connection.id, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Database Security", + content: .postgresSecurity(viewModel) + ) + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + private func addMySQLDatabaseSecurityTab(databaseName: String? = nil) -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.mysqlSecurity != nil }) { + activeQueryTabID = existing.id + if let databaseName, !databaseName.isEmpty { + existing.activeDatabaseName = databaseName + } + return existing + } + + let viewModel = MySQLDatabaseSecurityViewModel( + session: session, + connectionID: connection.id, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + + let effectiveDatabase = databaseName ?? sidebarFocusedDatabase ?? connection.database + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Database Security", + content: .mysqlSecurity(viewModel), + activeDatabaseName: effectiveDatabase.isEmpty ? nil : effectiveDatabase + ) + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + func addServerSecurityTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.serverSecurity != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = ServerSecurityViewModel( + session: session, + connectionID: connection.id, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Server Security", + content: .serverSecurity(viewModel), + activeDatabaseName: nil + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } +} diff --git a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+ServerTools.swift b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+ServerTools.swift new file mode 100644 index 000000000..2a97d904b --- /dev/null +++ b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+ServerTools.swift @@ -0,0 +1,264 @@ +import Foundation +import SwiftUI +import SQLServerKit + +// MARK: - Server Tools Tab Factory Methods + +extension ConnectionSession { + + @discardableResult + func addServerPropertiesTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.serverPropertiesVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = ServerPropertiesViewModel( + session: session, + connectionID: connection.id, + connectionSessionID: id, + connectionHost: connection.host + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Server Properties", + content: .serverProperties(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + func addTuningAdvisorTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.tuningAdvisorVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = TuningAdvisorViewModel( + tuningClient: session.tuning, + session: session, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Tuning Advisor", + content: .tuningAdvisor(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + func addPolicyManagementTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.policyManagementVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = PolicyManagementViewModel( + policyClient: session.policy, + connectionSessionID: id + ) + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Policy Management", + content: .policyManagement(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + @discardableResult + func addAvailabilityGroupsTab() -> WorkspaceTab? { + guard let mssql = session as? MSSQLSession else { return nil } + + // Reuse existing availability groups tab if present + if let existing = queryTabs.first(where: { $0.availabilityGroupsVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = AvailabilityGroupsViewModel( + agClient: mssql.availabilityGroups, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Availability Groups", + content: .availabilityGroups(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + // MARK: - Error Log Tab + + @discardableResult + func addErrorLogTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.errorLogVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = ErrorLogViewModel(session: session, connectionSessionID: id) + viewModel.activityEngine = AppDirector.shared.activityEngine + viewModel.notificationEngine = AppDirector.shared.notificationEngine + + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Error Log", + content: .errorLog(viewModel), + activeDatabaseName: nil + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + // MARK: - Advanced Objects Tab (PostgreSQL) + + @discardableResult + func addPostgresAdvancedObjectsTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.postgresAdvancedObjectsVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = PostgresAdvancedObjectsViewModel( + session: session, + connectionID: connection.id, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Advanced Objects", + content: .postgresAdvancedObjects(viewModel), + activeDatabaseName: nil + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + // MARK: - Advanced Objects Tab (MSSQL) + + @discardableResult + func addMSSQLAdvancedObjectsTab(databaseName: String) -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.mssqlAdvancedObjectsVM != nil }) { + if let vm = existing.mssqlAdvancedObjectsVM, vm.databaseName != databaseName { + vm.databaseName = databaseName + vm.isInitialized = false + Task { await vm.initialize() } + } + activeQueryTabID = existing.id + return existing + } + + let viewModel = MSSQLAdvancedObjectsViewModel( + session: session, + connectionID: connection.id, + connectionSessionID: id, + databaseName: databaseName + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Advanced Objects", + content: .mssqlAdvancedObjects(viewModel), + activeDatabaseName: databaseName + ) + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + // MARK: - Schema Diff Tab (PostgreSQL) + + @discardableResult + func addSchemaDiffTab() -> WorkspaceTab { + if let existing = queryTabs.first(where: { $0.schemaDiffVM != nil }) { + activeQueryTabID = existing.id + return existing + } + + let viewModel = SchemaDiffViewModel( + session: session, + connectionID: connection.id, + connectionSessionID: id + ) + viewModel.activityEngine = AppDirector.shared.activityEngine + + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Schema Diff", + content: .schemaDiff(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } + + // MARK: - Visual Query Builder + + @discardableResult + func addQueryBuilderTab() -> WorkspaceTab { + let viewModel = VisualQueryBuilderViewModel( + databaseType: connection.databaseType, + session: session + ) + let tab = WorkspaceTab( + connection: connection, + session: session, + connectionSessionID: id, + title: "Query Builder", + content: .queryBuilder(viewModel) + ) + tab.tabSubtitle = serverLabel + queryTabs.append(tab) + activeQueryTabID = tab.id + lastActivity = Date() + return tab + } +} diff --git a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Specialized.swift b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Specialized.swift deleted file mode 100644 index c164af2d3..000000000 --- a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession+TabFactory+Specialized.swift +++ /dev/null @@ -1,536 +0,0 @@ -import Foundation -import SwiftUI -import SQLServerKit - -// MARK: - Specialized Tab Factory Methods (Monitor, MSSQL, Security) - -extension ConnectionSession { - - /// Display label for the server in tab subtitles — prefers connection name, falls back to host. - private var serverLabel: String { - let connName = connection.connectionName.trimmingCharacters(in: .whitespacesAndNewlines) - return connName.isEmpty ? connection.host : connName - } - - @discardableResult - func addActivityMonitorTab() throws -> WorkspaceTab { - // Reuse existing activity monitor tab if present - if let existing = queryTabs.first(where: { $0.activityMonitor != nil }) { - activeQueryTabID = existing.id - return existing - } - - let monitor = try session.makeActivityMonitor() - let interval = AppDirector.shared.projectStore.globalSettings.activityMonitorRefreshInterval - let viewModel = ActivityMonitorViewModel( - monitor: monitor, - mysqlSession: session as? MySQLSession, - connectionSessionID: self.id, - connectionID: connection.id, - databaseType: connection.databaseType, - refreshInterval: interval - ) - - if let mssql = session as? MSSQLSession { - let xeVM = ExtendedEventsViewModel( - xeClient: mssql.extendedEvents, - connectionSessionID: id - ) - xeVM.activityEngine = AppDirector.shared.activityEngine - viewModel.extendedEventsVM = xeVM - viewModel.profilerVM = ProfilerViewModel( - profilerClient: mssql.profiler, - session: session, - connectionSessionID: id - ) - } - - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Activity Monitor", - content: .activityMonitor(viewModel), - activeDatabaseName: nil - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - func addExtendedEventsTab() -> WorkspaceTab? { - guard let mssql = session as? MSSQLSession else { return nil } - - // Reuse existing extended events tab if present - if let existing = queryTabs.first(where: { $0.extendedEventsVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = ExtendedEventsViewModel( - xeClient: mssql.extendedEvents, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Extended Events", - content: .extendedEvents(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - func addProfilerTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.profilerVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = ProfilerViewModel( - profilerClient: session.profiler, - session: session, - connectionSessionID: id - ) - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "SQL Profiler", - content: .profiler(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - func addResourceGovernorTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.resourceGovernorVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = ResourceGovernorViewModel( - rgClient: session.resourceGovernor, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Resource Governor", - content: .resourceGovernor(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - func addServerPropertiesTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.serverPropertiesVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = ServerPropertiesViewModel( - session: session, - connectionID: connection.id, - connectionSessionID: id, - connectionHost: connection.host - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Server Properties", - content: .serverProperties(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - func addTuningAdvisorTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.tuningAdvisorVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = TuningAdvisorViewModel( - tuningClient: session.tuning, - session: session, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Tuning Advisor", - content: .tuningAdvisor(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - func addPolicyManagementTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.policyManagementVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = PolicyManagementViewModel( - policyClient: session.policy, - connectionSessionID: id - ) - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Policy Management", - content: .policyManagement(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - func addAvailabilityGroupsTab() -> WorkspaceTab? { - guard let mssql = session as? MSSQLSession else { return nil } - - // Reuse existing availability groups tab if present - if let existing = queryTabs.first(where: { $0.availabilityGroupsVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = AvailabilityGroupsViewModel( - agClient: mssql.availabilityGroups, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Availability Groups", - content: .availabilityGroups(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - // MARK: - Error Log Tab - - @discardableResult - func addErrorLogTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.errorLogVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = ErrorLogViewModel(session: session, connectionSessionID: id) - viewModel.activityEngine = AppDirector.shared.activityEngine - viewModel.notificationEngine = AppDirector.shared.notificationEngine - - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Error Log", - content: .errorLog(viewModel), - activeDatabaseName: nil - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - // MARK: - Advanced Objects Tab (PostgreSQL) - - @discardableResult - func addPostgresAdvancedObjectsTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.postgresAdvancedObjectsVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = PostgresAdvancedObjectsViewModel( - session: session, - connectionID: connection.id, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Advanced Objects", - content: .postgresAdvancedObjects(viewModel), - activeDatabaseName: nil - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - // MARK: - Advanced Objects Tab (MSSQL) - - @discardableResult - func addMSSQLAdvancedObjectsTab(databaseName: String) -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.mssqlAdvancedObjectsVM != nil }) { - if let vm = existing.mssqlAdvancedObjectsVM, vm.databaseName != databaseName { - vm.databaseName = databaseName - vm.isInitialized = false - Task { await vm.initialize() } - } - activeQueryTabID = existing.id - return existing - } - - let viewModel = MSSQLAdvancedObjectsViewModel( - session: session, - connectionID: connection.id, - connectionSessionID: id, - databaseName: databaseName - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Advanced Objects", - content: .mssqlAdvancedObjects(viewModel), - activeDatabaseName: databaseName - ) - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - // MARK: - Schema Diff Tab (PostgreSQL) - - @discardableResult - func addSchemaDiffTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.schemaDiffVM != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = SchemaDiffViewModel( - session: session, - connectionID: connection.id, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Schema Diff", - content: .schemaDiff(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - // MARK: - Visual Query Builder - - @discardableResult - func addQueryBuilderTab() -> WorkspaceTab { - let viewModel = VisualQueryBuilderViewModel( - databaseType: connection.databaseType, - session: session - ) - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Query Builder", - content: .queryBuilder(viewModel) - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - // MARK: - Security Tabs - - @discardableResult - func addDatabaseSecurityTab(databaseName: String? = nil) -> WorkspaceTab { - if connection.databaseType == .postgresql { - return addPostgresDatabaseSecurityTab() - } - if connection.databaseType == .mysql { - return addMySQLDatabaseSecurityTab(databaseName: databaseName) - } - return addMSSQLDatabaseSecurityTab(databaseName: databaseName) - } - - @discardableResult - private func addMSSQLDatabaseSecurityTab(databaseName: String? = nil) -> WorkspaceTab { - let effectiveDatabase = databaseName ?? sidebarFocusedDatabase ?? connection.database - - if let existing = queryTabs.first(where: { $0.databaseSecurity != nil }) { - activeQueryTabID = existing.id - if let vm = existing.databaseSecurity, vm.selectedDatabase != effectiveDatabase { - existing.activeDatabaseName = effectiveDatabase.isEmpty ? nil : effectiveDatabase - Task { await vm.selectDatabase(effectiveDatabase) } - } - return existing - } - - let viewModel = DatabaseSecurityViewModel( - session: session, - connectionID: connection.id, - connectionSessionID: id, - initialDatabase: effectiveDatabase.isEmpty ? nil : effectiveDatabase - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - - let dbName = databaseName ?? sidebarFocusedDatabase - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Database Security", - content: .databaseSecurity(viewModel), - activeDatabaseName: (dbName?.isEmpty == false) ? dbName : nil - ) - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - private func addPostgresDatabaseSecurityTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.postgresSecurity != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = PostgresDatabaseSecurityViewModel( - session: session, - connectionID: connection.id, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Database Security", - content: .postgresSecurity(viewModel) - ) - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - private func addMySQLDatabaseSecurityTab(databaseName: String? = nil) -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.mysqlSecurity != nil }) { - activeQueryTabID = existing.id - if let databaseName, !databaseName.isEmpty { - existing.activeDatabaseName = databaseName - } - return existing - } - - let viewModel = MySQLDatabaseSecurityViewModel( - session: session, - connectionID: connection.id, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - - let effectiveDatabase = databaseName ?? sidebarFocusedDatabase ?? connection.database - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Database Security", - content: .mysqlSecurity(viewModel), - activeDatabaseName: effectiveDatabase.isEmpty ? nil : effectiveDatabase - ) - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } - - @discardableResult - func addServerSecurityTab() -> WorkspaceTab { - if let existing = queryTabs.first(where: { $0.serverSecurity != nil }) { - activeQueryTabID = existing.id - return existing - } - - let viewModel = ServerSecurityViewModel( - session: session, - connectionID: connection.id, - connectionSessionID: id - ) - viewModel.activityEngine = AppDirector.shared.activityEngine - - let tab = WorkspaceTab( - connection: connection, - session: session, - connectionSessionID: id, - title: "Server Security", - content: .serverSecurity(viewModel), - activeDatabaseName: nil - ) - tab.tabSubtitle = serverLabel - queryTabs.append(tab) - activeQueryTabID = tab.id - lastActivity = Date() - return tab - } -} diff --git a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession.swift b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession.swift index 91e02eeec..9c7efcc2c 100644 --- a/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession.swift +++ b/Echo/Sources/Features/ConnectionVault/Domain/ConnectionSession.swift @@ -45,6 +45,7 @@ final class ConnectionSession: Identifiable { @ObservationIgnored var defaultBackgroundStreamingThreshold: Int @ObservationIgnored var defaultBackgroundFetchSize: Int @ObservationIgnored var schemaLoadsInFlight: Set = [] + @ObservationIgnored var metadataFreshnessByDatabase: [String: DatabaseMetadataFreshness] = [:] // Query tabs specific to this connection var queryTabs: [WorkspaceTab] = [] diff --git a/Echo/Sources/Features/ConnectionVault/Domain/SavedConnection+ObjectBrowserCache.swift b/Echo/Sources/Features/ConnectionVault/Domain/SavedConnection+ObjectBrowserCache.swift new file mode 100644 index 000000000..9f69765fc --- /dev/null +++ b/Echo/Sources/Features/ConnectionVault/Domain/SavedConnection+ObjectBrowserCache.swift @@ -0,0 +1,21 @@ +import Foundation + +extension SavedConnection { + var objectBrowserCacheFingerprint: String { + [ + "type=\(databaseType.rawValue)", + "host=\(host.lowercased())", + "port=\(port)", + "database=\(database.lowercased())", + "username=\(username.lowercased())", + "auth=\(authenticationMethod.rawValue)", + "domain=\(domain.lowercased())", + "tls=\(useTLS)", + "trust=\(trustServerCertificate)", + "tlsMode=\(tlsMode.rawValue)", + "mssqlEnc=\(mssqlEncryptionMode.rawValue)", + "readonly=\(readOnlyIntent)" + ] + .joined(separator: "|") + } +} diff --git a/Echo/Sources/Features/ConnectionVault/Domain/SavedConnection.swift b/Echo/Sources/Features/ConnectionVault/Domain/SavedConnection.swift index 5906c1d4d..8892c11b3 100644 --- a/Echo/Sources/Features/ConnectionVault/Domain/SavedConnection.swift +++ b/Echo/Sources/Features/ConnectionVault/Domain/SavedConnection.swift @@ -18,10 +18,17 @@ enum DatabaseType: String, Sendable, Codable, CaseIterable { nonisolated var iconName: String { switch self { - case .postgresql: return "postgresql" - case .mysql: return "mysql" - case .microsoftSQL: return "mssql" - case .sqlite: return "sqlite" + case .postgresql: return "PostgreSQL" + case .mysql: return "MySQL" + case .microsoftSQL: return "MicrosoftSQLServer" + case .sqlite: return "SQLite" + } + } + + nonisolated var usesTemplateIcon: Bool { + switch self { + case .postgresql, .mysql, .microsoftSQL, .sqlite: + return false } } diff --git a/Echo/Sources/Features/ConnectionVault/Views/ConnectionEditor/ConnectionEditorView+Actions.swift b/Echo/Sources/Features/ConnectionVault/Views/ConnectionEditor/ConnectionEditorView+Actions.swift index 768c5ffaf..9712af3e3 100644 --- a/Echo/Sources/Features/ConnectionVault/Views/ConnectionEditor/ConnectionEditorView+Actions.swift +++ b/Echo/Sources/Features/ConnectionVault/Views/ConnectionEditor/ConnectionEditorView+Actions.swift @@ -107,9 +107,10 @@ extension ConnectionEditorView { iconImage.draw(in: iconRect, from: .zero, operation: .sourceOver, fraction: 1.0) - // Tint the icon with the color - NSColor(color).setFill() - iconRect.fill(using: .sourceAtop) + if databaseType.usesTemplateIcon { + NSColor(color).setFill() + iconRect.fill(using: .sourceAtop) + } } // Convert to PNG diff --git a/Echo/Sources/Features/ConnectionVault/Views/ConnectionEditor/ConnectionEditorView+Detail.swift b/Echo/Sources/Features/ConnectionVault/Views/ConnectionEditor/ConnectionEditorView+Detail.swift index b75d0c683..2529ea779 100644 --- a/Echo/Sources/Features/ConnectionVault/Views/ConnectionEditor/ConnectionEditorView+Detail.swift +++ b/Echo/Sources/Features/ConnectionVault/Views/ConnectionEditor/ConnectionEditorView+Detail.swift @@ -50,10 +50,7 @@ extension ConnectionEditorView { Label { Text(type.displayName) } icon: { - Image(type.iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: SpacingTokens.md, height: SpacingTokens.md) + DatabaseTypeIcon(databaseType: type, presentation: .formControl) } .tag(type) } diff --git a/Echo/Sources/Features/ConnectionVault/Views/ManageConnections/Projects/ManageConnectionsView+Projects.swift b/Echo/Sources/Features/ConnectionVault/Views/ManageConnections/Projects/ManageConnectionsView+Projects.swift index dd2d3be71..dcb6468d0 100644 --- a/Echo/Sources/Features/ConnectionVault/Views/ManageConnections/Projects/ManageConnectionsView+Projects.swift +++ b/Echo/Sources/Features/ConnectionVault/Views/ManageConnections/Projects/ManageConnectionsView+Projects.swift @@ -252,7 +252,7 @@ extension ManageConnectionsView { Task { try? await projectStore.updateProject(updated) if newValue, let syncEngine = AppDirector.shared.syncEngine { - try? await syncEngine.performInitialUpload(for: updated) + try? await syncEngine.performInitialUpload(for: updated, strategy: .merge) } } } diff --git a/Echo/Sources/Features/ConnectionVault/Views/Sidebar/ConnectionSidebarItemViews.swift b/Echo/Sources/Features/ConnectionVault/Views/Sidebar/ConnectionSidebarItemViews.swift index 511c482df..a491222e0 100644 --- a/Echo/Sources/Features/ConnectionVault/Views/Sidebar/ConnectionSidebarItemViews.swift +++ b/Echo/Sources/Features/ConnectionVault/Views/Sidebar/ConnectionSidebarItemViews.swift @@ -148,12 +148,7 @@ struct ConnectionListRow: View { @ViewBuilder private var connectionIcon: some View { -#if os(macOS) - if let data = connection.logo, let img = NSImage(data: data) { Image(nsImage: img).resizable().aspectRatio(contentMode: .fit).clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) } - else { Image(connection.databaseType.iconName).resizable().renderingMode(.template).aspectRatio(contentMode: .fit).foregroundStyle(accentColor) } -#else - Image(connection.databaseType.iconName).resizable().renderingMode(.template).aspectRatio(contentMode: .fit).foregroundStyle(accentColor) -#endif + DatabaseTypeIcon(databaseType: connection.databaseType, tint: accentColor) } @ViewBuilder diff --git a/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel+Actions.swift b/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel+Actions.swift new file mode 100644 index 000000000..bf476c0f1 --- /dev/null +++ b/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel+Actions.swift @@ -0,0 +1,124 @@ +import Foundation +import AppKit +import Logging +import UniformTypeIdentifiers + +// MARK: - Database Loading, Object Selection, and Output Delivery + +extension DataMigrationWizardViewModel { + + // MARK: - Database Loading + + func loadSourceDatabases() { + guard let session = sourceSession else { return } + isLoadingSourceDatabases = true + Task { + do { + let databases = try await session.session.listDatabases() + self.sourceDatabases = databases + if self.sourceDatabaseName.isEmpty, let first = databases.first { + self.sourceDatabaseName = first + } + } catch { + logger.error("Failed to load source databases: \(error)") + } + self.isLoadingSourceDatabases = false + } + } + + func loadTargetDatabases() { + guard let session = targetSession else { return } + isLoadingTargetDatabases = true + Task { + do { + let databases = try await session.session.listDatabases() + self.targetDatabases = databases + if self.targetDatabaseName.isEmpty, let first = databases.first { + self.targetDatabaseName = first + } + } catch { + logger.error("Failed to load target databases: \(error)") + } + self.isLoadingTargetDatabases = false + } + } + + // MARK: - Object Loading + + func loadSourceObjects() { + guard let session = sourceSession else { return } + isLoadingObjects = true + Task { + do { + let dbSession = try await session.session.sessionForDatabase(sourceDatabaseName) + let schemas = try await dbSession.listSchemas() + var allObjects: [MigrationObject] = [] + for schema in schemas { + let tables = try await dbSession.listTablesAndViews(schema: schema) + for obj in tables where obj.type == .table { + allObjects.append(MigrationObject( + id: "\(obj.schema).\(obj.name)", + schema: obj.schema, + name: obj.name, + objectType: "Table" + )) + } + } + self.sourceObjects = allObjects + self.selectedObjectIDs = Set(allObjects.map(\.id)) + } catch { + logger.error("Failed to load source objects: \(error)") + } + self.isLoadingObjects = false + } + } + + // MARK: - Object Selection + + func selectAll() { + selectedObjectIDs = Set(sourceObjects.map(\.id)) + } + + func deselectAll() { + selectedObjectIDs.removeAll() + } + + func toggleObject(_ obj: MigrationObject) { + if selectedObjectIDs.contains(obj.id) { + selectedObjectIDs.remove(obj.id) + } else { + selectedObjectIDs.insert(obj.id) + } + } + + // MARK: - Output Delivery + + func deliverOutput() { + switch outputDestination { + case .execute: + executeMigration() + case .queryTab: + onOpenInQueryTab?(generatedSQL) + case .clipboard: + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(generatedSQL, forType: .string) + case .file: + saveToFile() + } + } + + func saveToFile() { + let panel = NSSavePanel() + panel.allowedContentTypes = [UTType(filenameExtension: "sql")!] + panel.nameFieldStringValue = "migration_\(sourceDatabaseName)_to_\(targetDatabaseName).sql" + panel.canCreateDirectories = true + + if panel.runModal() == .OK, let url = panel.url { + do { + try generatedSQL.write(to: url, atomically: true, encoding: .utf8) + } catch { + logger.error("Failed to save migration script: \(error)") + } + } + } +} diff --git a/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel+SQLGeneration.swift b/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel+SQLGeneration.swift new file mode 100644 index 000000000..91c6031af --- /dev/null +++ b/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel+SQLGeneration.swift @@ -0,0 +1,283 @@ +import Foundation +import Logging + +// MARK: - SQL Generation, Migration Execution, and SQL Helpers + +extension DataMigrationWizardViewModel { + + // MARK: - SQL Generation + + func generateMigrationSQL() { + guard let source = sourceSession, let target = targetSession else { return } + isGenerating = true + let selectedObjects = sourceObjects.filter { selectedObjectIDs.contains($0.id) } + let targetType = target.connection.databaseType + let sourceType = source.connection.databaseType + + Task { + var sql = "-- Data Migration Script\n" + sql += "-- Source: \(source.connection.connectionName) (\(sourceType.displayName)) / \(sourceDatabaseName)\n" + sql += "-- Target: \(target.connection.connectionName) (\(targetType.displayName)) / \(targetDatabaseName)\n" + sql += "-- Generated: \(Date().formatted())\n\n" + + // Fetch real column metadata from source for accurate DDL + do { + let sourceDB = try await source.session.sessionForDatabase(sourceDatabaseName) + + if migrateSchema { + sql += "-- === Schema Migration ===\n\n" + for obj in selectedObjects { + do { + let structure = try await sourceDB.getTableStructureDetails(schema: obj.schema, table: obj.name) + sql += buildCreateTableSQL( + for: obj, + structure: structure, + targetType: targetType + ) + sql += "\n\n" + } catch { + sql += "-- Failed to read structure for \(obj.schema).\(obj.name): \(error.localizedDescription)\n\n" + } + } + } + + if migrateData { + sql += "-- === Data Migration ===\n" + sql += "-- Use 'Execute Migration' to transfer data automatically,\n" + sql += "-- or run the INSERT statements below against the target.\n\n" + for obj in selectedObjects { + sql += "-- Data for \(obj.schema).\(obj.name) will be transferred during execution.\n" + } + } + } catch { + sql += "-- Error accessing source database: \(error.localizedDescription)\n" + } + + self.generatedSQL = sql + self.isGenerating = false + } + } + + // MARK: - Execute Migration + + func executeMigration() { + guard let source = sourceSession, let target = targetSession else { return } + isMigrating = true + migrationProgress = 0 + migrationStatus = "Starting migration..." + migrationLog = [] + migrationError = nil + + let selectedObjects = sourceObjects.filter { selectedObjectIDs.contains($0.id) } + let targetType = target.connection.databaseType + + Task { + do { + let targetDB = try await target.session.sessionForDatabase(targetDatabaseName) + let sourceDB = try await source.session.sessionForDatabase(sourceDatabaseName) + let total = Double(selectedObjects.count) + + for (index, obj) in selectedObjects.enumerated() { + self.migrationProgress = Double(index) / max(total, 1) + self.migrationStatus = "Migrating \(obj.schema).\(obj.name)..." + + if migrateSchema { + do { + let structure = try await sourceDB.getTableStructureDetails(schema: obj.schema, table: obj.name) + let ddl = buildCreateTableSQL(for: obj, structure: structure, targetType: targetType) + + if dropTargetIfExists { + let dropSQL = dropTableSQL(for: obj, targetType: targetType) + _ = try? await targetDB.simpleQuery(dropSQL) + } + _ = try await targetDB.simpleQuery(ddl) + appendLog("Created table \(obj.schema).\(obj.name)") + } catch { + appendLog("Schema failed for \(obj.schema).\(obj.name): \(error.localizedDescription)") + if !continueOnError { throw error } + } + } + + if migrateData { + do { + let sourceQualified = qualifiedName(obj, targetType: source.connection.databaseType) + let result = try await sourceDB.simpleQuery("SELECT * FROM \(sourceQualified)") + let rowCount = result.rows.count + if rowCount > 0 { + let insertBatches = buildInsertStatements( + for: obj, + columns: result.columns, + rows: result.rows, + targetType: targetType + ) + for batch in insertBatches { + _ = try await targetDB.simpleQuery(batch) + } + appendLog("Migrated \(rowCount) rows to \(obj.name)") + } else { + appendLog("No data in \(obj.name)") + } + } catch { + appendLog("Data transfer failed for \(obj.name): \(error.localizedDescription)") + if !continueOnError { throw error } + } + } + } + + self.migrationProgress = 1.0 + self.migrationStatus = "Migration completed successfully" + self.migrationSucceeded = true + } catch { + self.migrationError = error.localizedDescription + self.migrationStatus = "Migration failed" + logger.error("Migration failed: \(error)") + } + self.isMigrating = false + } + } + + // MARK: - SQL Helpers + + func qualifiedName(_ obj: MigrationObject, targetType: DatabaseType) -> String { + switch targetType { + case .microsoftSQL: + return "[\(obj.schema)].[\(obj.name)]" + case .postgresql: + return "\"\(obj.schema)\".\"\(obj.name)\"" + case .mysql: + return "`\(obj.name)`" + case .sqlite: + return "\"\(obj.name)\"" + } + } + + func buildCreateTableSQL( + for obj: MigrationObject, + structure: TableStructureDetails, + targetType: DatabaseType + ) -> String { + let name = qualifiedName(obj, targetType: targetType) + let pkColumns = Set(structure.primaryKey?.columns ?? []) + let columnDefs = structure.columns.map { col in + let colName = quoteName(col.name, targetType: targetType) + let typeName = mapDataType(col.dataType, targetType: targetType) + let nullable = col.isNullable ? "" : " NOT NULL" + let pk = pkColumns.contains(col.name) ? " PRIMARY KEY" : "" + return " \(colName) \(typeName)\(nullable)\(pk)" + } + + let prefix: String + switch targetType { + case .microsoftSQL: + prefix = "CREATE TABLE \(name)" + default: + prefix = "CREATE TABLE IF NOT EXISTS \(name)" + } + + var sql = "\(prefix) (\n\(columnDefs.joined(separator: ",\n"))\n)" + if targetType == .microsoftSQL { + sql += ";\nGO" + } else { + sql += ";" + } + return sql + } + + func dropTableSQL(for obj: MigrationObject, targetType: DatabaseType) -> String { + let name = qualifiedName(obj, targetType: targetType) + switch targetType { + case .microsoftSQL: + return "DROP TABLE IF EXISTS \(name);\nGO" + default: + return "DROP TABLE IF EXISTS \(name);" + } + } + + func quoteName(_ name: String, targetType: DatabaseType) -> String { + switch targetType { + case .microsoftSQL: return "[\(name)]" + case .mysql: return "`\(name)`" + default: return "\"\(name)\"" + } + } + + func mapDataType(_ sourceType: String, targetType: DatabaseType) -> String { + let normalized = sourceType.uppercased() + + switch targetType { + case .mysql: + if normalized.hasPrefix("NVARCHAR") || normalized.hasPrefix("CHARACTER VARYING") { return "VARCHAR(255)" } + if normalized == "TEXT" || normalized == "NTEXT" { return "TEXT" } + if normalized == "SERIAL" || normalized == "BIGSERIAL" { return "BIGINT AUTO_INCREMENT" } + if normalized == "BOOLEAN" || normalized == "BOOL" { return "TINYINT(1)" } + if normalized.hasPrefix("TIMESTAMP") { return "DATETIME" } + if normalized == "BYTEA" || normalized == "VARBINARY(MAX)" { return "LONGBLOB" } + if normalized == "UUID" || normalized == "UNIQUEIDENTIFIER" { return "CHAR(36)" } + return sourceType + case .postgresql: + if normalized.hasPrefix("NVARCHAR") || normalized.hasPrefix("VARCHAR") { return "TEXT" } + if normalized == "INT" || normalized == "INTEGER" { return "INTEGER" } + if normalized == "BIGINT" { return "BIGINT" } + if normalized == "BIT" || normalized == "TINYINT(1)" { return "BOOLEAN" } + if normalized == "DATETIME" || normalized == "DATETIME2" { return "TIMESTAMP" } + if normalized == "VARBINARY(MAX)" || normalized == "LONGBLOB" { return "BYTEA" } + if normalized == "UNIQUEIDENTIFIER" || normalized == "CHAR(36)" { return "UUID" } + return sourceType + case .microsoftSQL: + if normalized == "TEXT" { return "NVARCHAR(MAX)" } + if normalized == "SERIAL" { return "INT IDENTITY(1,1)" } + if normalized == "BIGSERIAL" { return "BIGINT IDENTITY(1,1)" } + if normalized == "BOOLEAN" || normalized == "BOOL" { return "BIT" } + if normalized == "TIMESTAMP" || normalized.hasPrefix("TIMESTAMP") { return "DATETIME2" } + if normalized == "BYTEA" || normalized == "LONGBLOB" { return "VARBINARY(MAX)" } + if normalized == "UUID" { return "UNIQUEIDENTIFIER" } + return sourceType + case .sqlite: + if normalized.contains("INT") { return "INTEGER" } + if normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") { return "TEXT" } + if normalized.contains("BLOB") || normalized == "BYTEA" { return "BLOB" } + if normalized.contains("REAL") || normalized.contains("FLOAT") || normalized.contains("DOUBLE") { return "REAL" } + return sourceType + } + } + + func buildInsertStatements( + for obj: MigrationObject, + columns: [ColumnInfo], + rows: [[String?]], + targetType: DatabaseType + ) -> [String] { + guard !rows.isEmpty, !columns.isEmpty else { return [] } + + let tableName = qualifiedName(obj, targetType: targetType) + let colNames = columns.map { quoteName($0.name, targetType: targetType) }.joined(separator: ", ") + + var batches: [String] = [] + + for chunk in stride(from: 0, to: rows.count, by: batchSize) { + let end = min(chunk + batchSize, rows.count) + let batchRows = rows[chunk.. String { + guard let value, value != "(null)" else { return "NULL" } + let escaped = value.replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" + } + + func appendLog(_ message: String) { + migrationLog.append("[\(Date().formatted(date: .omitted, time: .standard))] \(message)") + } +} diff --git a/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel.swift b/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel.swift index 03b8d34fc..fd567ab06 100644 --- a/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel.swift +++ b/Echo/Sources/Features/DataMigration/Domain/DataMigrationWizardViewModel.swift @@ -83,7 +83,7 @@ final class DataMigrationWizardViewModel { // MARK: - Dependencies var onOpenInQueryTab: ((String) -> Void)? - private let logger = Logger(label: "DataMigrationWizardViewModel") + let logger = Logger(label: "DataMigrationWizardViewModel") // MARK: - Navigation @@ -128,395 +128,4 @@ final class DataMigrationWizardViewModel { guard let id = targetSessionID else { return nil } return availableSessions.first(where: { $0.id == id }) } - - // MARK: - Database Loading - - func loadSourceDatabases() { - guard let session = sourceSession else { return } - isLoadingSourceDatabases = true - Task { - do { - let databases = try await session.session.listDatabases() - self.sourceDatabases = databases - if self.sourceDatabaseName.isEmpty, let first = databases.first { - self.sourceDatabaseName = first - } - } catch { - logger.error("Failed to load source databases: \(error)") - } - self.isLoadingSourceDatabases = false - } - } - - func loadTargetDatabases() { - guard let session = targetSession else { return } - isLoadingTargetDatabases = true - Task { - do { - let databases = try await session.session.listDatabases() - self.targetDatabases = databases - if self.targetDatabaseName.isEmpty, let first = databases.first { - self.targetDatabaseName = first - } - } catch { - logger.error("Failed to load target databases: \(error)") - } - self.isLoadingTargetDatabases = false - } - } - - // MARK: - Object Loading - - func loadSourceObjects() { - guard let session = sourceSession else { return } - isLoadingObjects = true - Task { - do { - let dbSession = try await session.session.sessionForDatabase(sourceDatabaseName) - let schemas = try await dbSession.listSchemas() - var allObjects: [MigrationObject] = [] - for schema in schemas { - let tables = try await dbSession.listTablesAndViews(schema: schema) - for obj in tables where obj.type == .table { - allObjects.append(MigrationObject( - id: "\(obj.schema).\(obj.name)", - schema: obj.schema, - name: obj.name, - objectType: "Table" - )) - } - } - self.sourceObjects = allObjects - self.selectedObjectIDs = Set(allObjects.map(\.id)) - } catch { - logger.error("Failed to load source objects: \(error)") - } - self.isLoadingObjects = false - } - } - - // MARK: - Object Selection - - func selectAll() { - selectedObjectIDs = Set(sourceObjects.map(\.id)) - } - - func deselectAll() { - selectedObjectIDs.removeAll() - } - - func toggleObject(_ obj: MigrationObject) { - if selectedObjectIDs.contains(obj.id) { - selectedObjectIDs.remove(obj.id) - } else { - selectedObjectIDs.insert(obj.id) - } - } - - // MARK: - SQL Generation - - func generateMigrationSQL() { - guard let source = sourceSession, let target = targetSession else { return } - isGenerating = true - let selectedObjects = sourceObjects.filter { selectedObjectIDs.contains($0.id) } - let targetType = target.connection.databaseType - let sourceType = source.connection.databaseType - - Task { - var sql = "-- Data Migration Script\n" - sql += "-- Source: \(source.connection.connectionName) (\(sourceType.displayName)) / \(sourceDatabaseName)\n" - sql += "-- Target: \(target.connection.connectionName) (\(targetType.displayName)) / \(targetDatabaseName)\n" - sql += "-- Generated: \(Date().formatted())\n\n" - - // Fetch real column metadata from source for accurate DDL - do { - let sourceDB = try await source.session.sessionForDatabase(sourceDatabaseName) - - if migrateSchema { - sql += "-- === Schema Migration ===\n\n" - for obj in selectedObjects { - do { - let structure = try await sourceDB.getTableStructureDetails(schema: obj.schema, table: obj.name) - sql += buildCreateTableSQL( - for: obj, - structure: structure, - targetType: targetType - ) - sql += "\n\n" - } catch { - sql += "-- Failed to read structure for \(obj.schema).\(obj.name): \(error.localizedDescription)\n\n" - } - } - } - - if migrateData { - sql += "-- === Data Migration ===\n" - sql += "-- Use 'Execute Migration' to transfer data automatically,\n" - sql += "-- or run the INSERT statements below against the target.\n\n" - for obj in selectedObjects { - sql += "-- Data for \(obj.schema).\(obj.name) will be transferred during execution.\n" - } - } - } catch { - sql += "-- Error accessing source database: \(error.localizedDescription)\n" - } - - self.generatedSQL = sql - self.isGenerating = false - } - } - - // MARK: - Execute Migration - - func executeMigration() { - guard let source = sourceSession, let target = targetSession else { return } - isMigrating = true - migrationProgress = 0 - migrationStatus = "Starting migration..." - migrationLog = [] - migrationError = nil - - let selectedObjects = sourceObjects.filter { selectedObjectIDs.contains($0.id) } - let targetType = target.connection.databaseType - - Task { - do { - let targetDB = try await target.session.sessionForDatabase(targetDatabaseName) - let sourceDB = try await source.session.sessionForDatabase(sourceDatabaseName) - let total = Double(selectedObjects.count) - - for (index, obj) in selectedObjects.enumerated() { - self.migrationProgress = Double(index) / max(total, 1) - self.migrationStatus = "Migrating \(obj.schema).\(obj.name)..." - - if migrateSchema { - do { - let structure = try await sourceDB.getTableStructureDetails(schema: obj.schema, table: obj.name) - let ddl = buildCreateTableSQL(for: obj, structure: structure, targetType: targetType) - - if dropTargetIfExists { - let dropSQL = dropTableSQL(for: obj, targetType: targetType) - _ = try? await targetDB.simpleQuery(dropSQL) - } - _ = try await targetDB.simpleQuery(ddl) - appendLog("Created table \(obj.schema).\(obj.name)") - } catch { - appendLog("Schema failed for \(obj.schema).\(obj.name): \(error.localizedDescription)") - if !continueOnError { throw error } - } - } - - if migrateData { - do { - let sourceQualified = qualifiedName(obj, targetType: source.connection.databaseType) - let result = try await sourceDB.simpleQuery("SELECT * FROM \(sourceQualified)") - let rowCount = result.rows.count - if rowCount > 0 { - let insertBatches = buildInsertStatements( - for: obj, - columns: result.columns, - rows: result.rows, - targetType: targetType - ) - for batch in insertBatches { - _ = try await targetDB.simpleQuery(batch) - } - appendLog("Migrated \(rowCount) rows to \(obj.name)") - } else { - appendLog("No data in \(obj.name)") - } - } catch { - appendLog("Data transfer failed for \(obj.name): \(error.localizedDescription)") - if !continueOnError { throw error } - } - } - } - - self.migrationProgress = 1.0 - self.migrationStatus = "Migration completed successfully" - self.migrationSucceeded = true - } catch { - self.migrationError = error.localizedDescription - self.migrationStatus = "Migration failed" - logger.error("Migration failed: \(error)") - } - self.isMigrating = false - } - } - - // MARK: - Output Delivery - - func deliverOutput() { - switch outputDestination { - case .execute: - executeMigration() - case .queryTab: - onOpenInQueryTab?(generatedSQL) - case .clipboard: - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(generatedSQL, forType: .string) - case .file: - saveToFile() - } - } - - func saveToFile() { - let panel = NSSavePanel() - panel.allowedContentTypes = [UTType(filenameExtension: "sql")!] - panel.nameFieldStringValue = "migration_\(sourceDatabaseName)_to_\(targetDatabaseName).sql" - panel.canCreateDirectories = true - - if panel.runModal() == .OK, let url = panel.url { - do { - try generatedSQL.write(to: url, atomically: true, encoding: .utf8) - } catch { - logger.error("Failed to save migration script: \(error)") - } - } - } - - // MARK: - SQL Helpers - - private func qualifiedName(_ obj: MigrationObject, targetType: DatabaseType) -> String { - switch targetType { - case .microsoftSQL: - return "[\(obj.schema)].[\(obj.name)]" - case .postgresql: - return "\"\(obj.schema)\".\"\(obj.name)\"" - case .mysql: - return "`\(obj.name)`" - case .sqlite: - return "\"\(obj.name)\"" - } - } - - private func buildCreateTableSQL( - for obj: MigrationObject, - structure: TableStructureDetails, - targetType: DatabaseType - ) -> String { - let name = qualifiedName(obj, targetType: targetType) - let pkColumns = Set(structure.primaryKey?.columns ?? []) - let columnDefs = structure.columns.map { col in - let colName = quoteName(col.name, targetType: targetType) - let typeName = mapDataType(col.dataType, targetType: targetType) - let nullable = col.isNullable ? "" : " NOT NULL" - let pk = pkColumns.contains(col.name) ? " PRIMARY KEY" : "" - return " \(colName) \(typeName)\(nullable)\(pk)" - } - - let prefix: String - switch targetType { - case .microsoftSQL: - prefix = "CREATE TABLE \(name)" - default: - prefix = "CREATE TABLE IF NOT EXISTS \(name)" - } - - var sql = "\(prefix) (\n\(columnDefs.joined(separator: ",\n"))\n)" - if targetType == .microsoftSQL { - sql += ";\nGO" - } else { - sql += ";" - } - return sql - } - - private func dropTableSQL(for obj: MigrationObject, targetType: DatabaseType) -> String { - let name = qualifiedName(obj, targetType: targetType) - switch targetType { - case .microsoftSQL: - return "DROP TABLE IF EXISTS \(name);\nGO" - default: - return "DROP TABLE IF EXISTS \(name);" - } - } - - private func quoteName(_ name: String, targetType: DatabaseType) -> String { - switch targetType { - case .microsoftSQL: return "[\(name)]" - case .mysql: return "`\(name)`" - default: return "\"\(name)\"" - } - } - - private func mapDataType(_ sourceType: String, targetType: DatabaseType) -> String { - let normalized = sourceType.uppercased() - - switch targetType { - case .mysql: - if normalized.hasPrefix("NVARCHAR") || normalized.hasPrefix("CHARACTER VARYING") { return "VARCHAR(255)" } - if normalized == "TEXT" || normalized == "NTEXT" { return "TEXT" } - if normalized == "SERIAL" || normalized == "BIGSERIAL" { return "BIGINT AUTO_INCREMENT" } - if normalized == "BOOLEAN" || normalized == "BOOL" { return "TINYINT(1)" } - if normalized.hasPrefix("TIMESTAMP") { return "DATETIME" } - if normalized == "BYTEA" || normalized == "VARBINARY(MAX)" { return "LONGBLOB" } - if normalized == "UUID" || normalized == "UNIQUEIDENTIFIER" { return "CHAR(36)" } - return sourceType - case .postgresql: - if normalized.hasPrefix("NVARCHAR") || normalized.hasPrefix("VARCHAR") { return "TEXT" } - if normalized == "INT" || normalized == "INTEGER" { return "INTEGER" } - if normalized == "BIGINT" { return "BIGINT" } - if normalized == "BIT" || normalized == "TINYINT(1)" { return "BOOLEAN" } - if normalized == "DATETIME" || normalized == "DATETIME2" { return "TIMESTAMP" } - if normalized == "VARBINARY(MAX)" || normalized == "LONGBLOB" { return "BYTEA" } - if normalized == "UNIQUEIDENTIFIER" || normalized == "CHAR(36)" { return "UUID" } - return sourceType - case .microsoftSQL: - if normalized == "TEXT" { return "NVARCHAR(MAX)" } - if normalized == "SERIAL" { return "INT IDENTITY(1,1)" } - if normalized == "BIGSERIAL" { return "BIGINT IDENTITY(1,1)" } - if normalized == "BOOLEAN" || normalized == "BOOL" { return "BIT" } - if normalized == "TIMESTAMP" || normalized.hasPrefix("TIMESTAMP") { return "DATETIME2" } - if normalized == "BYTEA" || normalized == "LONGBLOB" { return "VARBINARY(MAX)" } - if normalized == "UUID" { return "UNIQUEIDENTIFIER" } - return sourceType - case .sqlite: - if normalized.contains("INT") { return "INTEGER" } - if normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") { return "TEXT" } - if normalized.contains("BLOB") || normalized == "BYTEA" { return "BLOB" } - if normalized.contains("REAL") || normalized.contains("FLOAT") || normalized.contains("DOUBLE") { return "REAL" } - return sourceType - } - } - - private func buildInsertStatements( - for obj: MigrationObject, - columns: [ColumnInfo], - rows: [[String?]], - targetType: DatabaseType - ) -> [String] { - guard !rows.isEmpty, !columns.isEmpty else { return [] } - - let tableName = qualifiedName(obj, targetType: targetType) - let colNames = columns.map { quoteName($0.name, targetType: targetType) }.joined(separator: ", ") - - var batches: [String] = [] - - for chunk in stride(from: 0, to: rows.count, by: batchSize) { - let end = min(chunk + batchSize, rows.count) - let batchRows = rows[chunk.. String { - guard let value, value != "(null)" else { return "NULL" } - let escaped = value.replacingOccurrences(of: "'", with: "''") - return "'\(escaped)'" - } - - private func appendLog(_ message: String) { - migrationLog.append("[\(Date().formatted(date: .omitted, time: .standard))] \(message)") - } } diff --git a/Echo/Sources/Features/GenerateScripts/Views/DACWizardView.swift b/Echo/Sources/Features/GenerateScripts/Views/DACWizardView.swift index c2bce8b56..32c203ed4 100644 --- a/Echo/Sources/Features/GenerateScripts/Views/DACWizardView.swift +++ b/Echo/Sources/Features/GenerateScripts/Views/DACWizardView.swift @@ -125,7 +125,7 @@ struct DACWizardView: View { if let error = viewModel.errorMessage { Image(systemName: "xmark.circle.fill") .font(TypographyTokens.iconHero) - .foregroundStyle(.red) + .foregroundStyle(ColorTokens.Status.error) Text("Operation Failed") .font(TypographyTokens.title) Text(error) @@ -134,7 +134,7 @@ struct DACWizardView: View { } else { Image(systemName: "checkmark.circle.fill") .font(TypographyTokens.iconHero) - .foregroundStyle(.green) + .foregroundStyle(ColorTokens.Status.success) Text("Success") .font(TypographyTokens.title) Text("The operation completed successfully.") diff --git a/Echo/Sources/Features/ObjectBrowser/Cache/ObjectBrowserCacheModels.swift b/Echo/Sources/Features/ObjectBrowser/Cache/ObjectBrowserCacheModels.swift new file mode 100644 index 000000000..deabe92c6 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Cache/ObjectBrowserCacheModels.swift @@ -0,0 +1,28 @@ +import Foundation + +struct ObjectBrowserCacheKey: Hashable, Codable, Sendable { + let connectionID: UUID +} + +struct ObjectBrowserCacheEntry: Codable, Sendable { + static let currentSchemaVersion = 1 + + let schemaVersion: Int + let key: ObjectBrowserCacheKey + let connectionFingerprint: String + let updatedAt: Date + let structure: DatabaseStructure + + init( + key: ObjectBrowserCacheKey, + connectionFingerprint: String, + updatedAt: Date = Date(), + structure: DatabaseStructure + ) { + self.schemaVersion = Self.currentSchemaVersion + self.key = key + self.connectionFingerprint = connectionFingerprint + self.updatedAt = updatedAt + self.structure = structure + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Cache/ObjectBrowserCacheStore.swift b/Echo/Sources/Features/ObjectBrowser/Cache/ObjectBrowserCacheStore.swift new file mode 100644 index 000000000..479dedbf2 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Cache/ObjectBrowserCacheStore.swift @@ -0,0 +1,137 @@ +import Foundation + +actor ObjectBrowserCacheStore { + struct Configuration: Sendable { + let rootDirectory: URL + } + + private let configuration: Configuration + private let fileManager = FileManager.default + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(configuration: Configuration) { + self.configuration = configuration + if !fileManager.fileExists(atPath: configuration.rootDirectory.path) { + try? fileManager.createDirectory( + at: configuration.rootDirectory, + withIntermediateDirectories: true + ) + } + } + + static func defaultRootDirectory() -> URL { + let base = (try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + )) ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + return base + .appendingPathComponent("Echo", isDirectory: true) + .appendingPathComponent("ObjectBrowserCache", isDirectory: true) + } + + func entry(for connection: SavedConnection) async -> ObjectBrowserCacheEntry? { + let url = cacheURL(for: ObjectBrowserCacheKey(connectionID: connection.id)) + guard fileManager.fileExists(atPath: url.path) else { return nil } + guard let data = try? Data(contentsOf: url), + let entry = try? decoder.decode(ObjectBrowserCacheEntry.self, from: data), + entry.schemaVersion == ObjectBrowserCacheEntry.currentSchemaVersion, + entry.connectionFingerprint == connection.objectBrowserCacheFingerprint else { + return nil + } + return entry + } + + func migrateLegacyCacheIfNeeded( + from connection: SavedConnection, + limitBytes: Int + ) async { + guard let legacyStructure = connection.cachedStructure else { return } + if await entry(for: connection) != nil { + return + } + let entry = ObjectBrowserCacheEntry( + key: ObjectBrowserCacheKey(connectionID: connection.id), + connectionFingerprint: connection.objectBrowserCacheFingerprint, + updatedAt: connection.cachedStructureUpdatedAt ?? Date(), + structure: legacyStructure + ) + try? await write(entry, limitBytes: limitBytes) + } + + func stashStructure( + _ structure: DatabaseStructure, + for connection: SavedConnection, + limitBytes: Int + ) async throws { + let entry = ObjectBrowserCacheEntry( + key: ObjectBrowserCacheKey(connectionID: connection.id), + connectionFingerprint: connection.objectBrowserCacheFingerprint, + structure: structure + ) + try await write(entry, limitBytes: limitBytes) + } + + func currentUsageBytes() async -> UInt64 { + let urls = cacheFileURLs() + return urls.reduce(into: UInt64(0)) { total, url in + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + total += UInt64(values?.fileSize ?? 0) + } + } + + func removeAll() async { + for url in cacheFileURLs() { + try? fileManager.removeItem(at: url) + } + } + + func pruneToLimit(_ limitBytes: Int) async { + let normalizedLimit = limitBytes + var entries: [(url: URL, updatedAt: Date, size: Int)] = cacheFileURLs().compactMap { url in + guard let data = try? Data(contentsOf: url), + let entry = try? decoder.decode(ObjectBrowserCacheEntry.self, from: data) else { + return nil + } + return (url, entry.updatedAt, data.count) + } + var total = entries.reduce(0) { $0 + $1.size } + guard total > normalizedLimit else { return } + + entries.sort { $0.updatedAt < $1.updatedAt } + for entry in entries where total > normalizedLimit { + try? fileManager.removeItem(at: entry.url) + total -= entry.size + } + } + + private func write(_ entry: ObjectBrowserCacheEntry, limitBytes: Int) async throws { + if !fileManager.fileExists(atPath: configuration.rootDirectory.path) { + try fileManager.createDirectory( + at: configuration.rootDirectory, + withIntermediateDirectories: true + ) + } + let data = try encoder.encode(entry) + try data.write(to: cacheURL(for: entry.key), options: [.atomic]) + await pruneToLimit(limitBytes) + } + + private func cacheURL(for key: ObjectBrowserCacheKey) -> URL { + configuration.rootDirectory + .appendingPathComponent(key.connectionID.uuidString) + .appendingPathExtension("json") + } + + private func cacheFileURLs() -> [URL] { + let urls = (try? fileManager.contentsOfDirectory( + at: configuration.rootDirectory, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], + options: [.skipsHiddenFiles] + )) ?? [] + return urls.filter { $0.pathExtension == "json" } + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Discovery/SchemaDiscoveryEngine.swift b/Echo/Sources/Features/ObjectBrowser/Discovery/SchemaDiscoveryEngine.swift index c010933bd..bc659b204 100644 --- a/Echo/Sources/Features/ObjectBrowser/Discovery/SchemaDiscoveryEngine.swift +++ b/Echo/Sources/Features/ObjectBrowser/Discovery/SchemaDiscoveryEngine.swift @@ -5,13 +5,20 @@ import Observation final class MetadataDiscoveryEngine: MetadataDiscoveryEngineProtocol, @unchecked Sendable { private let identityRepository: IdentityRepository private let connectionStore: ConnectionStore + private let objectBrowserCacheStore: ObjectBrowserCacheStore var onPersistConnections: (@MainActor @Sendable () async -> Void)? var onEnqueuePrefetch: (@MainActor @Sendable (ConnectionSession) async -> Void)? + var cacheLimitProvider: (@MainActor @Sendable () -> Int)? - init(identityRepository: IdentityRepository, connectionStore: ConnectionStore) { + init( + identityRepository: IdentityRepository, + connectionStore: ConnectionStore, + objectBrowserCacheStore: ObjectBrowserCacheStore + ) { self.identityRepository = identityRepository self.connectionStore = connectionStore + self.objectBrowserCacheStore = objectBrowserCacheStore } // MARK: - Core Discovery @@ -54,6 +61,7 @@ final class MetadataDiscoveryEngine: MetadataDiscoveryEngineProtocol, @unchecked if connectionSession.databaseStructure == nil { connectionSession.databaseStructure = DatabaseStructure(serverVersion: nil, databases: []) + connectionSession.reconcileMetadataFreshnessFromLiveStructure() } guard let credentials = identityRepository.resolveAuthenticationConfiguration(for: connectionSession.connection, overridePassword: nil) else { @@ -135,6 +143,12 @@ final class MetadataDiscoveryEngine: MetadataDiscoveryEngineProtocol, @unchecked let finalStructure = DatabaseStructure(serverVersion: interimServerVersion, databases: mergedDatabases) print("[PERF] initialLoad: applying final structure with \(mergedDatabases.count) databases (single UI update)") self.applyStructureUpdate(finalStructure, to: connectionSession, cacheResult: true) + let liveDatabases = Set(structure.databases.compactMap { database in + database.schemas.contains(where: { !$0.objects.isEmpty }) + ? connectionSession.schemaLoadKey(database.name) + : nil + }) + connectionSession.reconcileMetadataFreshnessFromLiveStructure(markingLive: liveDatabases) print("[PERF] initialLoad: done") connectionSession.structureLoadingState = .ready @@ -199,11 +213,14 @@ final class MetadataDiscoveryEngine: MetadataDiscoveryEngineProtocol, @unchecked for db in structure.databases { let mergeStart = CFAbsoluteTimeGetCurrent() mergeSingleDatabase(db, into: session) + let hasSchemas = db.schemas.contains(where: { !$0.objects.isEmpty }) + session.markMetadataRefreshCompleted(forDatabase: db.name, hasSchemas: hasSchemas) print("[PERF] \(databaseName): final merge took \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - mergeStart))s") } print("[PERF] \(databaseName): total loadDatabaseSchemaOnly \(String(format: "%.3f", CFAbsoluteTimeGetCurrent() - t0))s") } catch { + session.markMetadataRefreshFailed(forDatabase: databaseName) ConnectionDebug.log("[SchemaDiscovery] loadDatabaseSchemaOnly failed for '\(databaseName)': \(error.localizedDescription)") } } @@ -266,9 +283,15 @@ final class MetadataDiscoveryEngine: MetadataDiscoveryEngineProtocol, @unchecked private func schedulePersist(_ structure: DatabaseStructure, for session: ConnectionSession) { persistTask?.cancel() + let connection = session.connection + let limitBytes = cacheLimitProvider?() ?? 512 * 1_024 * 1_024 persistTask = Task { - try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } + try? await self.objectBrowserCacheStore.stashStructure( + structure, + for: connection, + limitBytes: limitBytes + ) var conn = session.connection conn.cachedStructure = structure conn.cachedStructureUpdatedAt = Date() diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserNode.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserNode.swift new file mode 100644 index 000000000..1114a5e47 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserNode.swift @@ -0,0 +1,178 @@ +import Foundation +import SQLServerKit + +enum ExperimentalObjectBrowserServerFolderKind: String { + case security + case databaseSnapshots + case agentJobs + case management + case ssis + case linkedServers + case serverTriggers + + var title: String { + switch self { + case .security: "Security" + case .databaseSnapshots: "Database Snapshots" + case .agentJobs: "Agent Jobs" + case .management: "Management" + case .ssis: "Integration Services Catalogs" + case .linkedServers: "Linked Servers" + case .serverTriggers: "Server Triggers" + } + } + + var systemImage: String { + switch self { + case .security: "shield" + case .databaseSnapshots: "camera.aperture" + case .agentJobs: "clock" + case .management: "gearshape" + case .ssis: "shippingbox" + case .linkedServers: "link" + case .serverTriggers: "bolt.badge.clock" + } + } +} + +enum ExperimentalObjectBrowserSecuritySectionKind: String { + case logins + case certificateLogins + case serverRoles + case credentials + case pgLoginRoles + case pgGroupRoles + + var title: String { + switch self { + case .logins: "Logins" + case .certificateLogins: "Certificate Logins" + case .serverRoles: "Server Roles" + case .credentials: "Credentials" + case .pgLoginRoles: "Login Roles" + case .pgGroupRoles: "Group Roles" + } + } + + var systemImage: String { + switch self { + case .logins: "person.2" + case .certificateLogins: "doc.badge.lock" + case .serverRoles: "shield" + case .credentials: "key" + case .pgLoginRoles: "person.crop.circle" + case .pgGroupRoles: "person.2.circle" + } + } +} + +enum ExperimentalObjectBrowserActionKind: String { + case maintenance + case serverProperties + case activityMonitor + case extendedEvents + case databaseMail + case sqlProfiler + case resourceGovernor + case tuningAdvisor + case policyManagement + case sqlServerLogs + case openJobQueue + + var title: String { + switch self { + case .maintenance: "Maintenance" + case .serverProperties: "Server Properties" + case .activityMonitor: "Activity Monitor" + case .extendedEvents: "Extended Events" + case .databaseMail: "Database Mail" + case .sqlProfiler: "SQL Profiler" + case .resourceGovernor: "Resource Governor" + case .tuningAdvisor: "Tuning Advisor" + case .policyManagement: "Policy Management" + case .sqlServerLogs: "SQL Server Logs" + case .openJobQueue: "Agent Jobs Overview" + } + } + + var systemImage: String { + switch self { + case .maintenance: "wrench.and.screwdriver" + case .serverProperties: "gearshape.2" + case .activityMonitor: "gauge.high" + case .extendedEvents: "list.bullet.rectangle" + case .databaseMail: "envelope" + case .sqlProfiler: "chart.line.uptrend.xyaxis" + case .resourceGovernor: "slider.horizontal.3" + case .tuningAdvisor: "wand.and.stars" + case .policyManagement: "checkmark.shield" + case .sqlServerLogs: "doc.text" + case .openJobQueue: "list.bullet.rectangle" + } + } +} + +enum ExperimentalObjectBrowserDatabaseFolderKind: String { + case security + case databaseTriggers + case serviceBroker + case externalResources + + var title: String { + switch self { + case .security: "Security" + case .databaseTriggers: "Database Triggers" + case .serviceBroker: "Service Broker" + case .externalResources: "External Resources" + } + } + + var systemImage: String { + switch self { + case .security: "shield" + case .databaseTriggers: "bolt" + case .serviceBroker: "tray.2" + case .externalResources: "externaldrive" + } + } +} + +@MainActor +final class ExperimentalObjectBrowserNode: NSObject { + enum Row { + case topSpacer(CGFloat) + case pendingConnection(PendingConnection) + case server(ConnectionSession) + case databasesFolder(ConnectionSession, count: Int) + case database(ConnectionSession, DatabaseInfo, isLoading: Bool) + case objectGroup(ConnectionSession, String, SchemaObjectInfo.ObjectType, count: Int) + case object(ConnectionSession, String, SchemaObjectInfo) + case serverFolder(ConnectionSession, ExperimentalObjectBrowserServerFolderKind, count: Int?) + case databaseFolder(ConnectionSession, String, ExperimentalObjectBrowserDatabaseFolderKind, count: Int?, isLoading: Bool) + case databaseSubfolder(ConnectionSession, String, title: String, systemImage: String, paletteTitle: String, count: Int?) + case databaseNamedItem(ConnectionSession, String, title: String, systemImage: String, paletteTitle: String, detail: String?) + case securitySection(ConnectionSession, ExperimentalObjectBrowserSecuritySectionKind, count: Int, isLoading: Bool) + case securityLogin(ConnectionSession, ExperimentalObjectBrowserSidebarViewModel.SecurityLoginItem) + case securityServerRole(ConnectionSession, ExperimentalObjectBrowserSidebarViewModel.SecurityServerRoleItem) + case securityCredential(ConnectionSession, ExperimentalObjectBrowserSidebarViewModel.SecurityCredentialItem) + case agentJob(ConnectionSession, ExperimentalObjectBrowserSidebarViewModel.AgentJobItem) + case databaseSnapshot(ConnectionSession, SQLServerDatabaseSnapshot) + case linkedServer(ConnectionSession, ExperimentalObjectBrowserSidebarViewModel.LinkedServerItem) + case ssisFolder(ConnectionSession, SQLServerSSISFolder) + case serverTrigger(ConnectionSession, ExperimentalObjectBrowserSidebarViewModel.ServerTriggerItem) + case action(ConnectionSession, ExperimentalObjectBrowserActionKind, depth: Int) + case infoLeaf(String, systemImage: String, paletteTitle: String, depth: Int) + case loading(String, depth: Int) + case message(String, systemImage: String, depth: Int) + } + + let id: String + var row: Row + var children: [ExperimentalObjectBrowserNode] + + init(id: String, row: Row, children: [ExperimentalObjectBrowserNode] = []) { + self.id = id + self.row = row + self.children = children + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserOutlineView.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserOutlineView.swift new file mode 100644 index 000000000..8921750a4 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserOutlineView.swift @@ -0,0 +1,387 @@ +import AppKit +import SwiftUI + +struct ExperimentalObjectBrowserOutlineView: NSViewRepresentable { + let roots: [ExperimentalObjectBrowserNode] + let expandedNodeIDs: Set + let selectedNodeID: String? + let rowContent: (ExperimentalObjectBrowserNode, Bool, Int, CGFloat, @escaping () -> Void) -> AnyView + let onExpansionChanged: (ExperimentalObjectBrowserNode, Bool) -> Void + let onActivation: (ExperimentalObjectBrowserNode) -> Void + let onSelectionChanged: (ExperimentalObjectBrowserNode?) -> Void + let revealNodeID: String? + let revealRequestID: Int + + func makeCoordinator() -> Coordinator { + Coordinator( + rowContent: rowContent, + onExpansionChanged: onExpansionChanged, + onActivation: onActivation, + onSelectionChanged: onSelectionChanged + ) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.contentInsets = NSEdgeInsets( + top: 0, + left: 0, + bottom: ExplorerSidebarConstants.scrollBottomPadding + SpacingTokens.md2, + right: 0 + ) + + scrollView.documentView = context.coordinator.tableView + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + context.coordinator.rowContent = rowContent + context.coordinator.onExpansionChanged = onExpansionChanged + context.coordinator.onActivation = onActivation + context.coordinator.onSelectionChanged = onSelectionChanged + context.coordinator.update( + roots: roots, + expandedNodeIDs: expandedNodeIDs, + selectedNodeID: selectedNodeID, + revealNodeID: revealNodeID, + revealRequestID: revealRequestID + ) + } + + @MainActor + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { + struct VisibleRow { + let node: ExperimentalObjectBrowserNode + let depth: Int + } + + let tableView: NSTableView + var rowContent: (ExperimentalObjectBrowserNode, Bool, Int, CGFloat, @escaping () -> Void) -> AnyView + var onExpansionChanged: (ExperimentalObjectBrowserNode, Bool) -> Void + var onActivation: (ExperimentalObjectBrowserNode) -> Void + var onSelectionChanged: (ExperimentalObjectBrowserNode?) -> Void + + private var roots: [ExperimentalObjectBrowserNode] = [] + private var expandedNodeIDs: Set = [] + private var selectedNodeID: String? + private var visibleRows: [VisibleRow] = [] + private var lastVisibleSignature: [String] = [] + private var lastRevealRequestID = 0 + + init( + rowContent: @escaping (ExperimentalObjectBrowserNode, Bool, Int, CGFloat, @escaping () -> Void) -> AnyView, + onExpansionChanged: @escaping (ExperimentalObjectBrowserNode, Bool) -> Void, + onActivation: @escaping (ExperimentalObjectBrowserNode) -> Void, + onSelectionChanged: @escaping (ExperimentalObjectBrowserNode?) -> Void + ) { + self.rowContent = rowContent + self.onExpansionChanged = onExpansionChanged + self.onActivation = onActivation + self.onSelectionChanged = onSelectionChanged + + let tableView = NSTableView(frame: .zero) + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("explorer-lab")) + column.resizingMask = .autoresizingMask + tableView.addTableColumn(column) + tableView.headerView = nil + tableView.rowSizeStyle = .default + tableView.selectionHighlightStyle = .none + tableView.focusRingType = .none + tableView.backgroundColor = .clear + tableView.enclosingScrollView?.drawsBackground = false + tableView.intercellSpacing = .zero + tableView.usesAutomaticRowHeights = false + tableView.rowHeight = 25 + tableView.allowsEmptySelection = true + tableView.allowsMultipleSelection = false + + self.tableView = tableView + super.init() + tableView.delegate = self + tableView.dataSource = self + } + + func update( + roots: [ExperimentalObjectBrowserNode], + expandedNodeIDs: Set, + selectedNodeID: String?, + revealNodeID: String?, + revealRequestID: Int + ) { + self.roots = roots + self.expandedNodeIDs = expandedNodeIDs + self.selectedNodeID = selectedNodeID + let shouldReveal = revealRequestID != lastRevealRequestID && revealNodeID != nil + let preservedScrollY = shouldReveal ? nil : currentScrollY() + + let oldSignature = lastVisibleSignature + let newVisibleRows = flattenVisibleRows(from: roots, expandedNodeIDs: expandedNodeIDs) + let newSignature = newVisibleRows.map(\.node.id) + let structureChanged = newSignature != lastVisibleSignature + + visibleRows = newVisibleRows + + if structureChanged { + if !oldSignature.isEmpty, + let animations = rowAnimations(from: oldSignature, to: newSignature), + (!animations.removed.isEmpty || !animations.inserted.isEmpty) { + applyRowAnimations(removed: animations.removed, inserted: animations.inserted) + } else { + tableView.reloadData() + } + lastVisibleSignature = newSignature + } else { + refreshVisibleRows() + } + + tableView.deselectAll(nil) + + if let preservedScrollY { + restoreScrollPosition(y: preservedScrollY) + } + + if shouldReveal, let revealNodeID { + reveal(nodeID: revealNodeID) + lastRevealRequestID = revealRequestID + } + } + + func numberOfRows(in tableView: NSTableView) -> Int { + visibleRows.count + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard visibleRows.indices.contains(row) else { return nil } + + let visibleRow = visibleRows[row] + let node = visibleRow.node + let identifier = NSUserInterfaceItemIdentifier("ExperimentalOutlineCell") + let cell = (tableView.makeView(withIdentifier: identifier, owner: nil) as? ExperimentalTableCellView) + ?? ExperimentalTableCellView(identifier: identifier) + + cell.configure(rootView: rowContent( + node, + expandedNodeIDs.contains(node.id), + visibleRow.depth, + 0, + { [weak self] in self?.activate(nodeID: node.id) } + )) + return cell + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + ExperimentalClearRowView() + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + guard visibleRows.indices.contains(row) else { return 25 } + if case .topSpacer(let height) = visibleRows[row].node.row { + return height + } + return 25 + } + + func tableViewSelectionDidChange(_ notification: Notification) { + tableView.deselectAll(nil) + } + + private func activate(nodeID: String) { + guard let node = findNode(id: nodeID, in: roots) else { return } + + if !node.children.isEmpty { + let shouldExpand = !expandedNodeIDs.contains(node.id) + onExpansionChanged(node, shouldExpand) + } + + onSelectionChanged(node) + onActivation(node) + } + + private func refreshVisibleRows() { + let visibleRange = tableView.rows(in: tableView.visibleRect) + guard visibleRange.length > 0 else { return } + + for row in visibleRange.location ..< (visibleRange.location + visibleRange.length) { + guard visibleRows.indices.contains(row), + let cell = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? ExperimentalTableCellView + else { continue } + + let visibleRow = visibleRows[row] + let node = visibleRow.node + cell.configure(rootView: rowContent( + node, + expandedNodeIDs.contains(node.id), + visibleRow.depth, + 0, + { [weak self] in self?.activate(nodeID: node.id) } + )) + } + } + + private func rowAnimations( + from oldSignature: [String], + to newSignature: [String] + ) -> (removed: IndexSet, inserted: IndexSet)? { + let oldSet = Set(oldSignature) + let newSet = Set(newSignature) + + if oldSet.count != oldSignature.count || newSet.count != newSignature.count { + return nil + } + + let removed = IndexSet(oldSignature.enumerated().compactMap { index, id in + newSet.contains(id) ? nil : index + }) + let inserted = IndexSet(newSignature.enumerated().compactMap { index, id in + oldSet.contains(id) ? nil : index + }) + + return (removed, inserted) + } + + private func applyRowAnimations(removed: IndexSet, inserted: IndexSet) { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + tableView.beginUpdates() + if !removed.isEmpty { + tableView.removeRows(at: removed, withAnimation: [.slideUp]) + } + if !inserted.isEmpty { + tableView.insertRows(at: inserted, withAnimation: [.slideDown]) + } + tableView.endUpdates() + } + refreshVisibleRows() + } + + private func flattenVisibleRows( + from roots: [ExperimentalObjectBrowserNode], + expandedNodeIDs: Set + ) -> [VisibleRow] { + var rows: [VisibleRow] = [] + + func append(nodes: [ExperimentalObjectBrowserNode], depth: Int) { + for node in nodes { + rows.append(VisibleRow(node: node, depth: depth)) + guard expandedNodeIDs.contains(node.id) else { continue } + append(nodes: node.children, depth: childDepth(for: node, currentDepth: depth)) + } + } + + append(nodes: roots, depth: 0) + return rows + } + + private func childDepth(for node: ExperimentalObjectBrowserNode, currentDepth: Int) -> Int { + switch node.row { + case .topSpacer: + currentDepth + case .pendingConnection: + currentDepth + case .server: + currentDepth + case .databasesFolder, .serverFolder, .databaseFolder, .databaseSubfolder, .securitySection: + currentDepth + 1 + case .database, .objectGroup, .action, .infoLeaf, .loading, .message, .object, + .agentJob, .databaseSnapshot, .linkedServer, .ssisFolder, .serverTrigger, + .securityLogin, .securityServerRole, .securityCredential, .databaseNamedItem: + currentDepth + 1 + } + } + + private func findNode( + id: String, + in nodes: [ExperimentalObjectBrowserNode] + ) -> ExperimentalObjectBrowserNode? { + for node in nodes { + if node.id == id { + return node + } + if let child = findNode(id: id, in: node.children) { + return child + } + } + return nil + } + + private func reveal(nodeID: String) { + guard let row = visibleRows.firstIndex(where: { $0.node.id == nodeID }) else { return } + let targetRow = preferredRevealRow(for: row) + scrollRowToTop(targetRow) + } + + private func preferredRevealRow(for row: Int) -> Int { + guard row > 0 else { return row } + if case .topSpacer = visibleRows[row - 1].node.row { + return row - 1 + } + return row + } + + private func currentScrollY() -> CGFloat? { + tableView.enclosingScrollView?.contentView.bounds.origin.y + } + + private func restoreScrollPosition(y: CGFloat) { + guard let scrollView = tableView.enclosingScrollView else { return } + let clipView = scrollView.contentView + let maxY = max(0, tableView.bounds.height - clipView.bounds.height) + let clampedY = min(max(0, y), maxY) + clipView.scroll(to: NSPoint(x: 0, y: clampedY)) + scrollView.reflectScrolledClipView(clipView) + } + + private func scrollRowToTop(_ row: Int) { + guard let scrollView = tableView.enclosingScrollView, row >= 0, row < tableView.numberOfRows else { + return + } + let clipView = scrollView.contentView + let rowRect = tableView.rect(ofRow: row) + let maxY = max(0, tableView.bounds.height - clipView.bounds.height) + let targetY = min(max(0, rowRect.minY), maxY) + clipView.scroll(to: NSPoint(x: 0, y: targetY)) + scrollView.reflectScrolledClipView(clipView) + } + } +} + +@MainActor +final class ExperimentalTableCellView: NSTableCellView { + private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + + init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + + hostingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingView.topAnchor.constraint(equalTo: topAnchor), + hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + func configure(rootView: AnyView) { + hostingView.rootView = rootView + } +} + +@MainActor +final class ExperimentalClearRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) {} + override func drawBackground(in dirtyRect: NSRect) {} +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserRowView+Helpers.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserRowView+Helpers.swift new file mode 100644 index 000000000..d4d9215b6 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserRowView+Helpers.swift @@ -0,0 +1,75 @@ +import SwiftUI + +extension ExperimentalObjectBrowserRowView { + func serverDisplayName(_ connection: SavedConnection) -> String { + let name = connection.connectionName.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? connection.host : name + } + + func serverDisplayName(_ session: ConnectionSession) -> String { + serverDisplayName(session.connection) + } + + func serverSubtitle(_ session: ConnectionSession) -> String { + if let version = serverVersionLabel(session) { + return "\(session.connection.databaseType.displayName) (\(version))" + } + return session.connection.databaseType.displayName + } + + func serverVersionLabel(_ session: ConnectionSession) -> String? { + let raw = session.databaseStructure?.serverVersion ?? session.connection.serverVersion + guard let raw, !raw.isEmpty else { return nil } + let prefixes = ["SQL Server ", "PostgreSQL ", "Microsoft SQL Server "] + for prefix in prefixes where raw.hasPrefix(prefix) { + let version = String(raw.dropFirst(prefix.count)) + return version.isEmpty ? nil : version + } + if ["PostgreSQL", "Microsoft SQL Server", "SQL Server"].contains(raw) { + return nil + } + return raw + } + + func databaseIconColor(_ database: DatabaseInfo, session: ConnectionSession) -> Color { + if !database.isOnline || !database.isAccessible { + return ColorTokens.Text.quaternary + } + if isSelected { + return resolvedAccentColor(for: session.connection) + } + return projectStore.globalSettings.sidebarIconColorMode == .colorful + ? ExplorerSidebarPalette.databaseInstance + : ExplorerSidebarPalette.monochrome + } + + func resolvedAccentColor(for connection: SavedConnection) -> Color { + switch projectStore.globalSettings.accentColorSource { + case .system: + Color.accentColor + case .connection: + connection.color + case .custom: + ColorTokens.accent + } + } + + func objectIconName(_ type: SchemaObjectInfo.ObjectType) -> String { + switch type { + case .table: "tablecells" + case .view, .materializedView: "eye" + case .function: "function" + case .trigger: "bolt" + case .procedure: "terminal" + case .extension: "puzzlepiece" + case .sequence: "number" + case .type: "t.square" + case .synonym: "arrow.triangle.branch" + } + } + + func objectSubtitle(_ object: SchemaObjectInfo) -> String? { + guard object.type == .trigger, let table = object.triggerTable, !table.isEmpty else { return nil } + return "on \(table)" + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserRowView.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserRowView.swift new file mode 100644 index 000000000..395e050a7 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserRowView.swift @@ -0,0 +1,583 @@ +import SwiftUI +import AppKit +import SQLServerKit + +struct ExperimentalObjectBrowserRowView: View { + let node: ExperimentalObjectBrowserNode + let isExpanded: Bool + let isSelected: Bool + let outlineLevel: Int + let outlineOffset: CGFloat + let isHighlighted: Bool + let highlightPulse: Bool + let contextMenuBuilder: (() -> NSMenu?)? + let onActivate: () -> Void + + @Environment(ProjectStore.self) var projectStore + @Environment(EnvironmentState.self) var environmentState + + private var depth: Int { + max(0, outlineLevel) + } + + private var leadingAlignmentCompensation: CGFloat { + switch node.row { + case .topSpacer: + 0 + case .server: + -(SidebarRowConstants.rowOuterHorizontalPadding + SpacingTokens.xs) + case .pendingConnection: + -(SidebarRowConstants.rowOuterHorizontalPadding + SpacingTokens.xs) + default: + -(SidebarRowConstants.rowOuterHorizontalPadding + SpacingTokens.xxxs) + } + } + + var body: some View { + rowBody + .padding(.leading, leadingAlignmentCompensation) + .overlay { + if shouldShowHighlightOverlay { + StatusWaveOverlay( + color: ColorTokens.Status.success, + cornerRadius: SidebarRowConstants.hoverCornerRadius, + trigger: highlightPulse + ) + .clipShape(RoundedRectangle(cornerRadius: SidebarRowConstants.hoverCornerRadius, style: .continuous)) + .allowsHitTesting(false) + } + } + .modifier(RowLazyContextMenu(menuBuilder: contextMenuBuilder)) + } + + private var shouldShowHighlightOverlay: Bool { + guard isHighlighted else { return false } + switch node.row { + case .topSpacer, .pendingConnection, .server: + return false + default: + return true + } + } + + @ViewBuilder + private var rowBody: some View { + switch node.row { + case .topSpacer(let height): + Color.clear + .frame(height: height) + case .pendingConnection(let pending): + pendingConnectionRow(pending: pending) + case .server(let session): + serverRow(session: session) + case .databasesFolder(_, let count): + buttonRow { + SidebarRow( + depth: depth, + icon: .system("cylinder"), + label: "Databases", + isExpanded: Binding(get: { isExpanded }, set: { _ in onActivate() }), + iconColor: ExplorerSidebarPalette.folderIconColor( + title: "Databases", + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + ) { + Text("\(count)") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } + case .database(let session, let database, let isLoading): + buttonRow { + SidebarRow( + depth: depth, + icon: .system("internaldrive"), + label: database.name, + isExpanded: Binding(get: { isExpanded }, set: { _ in onActivate() }), + isSelected: isSelected, + iconColor: databaseIconColor(database, session: session), + labelColor: database.isAccessible ? ColorTokens.Text.primary : ColorTokens.Text.secondary, + accentColor: resolvedAccentColor(for: session.connection) + ) { + if !database.isOnline, let state = database.stateDescription { + Text(state.uppercased()) + .font(TypographyTokens.compact) + .foregroundStyle(ColorTokens.Text.quaternary) + } else if !database.isAccessible { + Text("NO ACCESS") + .font(TypographyTokens.compact) + .foregroundStyle(ColorTokens.Text.quaternary) + } + if isLoading { + ProgressView() + .controlSize(.mini) + } + } + .opacity(database.isOnline && database.isAccessible ? 1 : 0.5) + } + case .objectGroup(_, _, let type, let count): + buttonRow { + SidebarRow( + depth: depth, + icon: .system(type.systemImage), + label: type.pluralDisplayName, + isExpanded: Binding(get: { isExpanded }, set: { _ in onActivate() }), + iconColor: ExplorerSidebarPalette.objectGroupIconColor( + for: type, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + ) { + Text("\(count)") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } + case .object(let session, _, let object): + buttonRow { + SidebarRow( + depth: depth, + icon: .system(objectIconName(object.type)), + label: object.fullName, + subtitle: objectSubtitle(object), + isSelected: isSelected, + iconColor: ExplorerSidebarPalette.objectGroupIconColor( + for: object.type, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + accentColor: resolvedAccentColor(for: session.connection) + ) + } + case .serverFolder(_, let kind, let count): + buttonRow { + SidebarRow( + depth: depth, + icon: .system(kind.systemImage), + label: kind.title, + isExpanded: Binding(get: { isExpanded }, set: { _ in onActivate() }), + iconColor: ExplorerSidebarPalette.folderIconColor( + title: kind.title, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + ) { + if let count { + Text("\(count)") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } + } + case .databaseFolder(_, _, let kind, let count, let isLoading): + buttonRow { + SidebarRow( + depth: depth, + icon: .system(kind.systemImage), + label: kind.title, + isExpanded: Binding(get: { isExpanded }, set: { _ in onActivate() }), + iconColor: ExplorerSidebarPalette.folderIconColor( + title: kind.title, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + ) { + if let count { + Text("\(count)") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + } + if isLoading { + ProgressView() + .controlSize(.mini) + } + } + } + case .databaseSubfolder(_, _, let title, let systemImage, let paletteTitle, let count): + buttonRow { + SidebarRow( + depth: depth, + icon: .system(systemImage), + label: title, + isExpanded: Binding(get: { isExpanded }, set: { _ in onActivate() }), + iconColor: ExplorerSidebarPalette.folderIconColor( + title: paletteTitle, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + ) { + if let count { + Text("\(count)") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } + } + case .databaseNamedItem(let session, _, let title, let systemImage, let paletteTitle, let detail): + buttonRow { + SidebarRow( + depth: depth, + icon: .system(systemImage), + label: title, + isSelected: isSelected, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: paletteTitle, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + accentColor: resolvedAccentColor(for: session.connection) + ) { + if let detail, !detail.isEmpty { + Text(detail) + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + .lineLimit(1) + } + } + } + case .securitySection(_, let kind, let count, let isLoading): + buttonRow { + SidebarRow( + depth: depth, + icon: .system(kind.systemImage), + label: kind.title, + isExpanded: Binding(get: { isExpanded }, set: { _ in onActivate() }), + iconColor: ExplorerSidebarPalette.folderIconColor( + title: kind.title, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + ) { + Text("\(count)") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + if isLoading { + ProgressView() + .controlSize(.mini) + } + } + } + case .securityLogin(_, let login): + SidebarRow( + depth: depth, + icon: .system(securityLoginIconName(login)), + label: login.name, + iconColor: securityLoginIconColor(login), + labelColor: login.isDisabled ? ColorTokens.Text.secondary : ColorTokens.Text.primary + ) { + Text(login.loginType) + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + + if login.isDisabled { + Text("Disabled") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.quaternary) + } + } + case .securityServerRole(_, let role): + SidebarRow( + depth: depth, + icon: .system("shield"), + label: role.name, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: "Server Roles", + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + ) { + if role.isFixed { + Text("Fixed") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.quaternary) + } + } + case .securityCredential(_, let credential): + SidebarRow( + depth: depth, + icon: .system("key"), + label: credential.name, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: "Credentials", + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + ) { + Text(credential.identity) + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + .lineLimit(1) + } + case .agentJob(_, let job): + buttonRow { + SidebarRow( + depth: depth, + icon: .system("clock"), + label: job.name, + isSelected: isSelected, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: "Agent Jobs", + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + accentColor: Color.accentColor + ) { + if let lastOutcome = job.lastOutcome, !lastOutcome.isEmpty { + Text(lastOutcome) + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } + } + case .databaseSnapshot(_, let snapshot): + buttonRow { + SidebarRow( + depth: depth, + icon: .system("camera.fill"), + label: snapshot.name, + isSelected: isSelected, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: "Database Snapshots", + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + accentColor: Color.accentColor + ) { + Text(snapshot.sourceDatabaseName) + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + .lineLimit(1) + } + } + case .linkedServer(_, let server): + buttonRow { + SidebarRow( + depth: depth, + icon: .system("link"), + label: server.name, + isSelected: isSelected, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: "Linked Servers", + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + labelColor: server.isDataAccessEnabled ? ColorTokens.Text.primary : ColorTokens.Text.secondary, + accentColor: Color.accentColor + ) { + if !server.dataSource.isEmpty { + Text(server.dataSource) + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + .lineLimit(1) + } + } + } + case .ssisFolder(_, let folder): + buttonRow { + SidebarRow( + depth: depth, + icon: .system("folder"), + label: folder.name, + isSelected: isSelected, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: "Integration Services Catalogs", + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + accentColor: Color.accentColor + ) + } + case .serverTrigger(_, let trigger): + buttonRow { + SidebarRow( + depth: depth, + icon: .system("bolt"), + label: trigger.name, + isSelected: isSelected, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: "Server Triggers", + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + labelColor: trigger.isDisabled ? ColorTokens.Text.tertiary : ColorTokens.Text.primary, + accentColor: Color.accentColor + ) { + if trigger.isDisabled { + Text("Disabled") + .font(SidebarRowConstants.trailingFont) + .foregroundStyle(ColorTokens.Text.tertiary) + } + } + } + case .action(_, let action, _): + buttonRow { + SidebarRow( + depth: depth, + icon: .system(action.systemImage), + label: action.title, + isSelected: isSelected, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: action.title, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + accentColor: Color.accentColor + ) + } + case .infoLeaf(let title, let systemImage, let paletteTitle, _): + SidebarRow( + depth: depth, + icon: .system(systemImage), + label: title, + iconColor: ExplorerSidebarPalette.folderIconColor( + title: paletteTitle, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ), + labelColor: ColorTokens.Text.secondary, + labelFont: TypographyTokens.detail + ) + case .loading(let title, _): + SidebarRow( + depth: depth, + icon: .none, + label: title, + labelColor: ColorTokens.Text.tertiary, + labelFont: TypographyTokens.detail + ) { + ProgressView() + .controlSize(.mini) + } + case .message(let title, let systemImage, _): + SidebarRow( + depth: depth, + icon: .system(systemImage), + label: title, + iconColor: ColorTokens.Status.warning, + labelColor: ColorTokens.Text.secondary, + labelFont: TypographyTokens.detail + ) + } + } + + private func serverRow(session: ConnectionSession) -> some View { + SidebarConnectionHeader( + connectionName: serverDisplayName(session), + subtitle: serverSubtitle(session), + databaseType: session.connection.databaseType, + connectionColor: resolvedAccentColor(for: session.connection), + isExpanded: Binding(get: { isExpanded }, set: { _ in onActivate() }), + isColorful: projectStore.globalSettings.sidebarIconColorMode == .colorful, + isSecure: session.connection.useTLS, + connectionState: session.connectionState, + onAction: onActivate, + iconScale: 1, + iconFrameScale: 1.58, + iconGlyphScale: 1.55, + leadingPaddingAdjustment: -SpacingTokens.xxs2, + statusPresentation: .none, + labelFont: TypographyTokens.standard.weight(.medium) + ) + .overlay { + if isHighlighted { + StatusWaveOverlay( + color: ColorTokens.Status.success, + cornerRadius: SidebarRowConstants.hoverCornerRadius, + trigger: highlightPulse + ) + } + } + } + + private func pendingConnectionRow(pending: PendingConnection) -> some View { + let connection = pending.connection + let connectionState: ConnectionState = switch pending.phase { + case .connecting: + .connecting + case .failed(let message): + .error(.connectionFailed(message)) + } + + let trailingAccessory: SidebarConnectionHeader.TrailingAccessory = switch pending.phase { + case .connecting: + .spinner + case .failed: + .retryButton({ + environmentState.retryPendingConnection(for: connection.id) + }) + } + + return SidebarConnectionHeader( + connectionName: serverDisplayName(connection), + subtitle: connection.databaseType.displayName, + databaseType: connection.databaseType, + connectionColor: resolvedAccentColor(for: connection), + isExpanded: .constant(false), + isColorful: projectStore.globalSettings.sidebarIconColorMode == .colorful, + isSecure: connection.useTLS, + connectionState: connectionState, + onAction: {}, + trailingAccessory: trailingAccessory, + iconScale: 1, + iconFrameScale: 1.58, + iconGlyphScale: 1.55, + leadingPaddingAdjustment: -SpacingTokens.xxs2, + statusPresentation: .none, + labelFont: TypographyTokens.standard.weight(.medium) + ) + .background { + switch pending.phase { + case .connecting: + StatusWaveOverlay( + color: ColorTokens.accent, + cornerRadius: SidebarRowConstants.hoverCornerRadius, + continuous: true + ) + .clipShape(RoundedRectangle(cornerRadius: SidebarRowConstants.hoverCornerRadius, style: .continuous)) + .allowsHitTesting(false) + case .failed: + StatusWaveOverlay( + color: ColorTokens.Status.error, + cornerRadius: SidebarRowConstants.hoverCornerRadius, + trigger: true + ) + .clipShape(RoundedRectangle(cornerRadius: SidebarRowConstants.hoverCornerRadius, style: .continuous)) + .allowsHitTesting(false) + } + } + } + + private func securityLoginIconName( + _ login: ExperimentalObjectBrowserSidebarViewModel.SecurityLoginItem + ) -> String { + if login.loginType == "Group Role" { + return "person.2.circle" + } + return login.isDisabled ? "person.crop.circle.badge.xmark" : "person.crop.circle" + } + + private func securityLoginIconColor( + _ login: ExperimentalObjectBrowserSidebarViewModel.SecurityLoginItem + ) -> Color { + if login.isDisabled { + return ColorTokens.Text.quaternary + } + let title: String = if login.loginType == "Group Role" { + "Group Roles" + } else if login.loginType.contains("Login") || login.loginType.contains("Superuser") { + "Login Roles" + } else { + "Logins" + } + return ExplorerSidebarPalette.folderIconColor( + title: title, + colored: projectStore.globalSettings.sidebarIconColorMode == .colorful + ) + } + + private func buttonRow(@ViewBuilder content: () -> Content) -> some View { + Button(action: onActivate) { + content() + } + .buttonStyle(.plain) + .animation(.snappy(duration: 0.18, extraBounce: 0), value: isExpanded) + } +} + +private struct RowLazyContextMenu: ViewModifier { + let menuBuilder: (() -> NSMenu?)? + + func body(content: Content) -> some View { + if let menuBuilder { + content.lazyContextMenu { + menuBuilder() ?? NSMenu() + } + } else { + content + } + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+ContextMenus.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+ContextMenus.swift new file mode 100644 index 000000000..e0f069339 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+ContextMenus.swift @@ -0,0 +1,1368 @@ +import AppKit +import SwiftUI +import SQLServerKit + +extension ExperimentalObjectBrowserSidebarView { + func contextMenu(for node: ExperimentalObjectBrowserNode) -> NSMenu? { + switch node.row { + case .topSpacer: + nil + case .pendingConnection(let pending): + pendingConnectionMenu(for: pending) + case .server(let session): + serverMenu(for: session) + case .databasesFolder(let session, _): + databasesFolderMenu(for: session) + case .database(let session, let database, _): + databaseMenu(for: database, session: session) + case .objectGroup(let session, let databaseName, let type, _): + objectGroupMenu(for: type, databaseName: databaseName, session: session) + case .object(let session, let databaseName, let object): + objectMenu(for: object, databaseName: databaseName, session: session) + case .serverFolder(let session, let kind, _): + serverFolderMenu(kind: kind, session: session) + case .databaseFolder(let session, let databaseName, let kind, _, _): + databaseFolderMenu(kind: kind, databaseName: databaseName, session: session) + case .databaseSubfolder(let session, let databaseName, let title, _, _, _): + databaseSubfolderMenu(title: title, databaseName: databaseName, session: session) + case .databaseNamedItem: + nil + case .securitySection(let session, let kind, _, _): + securitySectionMenu(kind: kind, session: session) + case .securityLogin(let session, let login): + securityLoginMenu(login: login, session: session) + case .securityServerRole(let session, let role): + securityServerRoleMenu(role: role, session: session) + case .securityCredential(let session, let credential): + securityCredentialMenu(credential: credential, session: session) + case .databaseSnapshot(let session, let snapshot): + snapshotMenu(snapshot: snapshot, session: session) + case .linkedServer(let session, let server): + linkedServerMenu(server: server, session: session) + case .serverTrigger(let session, let trigger): + serverTriggerMenu(trigger: trigger, session: session) + case .agentJob(let session, _): + agentJobMenu(for: session) + case .ssisFolder, .action, .infoLeaf, .loading, .message: + nil + } + } + + private func pendingConnectionMenu(for pending: PendingConnection) -> NSMenu { + let menu = NSMenu() + + switch pending.phase { + case .connecting: + menu.addActionItem("Cancel Connection", systemImage: "xmark.circle") { + environmentState.cancelPendingConnection(for: pending.connection.id) + } + menu.addActionItem("Edit Connection", systemImage: "pencil") { + ManageConnectionsWindowController.shared.present() + } + case .failed: + menu.addActionItem("Retry", systemImage: "arrow.clockwise") { + environmentState.retryPendingConnection(for: pending.connection.id) + } + menu.addActionItem("Edit Connection", systemImage: "pencil") { + ManageConnectionsWindowController.shared.present() + } + menu.addDivider() + menu.addActionItem("Remove", systemImage: "trash") { + environmentState.removePendingConnection(for: pending.connection.id) + } + } + + return menu + } + + private func serverFolderMenu( + kind: ExperimentalObjectBrowserServerFolderKind, + session: ConnectionSession + ) -> NSMenu? { + let menu = NSMenu() + + switch kind { + case .security: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + Task { + let handle = AppDirector.shared.activityEngine.begin("Refreshing security", connectionSessionID: session.id) + await loadServerSecurityAsync(session: session) + handle.succeed() + } + } + switch session.connection.databaseType { + case .microsoftSQL: + menu.addActionItem("New Login", systemImage: "person.badge.plus") { + let value = environmentState.prepareLoginEditorWindow( + connectionSessionID: session.connection.id, + existingLogin: nil + ) + openWindow(id: LoginEditorWindow.sceneID, value: value) + } + menu.addDivider() + menu.addActionItem("Open Security Management", systemImage: "lock.shield") { + environmentState.openServerSecurityTab(connectionID: session.connection.id) + } + case .postgresql: + menu.addActionItem("New Login Role", systemImage: "person.badge.plus") { + sheetState.securityPGRoleSheetSessionID = session.connection.id + sheetState.securityPGRoleSheetEditName = nil + sheetState.showSecurityPGRoleSheet = true + } + menu.addActionItem("New Group Role", systemImage: "person.2.badge.plus") { + sheetState.securityPGRoleSheetSessionID = session.connection.id + sheetState.securityPGRoleSheetEditName = nil + sheetState.showSecurityPGRoleSheet = true + } + case .mysql, .sqlite: + break + } + case .agentJobs: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + loadAgentJobs(session: session) + } + menu.addDivider() + menu.addActionItem("Open in Tab", systemImage: "list.bullet.rectangle") { + environmentState.openJobQueueTab(for: session) + } + menu.addActionItem("Open in New Window", systemImage: "rectangle.portrait.and.arrow.right") { + let sessionID = environmentState.prepareJobQueueWindow(for: session) + openWindow(id: JobQueueWindow.sceneID, value: sessionID) + } + case .databaseSnapshots: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + loadDatabaseSnapshots(session: session) + } + menu.addDivider() + menu.addActionItem("New Snapshot", systemImage: "camera.badge.ellipsis") { + sheetState.createSnapshotConnectionID = session.connection.id + sheetState.showCreateSnapshotSheet = true + } + case .ssis: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + Task { await loadSSISFoldersAsync(session: session) } + } + case .linkedServers: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + loadLinkedServers(session: session) + } + menu.addDivider() + menu.addActionItem("New Linked Server", systemImage: "link.badge.plus") { + sheetState.newLinkedServerSessionID = session.connection.id + sheetState.showNewLinkedServerSheet = true + } + case .serverTriggers: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + loadServerTriggers(session: session) + } + menu.addActionItem("New Server Trigger", systemImage: "bolt") { + sheetState.newServerTriggerConnectionID = session.connection.id + sheetState.showNewServerTriggerSheet = true + } + case .management: + return nil + } + + return menu + } + + private func securitySectionMenu( + kind: ExperimentalObjectBrowserSecuritySectionKind, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + Task { + let handle = AppDirector.shared.activityEngine.begin("Refreshing \(kind.title.lowercased())", connectionSessionID: session.id) + await loadServerSecurityAsync(session: session) + handle.succeed() + } + } + + switch kind { + case .logins: + menu.addActionItem("New Login", systemImage: "person.badge.plus") { + let value = environmentState.prepareLoginEditorWindow( + connectionSessionID: session.connection.id, + existingLogin: nil + ) + openWindow(id: LoginEditorWindow.sceneID, value: value) + } + case .serverRoles: + menu.addActionItem("New Server Role", systemImage: "person.2.badge.plus") { + createMSSQLServerRole(session: session) + } + case .credentials: + menu.addActionItem("New Credential", systemImage: "key.fill") { + createMSSQLCredential(session: session) + } + case .pgLoginRoles: + menu.addActionItem("New Login Role", systemImage: "person.badge.plus") { + sheetState.securityPGRoleSheetSessionID = session.connection.id + sheetState.securityPGRoleSheetEditName = nil + sheetState.showSecurityPGRoleSheet = true + } + case .pgGroupRoles: + menu.addActionItem("New Group Role", systemImage: "person.2.badge.plus") { + sheetState.securityPGRoleSheetSessionID = session.connection.id + sheetState.securityPGRoleSheetEditName = nil + sheetState.showSecurityPGRoleSheet = true + } + case .certificateLogins: + break + } + + return menu + } + + private func securityLoginMenu( + login: ExperimentalObjectBrowserSidebarViewModel.SecurityLoginItem, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + + if session.connection.databaseType == .postgresql { + menu.addActionItem("Reassign Owned Objects", systemImage: "arrow.triangle.swap") { + Task { await reassignPGRole(name: login.name, session: session) } + } + menu.addDivider() + menu.addSubmenu("Script as", systemImage: "scroll") { sub in + let loginAttribute = login.loginType.contains("Login") || login.loginType.contains("Superuser") ? " LOGIN" : "" + sub.addActionItem("CREATE", systemImage: "plus.rectangle.on.rectangle") { + openScriptTab(sql: "CREATE ROLE \"\(login.name)\"\(loginAttribute);", session: session) + } + sub.addDivider() + sub.addActionItem("DROP", systemImage: "trash") { + openScriptTab(sql: "DROP ROLE \"\(login.name)\";", session: session) + } + } + menu.addDivider() + menu.addActionItem("Drop Role", systemImage: "trash") { + sheetState.dropSecurityPrincipalTarget = .init( + sessionID: session.id, + connectionID: session.connection.id, + name: login.name, + kind: .pgRole, + databaseName: nil + ) + sheetState.showDropSecurityPrincipalAlert = true + } + menu.addDivider() + menu.addActionItem("Properties", systemImage: "info.circle") { + sheetState.securityPGRoleSheetSessionID = session.connection.id + sheetState.securityPGRoleSheetEditName = login.name + sheetState.showSecurityPGRoleSheet = true + } + return menu + } + + menu.addSubmenu("Script as", systemImage: "scroll") { sub in + let createSQL = if login.loginType == "SQL" { + "CREATE LOGIN [\(login.name)] WITH PASSWORD = N'';" + } else { + "CREATE LOGIN [\(login.name)] FROM WINDOWS;" + } + sub.addActionItem("CREATE", systemImage: "plus.rectangle.on.rectangle") { + openScriptTab(sql: createSQL, session: session) + } + sub.addDivider() + sub.addActionItem("DROP", systemImage: "trash") { + openScriptTab(sql: "DROP LOGIN [\(login.name)];", session: session) + } + } + menu.addDivider() + if login.isDisabled { + menu.addActionItem("Enable Login", systemImage: "checkmark.circle") { + Task { await enableMSSQLLogin(name: login.name, enabled: true, session: session) } + } + } else { + menu.addActionItem("Disable Login", systemImage: "nosign") { + Task { await enableMSSQLLogin(name: login.name, enabled: false, session: session) } + } + } + menu.addDivider() + menu.addActionItem("Drop Login", systemImage: "trash") { + sheetState.dropSecurityPrincipalTarget = .init( + sessionID: session.id, + connectionID: session.connection.id, + name: login.name, + kind: .mssqlLogin, + databaseName: nil + ) + sheetState.showDropSecurityPrincipalAlert = true + } + menu.addDivider() + menu.addActionItem("Properties", systemImage: "info.circle") { + let value = environmentState.prepareLoginEditorWindow( + connectionSessionID: session.connection.id, + existingLogin: login.name + ) + openWindow(id: LoginEditorWindow.sceneID, value: value) + } + return menu + } + + private func securityServerRoleMenu( + role: ExperimentalObjectBrowserSidebarViewModel.SecurityServerRoleItem, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + menu.addActionItem("List Members", systemImage: "person.2") { + openScriptTab( + sql: """ + SELECT m.name AS member_name, m.type_desc + FROM sys.server_role_members rm + JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id + JOIN sys.server_principals m ON rm.member_principal_id = m.principal_id + WHERE r.name = N'\(role.name)'; + """, + session: session + ) + } + + if !role.isFixed { + menu.addDivider() + menu.addSubmenu("Script as", systemImage: "scroll") { sub in + sub.addActionItem("CREATE", systemImage: "plus.rectangle.on.rectangle") { + openScriptTab(sql: "CREATE SERVER ROLE [\(role.name)];", session: session) + } + sub.addDivider() + sub.addActionItem("DROP", systemImage: "trash") { + openScriptTab(sql: "DROP SERVER ROLE [\(role.name)];", session: session) + } + } + menu.addDivider() + menu.addActionItem("Drop Server Role", systemImage: "trash") { + sheetState.dropSecurityPrincipalTarget = .init( + sessionID: session.id, + connectionID: session.connection.id, + name: role.name, + kind: .mssqlServerRole, + databaseName: nil + ) + sheetState.showDropSecurityPrincipalAlert = true + } + } + + return menu + } + + private func securityCredentialMenu( + credential: ExperimentalObjectBrowserSidebarViewModel.SecurityCredentialItem, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + menu.addSubmenu("Script as", systemImage: "scroll") { sub in + sub.addActionItem("CREATE", systemImage: "plus.rectangle.on.rectangle") { + openScriptTab( + sql: "CREATE CREDENTIAL [\(credential.name)] WITH IDENTITY = N'\(credential.identity)', SECRET = N'';", + session: session + ) + } + sub.addDivider() + sub.addActionItem("DROP", systemImage: "trash") { + openScriptTab(sql: "DROP CREDENTIAL [\(credential.name)];", session: session) + } + } + return menu + } + + private func serverMenu(for session: ConnectionSession) -> NSMenu { + let menu = NSMenu() + + menu.addActionItem("Refresh All", systemImage: "arrow.clockwise") { + Task { + let handle = AppDirector.shared.activityEngine.begin("Refreshing all databases", connectionSessionID: session.id) + await environmentState.refreshDatabaseStructure(for: session.id, scope: .full) + handle.succeed() + } + } + menu.addActionItem("New Query", systemImage: "doc.text") { + environmentState.openQueryTab(for: session) + } + menu.addDivider() + menu.addActionItem("Activity Monitor", systemImage: "gauge.with.dots.needle.33percent") { + environmentState.openActivityMonitorTab(connectionID: session.connection.id) + } + menu.addDivider() + menu.addActionItem("Maintenance", systemImage: "wrench.and.screwdriver") { + environmentState.openMaintenanceTab(connectionID: session.connection.id) + } + + if session.connection.databaseType == .microsoftSQL { + menu.addActionItem("Database Mail", systemImage: "envelope") { + let value = environmentState.prepareDatabaseMailEditorWindow(connectionSessionID: session.connection.id) + openWindow(id: DatabaseMailEditorWindow.sceneID, value: value) + } + menu.addActionItem("Central Management Servers", systemImage: "server.rack") { + sheetState.cmsConnectionID = session.connection.id + sheetState.showCMSSheet = true + } + menu.addActionItem("Extended Events", systemImage: "waveform.path.ecg") { + environmentState.openActivityMonitorTab(connectionID: session.connection.id, section: "XEvents") + } + menu.addActionItem("Availability Groups", systemImage: "server.rack") { + environmentState.openAvailabilityGroupsTab(connectionID: session.connection.id) + } + menu.addDivider() + + let connID = session.connection.id + let hideOffline = viewModel.hideOfflineDatabasesBySession[connID] ?? false + let item = menu.addActionItem("Hide Offline Databases", systemImage: "eye.slash") { + viewModel.hideOfflineDatabasesBySession[connID] = !(viewModel.hideOfflineDatabasesBySession[connID] ?? false) + } + item.state = hideOffline ? NSControl.StateValue.on : NSControl.StateValue.off + } + + menu.addDivider() + menu.addActionItem("Manage Connection", systemImage: "slider.horizontal.3") { + ManageConnectionsWindowController.shared.present( + initialSection: .connections, + selectedConnectionID: session.connection.id + ) + } + menu.addActionItem("Disconnect", systemImage: "xmark.circle") { + Task { await environmentState.disconnectSession(withID: session.id) } + } + + menu.addDivider() + if session.connection.databaseType == .microsoftSQL { + menu.addActionItem("Properties", systemImage: "info.circle") { + let value = environmentState.prepareServerEditorWindow(connectionSessionID: session.connection.id) + openWindow(id: ServerEditorWindow.sceneID, value: value) + } + } else if session.connection.databaseType == .mysql { + menu.addActionItem("Server Properties", systemImage: "info.circle") { + environmentState.openServerPropertiesTab(connectionID: session.connection.id) + } + } + + return menu + } + + private func databasesFolderMenu(for session: ConnectionSession) -> NSMenu { + let menu = NSMenu() + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + Task { + let handle = AppDirector.shared.activityEngine.begin("Refreshing databases", connectionSessionID: session.id) + await environmentState.refreshDatabaseStructure(for: session.id, scope: .full) + handle.succeed() + } + } + let newDatabaseItem = menu.addActionItem("New Database", systemImage: "cylinder") { + sheetState.newDatabaseConnectionID = session.connection.id + sheetState.showNewDatabaseSheet = true + } + newDatabaseItem.isEnabled = session.permissions?.canCreateDatabases ?? true + if session.connection.databaseType == .microsoftSQL || session.connection.databaseType == .sqlite { + menu.addDivider() + menu.addActionItem("Attach Database", systemImage: "externaldrive.badge.plus") { + sheetState.attachConnectionID = session.connection.id + sheetState.showAttachSheet = true + } + } + return menu + } + + private func databaseMenu(for database: DatabaseInfo, session: ConnectionSession) -> NSMenu { + let menu = NSMenu() + let connID = session.connection.id + let dbType = session.connection.databaseType + + menu.addActionItem("Refresh Schema", systemImage: "arrow.clockwise") { + viewModel.setExpanded(true, nodeID: ExperimentalObjectBrowserSidebarViewModel.databaseNodeID(connectionID: connID, databaseName: database.name)) + Task { + let handle = AppDirector.shared.activityEngine.begin("Refreshing schema for \(database.name)", connectionSessionID: session.id) + await environmentState.loadSchemaForDatabase(database.name, connectionSession: session) + handle.succeed() + } + } + menu.addActionItem("New Query", systemImage: "doc.text") { + environmentState.openQueryTab(for: session, database: database.name) + } + + menu.addDivider() + + if dbType == .postgresql, projectStore.globalSettings.managedPostgresConsoleEnabled { + menu.addActionItem("Postgres Console", systemImage: "terminal") { + environmentState.openPSQLTab(for: session, database: database.name) + } + menu.addDivider() + } + + menu.addActionItem("Maintenance", systemImage: "wrench.and.screwdriver") { + environmentState.openMaintenanceTab(connectionID: connID, databaseName: database.name) + } + + if dbType == .postgresql { + menu.addSubmenu("Tasks", systemImage: "gearshape") { sub in + sub.addActionItem("Back Up", systemImage: "arrow.down.doc") { + sheetState.pgBackupDatabaseName = database.name + sheetState.pgBackupConnectionID = connID + sheetState.showPgBackupSheet = true + } + sub.addActionItem("Restore", systemImage: "arrow.up.doc") { + sheetState.pgBackupDatabaseName = database.name + sheetState.pgBackupConnectionID = connID + sheetState.showPgRestoreSheet = true + } + } + } + + if dbType == .mysql { + menu.addSubmenu("Tasks", systemImage: "gearshape") { sub in + sub.addActionItem("Back Up", systemImage: "arrow.down.doc") { + sheetState.mysqlBackupDatabaseName = database.name + sheetState.mysqlBackupConnectionID = connID + sheetState.showMySQLBackupSheet = true + } + sub.addActionItem("Restore", systemImage: "arrow.up.doc") { + sheetState.mysqlBackupDatabaseName = database.name + sheetState.mysqlBackupConnectionID = connID + sheetState.showMySQLRestoreSheet = true + } + } + } + + if dbType == .sqlite && database.name.lowercased() != "main" && database.name.lowercased() != "temp" { + menu.addDivider() + menu.addActionItem("Detach Database", systemImage: "externaldrive.badge.minus") { + sheetState.detachDatabaseName = database.name + sheetState.detachConnectionID = connID + sheetState.showDetachSheet = true + } + } + + if dbType == .microsoftSQL { + menu.addSubmenu("Advanced Objects", systemImage: "puzzlepiece.extension") { sub in + sub.addActionItem("Change Tracking", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") { + environmentState.openMSSQLAdvancedObjectsTab(connectionID: connID, databaseName: database.name, section: .changeTracking) + } + sub.addActionItem("Change Data Capture", systemImage: "arrow.triangle.branch") { + environmentState.openMSSQLAdvancedObjectsTab(connectionID: connID, databaseName: database.name, section: .cdc) + } + sub.addActionItem("Full-Text Search", systemImage: "text.magnifyingglass") { + environmentState.openMSSQLAdvancedObjectsTab(connectionID: connID, databaseName: database.name, section: .fullTextSearch) + } + sub.addActionItem("Replication", systemImage: "arrow.triangle.swap") { + environmentState.openMSSQLAdvancedObjectsTab(connectionID: connID, databaseName: database.name, section: .replication) + } + } + + menu.addSubmenu("Tasks", systemImage: "gearshape") { sub in + if database.isOnline { + sub.addActionItem("Back Up", systemImage: "arrow.down.doc") { + environmentState.openMaintenanceBackups(connectionID: connID, databaseName: database.name, action: .backup) + } + sub.addActionItem("Restore", systemImage: "arrow.up.doc") { + environmentState.openMaintenanceBackups(connectionID: connID, databaseName: database.name, action: .restore) + } + sub.addDivider() + sub.addActionItem("Shrink Database", systemImage: "arrow.down.right.and.arrow.up.left") { + Task { await self.runMSSQLTask(session: session, database: database.name, task: .shrink) } + } + sub.addDivider() + sub.addActionItem("Take Offline", systemImage: "bolt.slash") { + Task { await self.runMSSQLTask(session: session, database: database.name, task: .takeOffline) } + } + sub.addDivider() + sub.addActionItem("Detach Database", systemImage: "externaldrive.badge.minus") { + sheetState.detachDatabaseName = database.name + sheetState.detachConnectionID = connID + sheetState.showDetachSheet = true + } + sub.addDivider() + sub.addActionItem("Generate Scripts", systemImage: "scroll") { + sheetState.generateScriptsDatabaseName = database.name + sheetState.generateScriptsConnectionID = connID + sheetState.showGenerateScriptsWizard = true + } + sub.addActionItem("Import Flat File", systemImage: "square.and.arrow.down.on.square") { + sheetState.quickImportDatabaseName = database.name + sheetState.quickImportConnectionID = connID + sheetState.showQuickImportSheet = true + } + sub.addActionItem("Migrate Data", systemImage: "arrow.right.arrow.left") { + sheetState.dataMigrationConnectionID = connID + sheetState.showDataMigrationWizard = true + } + sub.addActionItem("Visual Query Builder", systemImage: "hammer") { + environmentState.openQueryBuilderTab(connectionID: connID) + } + sub.addDivider() + sub.addActionItem("Data-tier Application Tasks", systemImage: "archivebox") { + sheetState.dacWizardDatabaseName = database.name + sheetState.dacWizardConnectionID = connID + sheetState.showDACWizard = true + } + } else { + sub.addActionItem("Bring Online", systemImage: "bolt") { + Task { await self.runMSSQLTask(session: session, database: database.name, task: .bringOnline) } + } + sub.addActionItem("Restore", systemImage: "arrow.up.doc") { + environmentState.openMaintenanceBackups(connectionID: connID, databaseName: database.name, action: .restore) + } + } + } + } + + menu.addDivider() + if dbType == .postgresql { + menu.addSubmenu("Drop Database", systemImage: "trash") { sub in + sub.addActionItem("Drop", systemImage: "trash") { + sheetState.dropDatabaseTarget = .init(sessionID: session.id, connectionID: connID, databaseName: database.name, databaseType: .postgresql, variant: .standard) + sheetState.showDropDatabaseAlert = true + } + sub.addActionItem("Drop (Cascade)", systemImage: "trash") { + sheetState.dropDatabaseTarget = .init(sessionID: session.id, connectionID: connID, databaseName: database.name, databaseType: .postgresql, variant: .cascade) + sheetState.showDropDatabaseAlert = true + } + sub.addActionItem("Drop (Force)", systemImage: "trash") { + sheetState.dropDatabaseTarget = .init(sessionID: session.id, connectionID: connID, databaseName: database.name, databaseType: .postgresql, variant: .force) + sheetState.showDropDatabaseAlert = true + } + } + } else { + menu.addActionItem("Drop Database", systemImage: "trash") { + sheetState.dropDatabaseTarget = .init(sessionID: session.id, connectionID: connID, databaseName: database.name, databaseType: dbType, variant: .standard) + sheetState.showDropDatabaseAlert = true + } + } + + menu.addDivider() + menu.addActionItem("Properties", systemImage: "info.circle") { + let value = environmentState.prepareDatabaseEditorWindow( + connectionSessionID: session.connection.id, + databaseName: database.name, + databaseType: dbType + ) + openWindow(id: DatabaseEditorWindow.sceneID, value: value) + } + + return menu + } + + private func databaseFolderMenu( + kind: ExperimentalObjectBrowserDatabaseFolderKind, + databaseName: String, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + + switch kind { + case .security: + menu.addActionItem("Open Security Management", systemImage: "lock.shield") { + environmentState.openDatabaseSecurityTab(connectionID: session.connection.id, databaseName: databaseName) + } + case .databaseTriggers: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + if let database = session.databaseStructure?.databases.first(where: { $0.name == databaseName }) { + loadDatabaseDDLTriggers(database: database, session: session) + } + } + menu.addActionItem("New Database Trigger", systemImage: "bolt") { + sheetState.newDBDDLTriggerConnectionID = session.connection.id + sheetState.newDBDDLTriggerDatabaseName = databaseName + sheetState.showNewDBDDLTriggerSheet = true + } + case .serviceBroker: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + if let database = session.databaseStructure?.databases.first(where: { $0.name == databaseName }) { + loadServiceBrokerData(database: database, session: session) + } + } + case .externalResources: + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + if let database = session.databaseStructure?.databases.first(where: { $0.name == databaseName }) { + loadExternalResources(database: database, session: session) + } + } + } + + return menu + } + + private func databaseSubfolderMenu( + title: String, + databaseName: String, + session: ConnectionSession + ) -> NSMenu? { + let menu = NSMenu() + switch title { + case "Message Types": + menu.addActionItem("New Message Type", systemImage: "plus") { + sheetState.newMessageTypeConnectionID = session.connection.id + sheetState.newMessageTypeDatabaseName = databaseName + sheetState.showNewMessageTypeSheet = true + } + case "Contracts": + menu.addActionItem("New Contract", systemImage: "plus") { + sheetState.newContractConnectionID = session.connection.id + sheetState.newContractDatabaseName = databaseName + sheetState.showNewContractSheet = true + } + case "Queues": + menu.addActionItem("New Queue", systemImage: "plus") { + sheetState.newQueueConnectionID = session.connection.id + sheetState.newQueueDatabaseName = databaseName + sheetState.showNewQueueSheet = true + } + case "Services": + menu.addActionItem("New Service", systemImage: "plus") { + sheetState.newServiceConnectionID = session.connection.id + sheetState.newServiceDatabaseName = databaseName + sheetState.showNewServiceSheet = true + } + case "Routes": + menu.addActionItem("New Route", systemImage: "plus") { + sheetState.newRouteConnectionID = session.connection.id + sheetState.newRouteDatabaseName = databaseName + sheetState.showNewRouteSheet = true + } + case "External Data Sources": + menu.addActionItem("New External Data Source", systemImage: "plus") { + sheetState.newExternalDataSourceConnectionID = session.connection.id + sheetState.newExternalDataSourceDatabaseName = databaseName + sheetState.showNewExternalDataSourceSheet = true + } + case "External Tables": + menu.addActionItem("New External Table", systemImage: "plus") { + sheetState.newExternalTableConnectionID = session.connection.id + sheetState.newExternalTableDatabaseName = databaseName + sheetState.showNewExternalTableSheet = true + } + case "External File Formats": + menu.addActionItem("New External File Format", systemImage: "plus") { + sheetState.newExternalFileFormatConnectionID = session.connection.id + sheetState.newExternalFileFormatDatabaseName = databaseName + sheetState.showNewExternalFileFormatSheet = true + } + default: + return nil + } + return menu + } + + private func objectGroupMenu( + for type: SchemaObjectInfo.ObjectType, + databaseName: String, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + + menu.addActionItem("Refresh", systemImage: "arrow.clockwise") { + Task { + let handle = AppDirector.shared.activityEngine.begin("Refreshing \(type.pluralDisplayName)", connectionSessionID: session.id) + await environmentState.loadSchemaForDatabase(databaseName, connectionSession: session) + handle.succeed() + } + } + + if let title = experimentalObjectGroupCreationTitle(for: type) { + menu.addDivider() + let hasDesigner = VisualEditorResolver.hasVisualEditor(for: type, databaseType: session.connection.databaseType) + + if hasDesigner { + menu.addActionItem(title, systemImage: experimentalObjectGroupCreationIcon(for: type)) { + openNewObjectInDesigner(type: type, session: session) + } + menu.addActionItem(title + " (SQL)", systemImage: "scroll") { + let schemaName = session.connection.databaseType == .microsoftSQL ? "dbo" : "public" + let sql = experimentalObjectGroupCreationSQL( + for: title, + databaseType: session.connection.databaseType, + schemaName: schemaName + ) + environmentState.openQueryTab(for: session, presetQuery: sql, database: databaseName) + } + } else if type == .extension { + menu.addActionItem(title, systemImage: experimentalObjectGroupCreationIcon(for: type)) { + environmentState.openExtensionsManagerTab(connectionID: session.connection.id, databaseName: databaseName) + } + } else { + menu.addActionItem(title, systemImage: experimentalObjectGroupCreationIcon(for: type)) { + let schemaName = session.connection.databaseType == .microsoftSQL ? "dbo" : "public" + let sql = experimentalObjectGroupCreationSQL( + for: title, + databaseType: session.connection.databaseType, + schemaName: schemaName + ) + environmentState.openQueryTab(for: session, presetQuery: sql, database: databaseName) + } + } + } + + return menu + } + + private func objectMenu( + for object: SchemaObjectInfo, + databaseName: String, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + let databaseType = session.connection.databaseType + + menu.addActionItem("New Query", systemImage: "doc.text") { + environmentState.openQueryTab(for: session, database: databaseName) + } + + if object.type == .extension { + menu.addActionItem("New Extension", systemImage: "puzzlepiece.extension") { + environmentState.openExtensionsManagerTab(connectionID: session.connection.id, databaseName: databaseName) + } + } + + menu.addDivider() + + if object.type == .table || object.type == .view || object.type == .materializedView { + menu.addActionItem("Data", systemImage: "tablecells") { + let sql = previewQuery(for: object, databaseType: databaseType) + environmentState.openQueryTab(for: session, presetQuery: sql, database: databaseName) + } + } + + if object.type == .table || object.type == .extension { + menu.addActionItem("Structure", systemImage: object.type == .extension ? "puzzlepiece.fill" : "square.stack.3d.up") { + environmentState.openStructureTab(for: session, object: object, databaseName: databaseName) + } + } + + if object.type == .table { + menu.addActionItem("Diagram", systemImage: "rectangle.connected.to.line.below") { + environmentState.openDiagramTab(for: session, object: object, activeDatabaseName: databaseName) + } + } + + if [.view, .materializedView, .function, .procedure, .trigger, .sequence, .type].contains(object.type) { + menu.addActionItem("Definition", systemImage: "doc.text") { + openDefinition(for: object, databaseName: databaseName, session: session) + } + } + + if object.type == .function || object.type == .procedure { + menu.addActionItem("Execute", systemImage: "play.circle") { + let sql = executeStatement(for: object, databaseType: databaseType) + environmentState.openQueryTab(for: session, presetQuery: sql, database: databaseName) + } + } + + if VisualEditorResolver.hasVisualEditor(for: object.type, databaseType: databaseType) { + menu.addActionItem("Edit in Designer", systemImage: "rectangle.and.pencil.and.ellipsis") { + openObjectInDesigner(object, session: session) + } + } + + if object.type == .procedure || object.type == .function { + menu.addActionItem("Modify", systemImage: "pencil.and.outline") { + openAlterDefinition(for: object, databaseName: databaseName, session: session) + } + } + + let scriptActions = ScriptActionResolver.actions(for: object.type, databaseType: databaseType) + if !scriptActions.isEmpty { + menu.addDivider() + menu.addSubmenu("Script as", systemImage: "scroll") { submenu in + let readActions = scriptActions.filter(\.isReadGroup) + let createActions = scriptActions.filter(\.isCreateModifyGroup) + let writeActions = scriptActions.filter(\.isWriteGroup) + let executeActions = scriptActions.filter(\.isExecuteGroup) + let destroyActions = scriptActions.filter(\.isDestroyGroup) + + addScriptActions(readActions, to: submenu, object: object, databaseName: databaseName, session: session) + if !readActions.isEmpty && !createActions.isEmpty { submenu.addDivider() } + addScriptActions(createActions, to: submenu, object: object, databaseName: databaseName, session: session) + if !createActions.isEmpty && !writeActions.isEmpty { submenu.addDivider() } + addScriptActions(writeActions, to: submenu, object: object, databaseName: databaseName, session: session) + if !writeActions.isEmpty && !executeActions.isEmpty { submenu.addDivider() } + if writeActions.isEmpty && !createActions.isEmpty && !executeActions.isEmpty { submenu.addDivider() } + addScriptActions(executeActions, to: submenu, object: object, databaseName: databaseName, session: session) + let hasNonDestroy = !executeActions.isEmpty || !writeActions.isEmpty || !createActions.isEmpty || !readActions.isEmpty + if hasNonDestroy && !destroyActions.isEmpty { submenu.addDivider() } + addScriptActions(destroyActions, to: submenu, object: object, databaseName: databaseName, session: session) + } + } + + if databaseType == .microsoftSQL || object.type == .table || object.type == .view { + menu.addDivider() + menu.addSubmenu("Tasks", systemImage: "checklist") { submenu in + if databaseType == .microsoftSQL { + submenu.addActionItem("Generate Scripts", systemImage: "applescript") { + sheetState.generateScriptsDatabaseName = databaseName + sheetState.generateScriptsConnectionID = session.connection.id + sheetState.showGenerateScriptsWizard = true + } + } + if object.type == .table { + submenu.addActionItem("Import Data", systemImage: "square.and.arrow.down") { + sheetState.quickImportDatabaseName = databaseName + sheetState.quickImportConnectionID = session.connection.id + sheetState.showQuickImportSheet = true + } + } + if object.type == .table && databaseType == .microsoftSQL && object.isSystemVersioned != true && object.isHistoryTable != true { + submenu.addActionItem("Enable System Versioning", systemImage: "clock.badge.checkmark") { + sheetState.enableVersioningConnectionID = session.connection.id + sheetState.enableVersioningDatabaseName = databaseName + sheetState.enableVersioningSchemaName = object.schema + sheetState.enableVersioningTableName = object.name + sheetState.showEnableVersioningSheet = true + } + } + } + } + + menu.addDivider() + menu.addActionItem("Drop \(object.type.displayName)", systemImage: "trash") { + let sql = dropStatement(for: object, databaseType: databaseType, includeIfExists: false) + environmentState.openQueryTab(for: session, presetQuery: sql, database: databaseName) + } + + if object.type == .table { + menu.addDivider() + menu.addActionItem("Properties", systemImage: "info.circle") { + let value = environmentState.prepareTablePropertiesWindow( + connectionSessionID: session.connection.id, + schemaName: object.schema, + tableName: object.name, + databaseType: databaseType + ) + openWindow(id: TablePropertiesWindow.sceneID, value: value) + } + } else if VisualEditorResolver.hasVisualEditor(for: object.type, databaseType: databaseType) { + menu.addDivider() + menu.addActionItem("Properties", systemImage: "info.circle") { + openObjectInDesigner(object, session: session) + } + } + + return menu + } + + private func snapshotMenu(snapshot: SQLServerDatabaseSnapshot, session: ConnectionSession) -> NSMenu { + let menu = NSMenu() + menu.addActionItem("Revert to Snapshot", systemImage: "arrow.uturn.backward") { + revertSnapshot(snapshot, session: session) + } + menu.addDivider() + menu.addActionItem("Delete Snapshot", systemImage: "trash") { + deleteSnapshot(snapshot, session: session) + } + return menu + } + + private func linkedServerMenu( + server: ExperimentalObjectBrowserSidebarViewModel.LinkedServerItem, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + menu.addActionItem("Test Connection", systemImage: "bolt.horizontal") { + testLinkedServer(name: server.name, session: session) + } + menu.addDivider() + let dropItem = menu.addActionItem("Drop", systemImage: "trash") { + sheetState.dropLinkedServerTarget = .init( + connectionID: session.connection.id, + serverName: server.name + ) + sheetState.showDropLinkedServerAlert = true + } + dropItem.isEnabled = session.permissions?.canManageLinkedServers ?? true + return menu + } + + private func serverTriggerMenu( + trigger: ExperimentalObjectBrowserSidebarViewModel.ServerTriggerItem, + session: ConnectionSession + ) -> NSMenu { + let menu = NSMenu() + if trigger.isDisabled { + menu.addActionItem("Enable", systemImage: "checkmark.circle") { + setServerTrigger(trigger.name, enabled: true, session: session) + } + } else { + menu.addActionItem("Disable", systemImage: "pause.circle") { + setServerTrigger(trigger.name, enabled: false, session: session) + } + } + menu.addDivider() + menu.addActionItem("Script as CREATE", systemImage: "doc.text") { + scriptServerTrigger(name: trigger.name, session: session) + } + menu.addDivider() + menu.addActionItem("Drop", systemImage: "trash") { + dropServerTrigger(name: trigger.name, session: session) + } + return menu + } + + private func agentJobMenu(for session: ConnectionSession) -> NSMenu { + let menu = NSMenu() + menu.addActionItem("Open in Tab", systemImage: "list.bullet.rectangle") { + environmentState.openJobQueueTab(for: session) + } + menu.addActionItem("Open in New Window", systemImage: "rectangle.portrait.and.arrow.right") { + let sessionID = environmentState.prepareJobQueueWindow(for: session) + openWindow(id: JobQueueWindow.sceneID, value: sessionID) + } + return menu + } + + private func addScriptActions( + _ actions: [ScriptAction], + to menu: NSMenu, + object: SchemaObjectInfo, + databaseName: String, + session: ConnectionSession + ) { + for action in actions { + menu.addActionItem(action.title(for: session.connection.databaseType), systemImage: action.systemImage) { + performScriptAction(action, object: object, databaseName: databaseName, session: session) + } + } + } +} + +private extension ExperimentalObjectBrowserSidebarView { + func openNewObjectInDesigner(type: SchemaObjectInfo.ObjectType, session: ConnectionSession) { + let connID = session.connection.id + let schema = session.connection.databaseType == .microsoftSQL ? "dbo" : "public" + + switch type { + case .view: + let value = environmentState.prepareViewEditorWindow( + connectionSessionID: connID, + schemaName: schema, + existingView: nil, + isMaterialized: false + ) + openWindow(id: ViewEditorWindow.sceneID, value: value) + case .materializedView: + let value = environmentState.prepareViewEditorWindow( + connectionSessionID: connID, + schemaName: schema, + existingView: nil, + isMaterialized: true + ) + openWindow(id: ViewEditorWindow.sceneID, value: value) + case .function: + let value = environmentState.prepareFunctionEditorWindow( + connectionSessionID: connID, + schemaName: schema, + existingFunction: nil + ) + openWindow(id: FunctionEditorWindow.sceneID, value: value) + case .trigger: + let value = environmentState.prepareTriggerEditorWindow( + connectionSessionID: connID, + schemaName: schema, + tableName: "", + existingTrigger: nil + ) + openWindow(id: TriggerEditorWindow.sceneID, value: value) + case .sequence: + let value = environmentState.prepareSequenceEditorWindow( + connectionSessionID: connID, + schemaName: schema, + existingSequence: nil + ) + openWindow(id: SequenceEditorWindow.sceneID, value: value) + case .type: + let value = environmentState.prepareTypeEditorWindow( + connectionSessionID: connID, + schemaName: schema, + existingType: nil, + typeCategory: .composite + ) + openWindow(id: TypeEditorWindow.sceneID, value: value) + default: + break + } + } + + func performScriptAction( + _ action: ScriptAction, + object: SchemaObjectInfo, + databaseName: String, + session: ConnectionSession + ) { + let databaseType = session.connection.databaseType + let qualified = qualifiedName(for: object, databaseType: databaseType) + let sql: String + + switch action { + case .select: + sql = makeSelectStatement( + qualifiedName: qualified, + columnLines: "*", + databaseType: databaseType, + limit: nil + ) + case .selectLimited(let limit): + sql = makeSelectStatement( + qualifiedName: qualified, + columnLines: "*", + databaseType: databaseType, + limit: limit + ) + case .create: + openDefinition(for: object, databaseName: databaseName, session: session, replaceCreateWith: nil) + return + case .createOrReplace: + openDefinition(for: object, databaseName: databaseName, session: session, replaceCreateWith: "CREATE OR REPLACE") + return + case .alter: + openAlterDefinition(for: object, databaseName: databaseName, session: session) + return + case .alterTable: + sql = "ALTER TABLE \(qualified)\n ADD new_column_name data_type;" + case .insert: + sql = "INSERT INTO \(qualified) (column1, column2)\nVALUES (value1, value2);" + case .update: + sql = "UPDATE \(qualified)\nSET column1 = value1\nWHERE condition;" + case .delete: + sql = "DELETE FROM \(qualified)\nWHERE condition;" + case .execute: + sql = executeStatement(for: object, databaseType: databaseType) + case .drop: + sql = dropStatement(for: object, databaseType: databaseType, includeIfExists: false) + case .dropIfExists: + sql = dropStatement(for: object, databaseType: databaseType, includeIfExists: true) + } + + environmentState.openQueryTab(for: session, presetQuery: sql, database: databaseName) + } + + func openDefinition( + for object: SchemaObjectInfo, + databaseName: String, + session: ConnectionSession, + replaceCreateWith replacement: String? = nil + ) { + Task { + do { + var definition = try await session.session.getObjectDefinition( + objectName: object.name, + schemaName: object.schema, + objectType: object.type, + database: databaseName + ) + if let replacement, + let range = definition.range(of: "CREATE", options: .caseInsensitive) { + definition = definition.replacingCharacters(in: range, with: replacement) + } + environmentState.openQueryTab(for: session, presetQuery: definition, database: databaseName) + } catch { + environmentState.lastError = DatabaseError.from(error) + } + } + } + + func openAlterDefinition(for object: SchemaObjectInfo, databaseName: String, session: ConnectionSession) { + Task { + do { + var definition = try await session.session.getObjectDefinition( + objectName: object.name, + schemaName: object.schema, + objectType: object.type, + database: databaseName + ) + if let range = definition.range(of: "CREATE", options: .caseInsensitive) { + definition = definition.replacingCharacters(in: range, with: "ALTER") + } + environmentState.openQueryTab(for: session, presetQuery: definition, database: databaseName) + } catch { + environmentState.lastError = DatabaseError.from(error) + } + } + } + + func openObjectInDesigner(_ object: SchemaObjectInfo, session: ConnectionSession) { + switch object.type { + case .view: + let value = environmentState.prepareViewEditorWindow( + connectionSessionID: session.connection.id, + schemaName: object.schema, + existingView: object.name, + isMaterialized: false + ) + openWindow(id: ViewEditorWindow.sceneID, value: value) + case .materializedView: + let value = environmentState.prepareViewEditorWindow( + connectionSessionID: session.connection.id, + schemaName: object.schema, + existingView: object.name, + isMaterialized: true + ) + openWindow(id: ViewEditorWindow.sceneID, value: value) + case .trigger: + let value = environmentState.prepareTriggerEditorWindow( + connectionSessionID: session.connection.id, + schemaName: object.schema, + tableName: object.triggerTable ?? "", + existingTrigger: object.name + ) + openWindow(id: TriggerEditorWindow.sceneID, value: value) + case .function: + let value = environmentState.prepareFunctionEditorWindow( + connectionSessionID: session.connection.id, + schemaName: object.schema, + existingFunction: object.name + ) + openWindow(id: FunctionEditorWindow.sceneID, value: value) + case .sequence: + let value = environmentState.prepareSequenceEditorWindow( + connectionSessionID: session.connection.id, + schemaName: object.schema, + existingSequence: object.name + ) + openWindow(id: SequenceEditorWindow.sceneID, value: value) + case .type: + let value = environmentState.prepareTypeEditorWindow( + connectionSessionID: session.connection.id, + schemaName: object.schema, + existingType: object.name, + typeCategory: .composite + ) + openWindow(id: TypeEditorWindow.sceneID, value: value) + default: + break + } + } + + func previewQuery(for object: SchemaObjectInfo, databaseType: DatabaseType) -> String { + let qualified = qualifiedName(for: object, databaseType: databaseType) + return switch databaseType { + case .microsoftSQL: + "SELECT TOP 1000 * FROM \(qualified);" + default: + "SELECT * FROM \(qualified) LIMIT 1000;" + } + } + + func executeStatement(for object: SchemaObjectInfo, databaseType: DatabaseType) -> String { + let qualified = qualifiedName(for: object, databaseType: databaseType) + return switch databaseType { + case .microsoftSQL: + "EXEC \(qualified);" + case .postgresql: + "SELECT * FROM \(qualified)();" + case .mysql, .sqlite: + "CALL \(qualified)();" + } + } + + func dropStatement(for object: SchemaObjectInfo, databaseType: DatabaseType, includeIfExists: Bool) -> String { + let qualified = qualifiedName(for: object, databaseType: databaseType) + let keyword: String = switch object.type { + case .table: "TABLE" + case .view: "VIEW" + case .materializedView: "MATERIALIZED VIEW" + case .function: "FUNCTION" + case .procedure: "PROCEDURE" + case .trigger: "TRIGGER" + case .extension: "EXTENSION" + case .sequence: "SEQUENCE" + case .type: "TYPE" + case .synonym: "SYNONYM" + } + let ifExists = includeIfExists ? " IF EXISTS" : "" + return "DROP \(keyword)\(ifExists) \(qualified);" + } + + func qualifiedName(for object: SchemaObjectInfo, databaseType: DatabaseType) -> String { + switch databaseType { + case .microsoftSQL: + "[\(object.schema)].[\(object.name)]" + case .postgresql: + "\"\(object.schema.replacingOccurrences(of: "\"", with: "\"\""))\".\"\(object.name.replacingOccurrences(of: "\"", with: "\"\""))\"" + case .mysql: + "`\(object.schema)`.`\(object.name)`" + case .sqlite: + "\"\(object.name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + } +} + +private func experimentalObjectGroupCreationTitle(for type: SchemaObjectInfo.ObjectType) -> String? { + switch type { + case .table: "New Table" + case .view: "New View" + case .materializedView: "New Materialized View" + case .function: "New Function" + case .procedure: "New Procedure" + case .trigger: "New Trigger" + case .extension: "New Extension" + case .sequence: "New Sequence" + case .type: "New Type" + case .synonym: "New Synonym" + } +} + +private func experimentalObjectGroupCreationIcon(for type: SchemaObjectInfo.ObjectType) -> String { + switch type { + case .table: "tablecells" + case .view: "eye" + case .materializedView: "eye" + case .function: "function" + case .procedure: "gearshape" + case .trigger: "bolt" + case .extension: "puzzlepiece.extension" + case .sequence: "number" + case .type: "t.square" + case .synonym: "arrow.triangle.swap" + } +} + +private func experimentalObjectGroupCreationSQL( + for title: String, + databaseType: DatabaseType, + schemaName: String +) -> String { + switch (title, databaseType) { + case ("New Table", .microsoftSQL): + "CREATE TABLE [\(schemaName)].[NewTable] (\n [Id] INT IDENTITY(1,1) PRIMARY KEY,\n [Name] NVARCHAR(100) NOT NULL\n);\nGO" + case ("New Table", .postgresql): + "CREATE TABLE \(schemaName).new_table (\n id SERIAL PRIMARY KEY,\n name TEXT NOT NULL\n);" + case ("New Table", .mysql): + "CREATE TABLE new_table (\n id INT AUTO_INCREMENT PRIMARY KEY,\n name VARCHAR(100) NOT NULL\n);" + case ("New Table", .sqlite): + "CREATE TABLE new_table (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL\n);" + case ("New View", .microsoftSQL): + "CREATE VIEW [\(schemaName)].[NewView]\nAS\n SELECT * FROM [\(schemaName)].[TableName];\nGO" + case ("New View", .postgresql): + "CREATE VIEW \(schemaName).new_view AS\n SELECT * FROM \(schemaName).table_name;" + case ("New View", _): + "CREATE VIEW new_view AS\n SELECT * FROM table_name;" + case ("New Materialized View", _): + "CREATE MATERIALIZED VIEW \(schemaName).new_materialized_view AS\n SELECT * FROM \(schemaName).table_name;" + case ("New Function", .microsoftSQL): + "CREATE FUNCTION [\(schemaName)].[NewFunction]\n(\n @param1 INT\n)\nRETURNS INT\nAS\nBEGIN\n RETURN @param1;\nEND;\nGO" + case ("New Function", .postgresql): + "CREATE FUNCTION \(schemaName).new_function(param1 INTEGER)\nRETURNS INTEGER\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1;\nEND;\n$$;" + case ("New Function", _): + "CREATE FUNCTION new_function(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1;\nEND;" + case ("New Procedure", .microsoftSQL): + "CREATE PROCEDURE [\(schemaName)].[NewProcedure]\n @param1 INT\nAS\nBEGIN\n SET NOCOUNT ON;\n SELECT @param1;\nEND;\nGO" + case ("New Procedure", _): + "CREATE PROCEDURE \(schemaName).new_procedure(param1 INTEGER)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;" + case ("New Trigger", .microsoftSQL): + "CREATE TRIGGER [\(schemaName)].[NewTrigger]\nON [\(schemaName)].[TableName]\nAFTER INSERT\nAS\nBEGIN\n SET NOCOUNT ON;\n -- trigger body\nEND;\nGO" + case ("New Trigger", .postgresql): + "CREATE TRIGGER new_trigger\n AFTER INSERT ON \(schemaName).table_name\n FOR EACH ROW\n EXECUTE FUNCTION \(schemaName).trigger_function();" + case ("New Trigger", _): + "CREATE TRIGGER new_trigger\n AFTER INSERT ON table_name\n FOR EACH ROW\nBEGIN\n -- trigger body\nEND;" + case ("New Sequence", .microsoftSQL): + "CREATE SEQUENCE [\(schemaName)].[NewSequence]\n AS INT\n START WITH 1\n INCREMENT BY 1;\nGO" + case ("New Sequence", _): + "CREATE SEQUENCE \(schemaName).new_sequence\n START WITH 1\n INCREMENT BY 1;" + case ("New Type", .microsoftSQL): + "CREATE TYPE [\(schemaName)].[NewType] AS TABLE (\n [Id] INT,\n [Name] NVARCHAR(100)\n);\nGO" + case ("New Type", _): + "CREATE TYPE \(schemaName).new_type AS (\n field1 TEXT,\n field2 INTEGER\n);" + case ("New Synonym", .microsoftSQL): + "CREATE SYNONYM [\(schemaName)].[NewSynonym]\n FOR [\(schemaName)].[TargetObject];\nGO" + default: + "-- \(title)" + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+DatabaseSections.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+DatabaseSections.swift new file mode 100644 index 000000000..c9a7e00f8 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+DatabaseSections.swift @@ -0,0 +1,139 @@ +import SwiftUI +import SQLServerKit + +extension ExperimentalObjectBrowserSidebarView { + func loadDatabaseSecurityIfNeeded(database: DatabaseInfo, session: ConnectionSession) { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + let hasData = !(viewModel.dbSecurityUsersByDB[dbKey] ?? []).isEmpty + || !(viewModel.dbSecurityRolesByDB[dbKey] ?? []).isEmpty + || !(viewModel.dbSecurityAppRolesByDB[dbKey] ?? []).isEmpty + || !(viewModel.dbSecuritySchemasByDB[dbKey] ?? []).isEmpty + let isLoading = viewModel.dbSecurityLoadingByDB[dbKey] ?? false + if !hasData && !isLoading { + loadDatabaseSecurity(database: database, session: session) + } + } + + func loadDatabaseSecurity(database: DatabaseInfo, session: ConnectionSession) { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + viewModel.dbSecurityLoadingByDB[dbKey] = true + + Task { + defer { viewModel.dbSecurityLoadingByDB[dbKey] = false } + guard let mssql = session.session as? MSSQLSession else { return } + _ = try? await session.session.sessionForDatabase(database.name) + let security = mssql.security + + do { + let users = try await security.listUsers() + viewModel.dbSecurityUsersByDB[dbKey] = users + .filter { $0.name != "sys" && $0.name != "INFORMATION_SCHEMA" } + .map { .init(id: $0.name, name: $0.name, userType: String(describing: $0.type), defaultSchema: $0.defaultSchema) } + } catch { + viewModel.dbSecurityUsersByDB[dbKey] = [] + } + + do { + let roles = try await security.listRoles() + viewModel.dbSecurityRolesByDB[dbKey] = roles.map { + .init(id: $0.name, name: $0.name, isFixed: $0.isFixedRole, owner: $0.ownerPrincipalId.map(String.init)) + } + } catch { + viewModel.dbSecurityRolesByDB[dbKey] = [] + } + + viewModel.dbSecurityAppRolesByDB[dbKey] = [] + + do { + let schemas = try await security.listSchemas() + let systemSchemas: Set = [ + "sys", "INFORMATION_SCHEMA", "guest", + "db_owner", "db_accessadmin", "db_securityadmin", + "db_ddladmin", "db_backupoperator", "db_datareader", + "db_datawriter", "db_denydatareader", "db_denydatawriter" + ] + viewModel.dbSecuritySchemasByDB[dbKey] = schemas + .filter { !systemSchemas.contains($0.name) } + .map { .init(id: $0.name, name: $0.name, owner: $0.owner) } + } catch { + viewModel.dbSecuritySchemasByDB[dbKey] = [] + } + } + } + + func loadDatabaseDDLTriggers(database: DatabaseInfo, session: ConnectionSession) { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + guard let mssql = session.session as? MSSQLSession else { return } + viewModel.dbDDLTriggersLoadingByDB[dbKey] = true + + Task { + defer { viewModel.dbDDLTriggersLoadingByDB[dbKey] = false } + do { + let triggers = try await mssql.triggers.listDatabaseDDLTriggers(database: database.name) + viewModel.dbDDLTriggersByDB[dbKey] = triggers.map { + .init(id: $0.name, name: $0.name, isDisabled: $0.isDisabled, events: $0.events) + } + } catch { + viewModel.dbDDLTriggersByDB[dbKey] = [] + } + } + } + + func loadServiceBrokerData(database: DatabaseInfo, session: ConnectionSession) { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + guard let mssql = session.session as? MSSQLSession else { return } + viewModel.serviceBrokerLoadingByDB[dbKey] = true + + Task { + defer { viewModel.serviceBrokerLoadingByDB[dbKey] = false } + do { + let broker = mssql.serviceBroker + let dbName = database.name + let messageTypes = try await broker.listMessageTypes(database: dbName) + let contracts = try await broker.listContracts(database: dbName) + let queues = try await broker.listQueues(database: dbName) + let services = try await broker.listServices(database: dbName) + let routes = try await broker.listRoutes(database: dbName) + let bindings = try await broker.listRemoteServiceBindings(database: dbName) + + viewModel.serviceBrokerMessageTypesByDB[dbKey] = messageTypes.filter { !$0.isSystemObject }.map(\.name) + viewModel.serviceBrokerContractsByDB[dbKey] = contracts.filter { !$0.isSystemObject }.map(\.name) + viewModel.serviceBrokerQueuesByDB[dbKey] = queues.map { "\($0.schema).\($0.name)" } + viewModel.serviceBrokerServicesByDB[dbKey] = services.filter { !$0.isSystemObject }.map(\.name) + viewModel.serviceBrokerRoutesByDB[dbKey] = routes.map(\.name) + viewModel.serviceBrokerBindingsByDB[dbKey] = bindings.map(\.name) + } catch { + viewModel.serviceBrokerMessageTypesByDB[dbKey] = [] + viewModel.serviceBrokerContractsByDB[dbKey] = [] + viewModel.serviceBrokerQueuesByDB[dbKey] = [] + viewModel.serviceBrokerServicesByDB[dbKey] = [] + viewModel.serviceBrokerRoutesByDB[dbKey] = [] + viewModel.serviceBrokerBindingsByDB[dbKey] = [] + } + } + } + + func loadExternalResources(database: DatabaseInfo, session: ConnectionSession) { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + guard let mssql = session.session as? MSSQLSession else { return } + viewModel.externalResourcesLoadingByDB[dbKey] = true + + Task { + defer { viewModel.externalResourcesLoadingByDB[dbKey] = false } + do { + let polyBase = mssql.polyBase + let dbName = database.name + let sources = try await polyBase.listExternalDataSources(database: dbName) + let tables = try await polyBase.listExternalTables(database: dbName) + let formats = try await polyBase.listExternalFileFormats(database: dbName) + viewModel.externalDataSourcesByDB[dbKey] = sources.map(\.name) + viewModel.externalTablesByDB[dbKey] = tables.map { "\($0.schema).\($0.name)" } + viewModel.externalFileFormatsByDB[dbKey] = formats.map(\.name) + } catch { + viewModel.externalDataSourcesByDB[dbKey] = [] + viewModel.externalTablesByDB[dbKey] = [] + viewModel.externalFileFormatsByDB[dbKey] = [] + } + } + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Focus.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Focus.swift new file mode 100644 index 000000000..ea2163eb9 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Focus.swift @@ -0,0 +1,121 @@ +import EchoSense +import SwiftUI + +extension ExperimentalObjectBrowserSidebarView { + func handleExplorerFocus(_ focus: ExplorerFocus) { + Task { + await processExplorerFocus(focus) + } + } + + private func processExplorerFocus(_ focus: ExplorerFocus) async { + guard let session = await MainActor.run(body: { + environmentState.sessionGroup.sessionForConnection(focus.connectionID) + }) else { + await MainActor.run { + navigationStore.pendingExplorerFocus = nil + } + return + } + + await MainActor.run { + selectedConnectionID = focus.connectionID + environmentState.sessionGroup.setActiveSession(session.id) + viewModel.setExpanded(true, nodeID: ExperimentalObjectBrowserSidebarViewModel.serverNodeID(connectionID: focus.connectionID)) + viewModel.setExpanded(true, nodeID: ExperimentalObjectBrowserSidebarViewModel.databasesFolderNodeID(connectionID: focus.connectionID)) + viewModel.setExpanded(true, nodeID: ExperimentalObjectBrowserSidebarViewModel.databaseNodeID(connectionID: focus.connectionID, databaseName: focus.databaseName)) + let groupNodeID = ExperimentalObjectBrowserSidebarViewModel.objectGroupNodeID( + connectionID: focus.connectionID, + databaseName: focus.databaseName, + objectType: focus.objectType + ) + viewModel.setExpanded(true, nodeID: groupNodeID) + } + + let alreadyCached = await MainActor.run { + hasCachedSchema( + session: session, + databaseName: focus.databaseName, + schemaName: focus.schemaName, + objectName: focus.objectName, + objectType: focus.objectType + ) + } + + if !alreadyCached { + if session.sidebarFocusedDatabase?.localizedCaseInsensitiveCompare(focus.databaseName) != .orderedSame { + await environmentState.reconnectSession(session, to: focus.databaseName) + } + await environmentState.refreshDatabaseStructure( + for: session.id, + scope: .selectedDatabase, + databaseOverride: focus.databaseName + ) + } + + guard let refreshedSession = await MainActor.run(body: { + environmentState.sessionGroup.sessionForConnection(focus.connectionID) + }) else { + await MainActor.run { + navigationStore.pendingExplorerFocus = nil + } + return + } + + await MainActor.run { + applyExplorerFocus(focus, session: refreshedSession) + navigationStore.pendingExplorerFocus = nil + } + } + + private func hasCachedSchema( + session: ConnectionSession, + databaseName: String, + schemaName: String, + objectName: String, + objectType: SchemaObjectInfo.ObjectType + ) -> Bool { + let structure = session.databaseStructure + guard let structure, + let database = structure.databases.first(where: { + $0.name.localizedCaseInsensitiveCompare(databaseName) == .orderedSame + }), + let schema = database.schemas.first(where: { + $0.name.localizedCaseInsensitiveCompare(schemaName) == .orderedSame + }) else { + return false + } + + return schema.objects.contains(where: { + $0.type == objectType && $0.name.localizedCaseInsensitiveCompare(objectName) == .orderedSame + }) + } + + private func applyExplorerFocus(_ focus: ExplorerFocus, session: ConnectionSession) { + guard let structure = session.databaseStructure, + let database = structure.databases.first(where: { $0.name.localizedCaseInsensitiveCompare(focus.databaseName) == .orderedSame }), + let schema = database.schemas.first(where: { $0.name.localizedCaseInsensitiveCompare(focus.schemaName) == .orderedSame }), + let object = schema.objects.first(where: { $0.type == focus.objectType && $0.name.localizedCaseInsensitiveCompare(focus.objectName) == .orderedSame }) else { + return + } + + let objectGroupID = ExperimentalObjectBrowserSidebarViewModel.objectGroupNodeID( + connectionID: focus.connectionID, + databaseName: database.name, + objectType: object.type + ) + let objectNodeID = ExplorerSidebarIdentity.object( + connectionID: focus.connectionID, + databaseName: database.name, + objectID: object.id + ) + + session.sidebarFocusedDatabase = database.name + viewModel.selectedNodeID = objectNodeID + viewModel.setExpanded(true, nodeID: ExperimentalObjectBrowserSidebarViewModel.serverNodeID(connectionID: focus.connectionID)) + viewModel.setExpanded(true, nodeID: ExperimentalObjectBrowserSidebarViewModel.databasesFolderNodeID(connectionID: focus.connectionID)) + viewModel.setExpanded(true, nodeID: ExperimentalObjectBrowserSidebarViewModel.databaseNodeID(connectionID: focus.connectionID, databaseName: database.name)) + viewModel.setExpanded(true, nodeID: objectGroupID) + viewModel.revealAndPulse(nodeID: objectNodeID) + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Overlays.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Overlays.swift new file mode 100644 index 000000000..53ae92e72 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Overlays.swift @@ -0,0 +1,417 @@ +import SwiftUI +import SQLServerKit + +extension ExperimentalObjectBrowserSidebarView { + func applySheets(to content: V) -> some View { + content + .sheet(isPresented: $sheetState.showNewJobSheet) { + if let connID = sheetState.newJobSessionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + NewAgentJobSheet(session: session, environmentState: environmentState) { + sheetState.showNewJobSheet = false + loadAgentJobs(session: session) + } + } + } + .sheet(isPresented: $sheetState.showNewDatabaseSheet) { + if let connID = sheetState.newDatabaseConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + NewDatabaseSheet( + session: session, + environmentState: environmentState, + onDismiss: { sheetState.showNewDatabaseSheet = false } + ) + } + } + .sheet(isPresented: $sheetState.showNewServerRoleSheet) { + if let connID = sheetState.newSecuritySheetSessionID, + let session = environmentState.sessionGroup.activeSessions.first(where: { $0.id == connID }) { + NewServerRoleSheet(session: session) { + sheetState.showNewServerRoleSheet = false + loadServerSecurity(session: session) + } + } + } + .sheet(isPresented: $sheetState.showNewCredentialSheet) { + if let connID = sheetState.newSecuritySheetSessionID, + let session = environmentState.sessionGroup.activeSessions.first(where: { $0.id == connID }) { + NewCredentialSheet(session: session) { + sheetState.showNewCredentialSheet = false + loadServerSecurity(session: session) + } + } + } + .sheet(isPresented: $sheetState.showSecurityPGRoleSheet) { + if let connID = sheetState.securityPGRoleSheetSessionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + SecurityPGRoleSheet( + session: session, + environmentState: environmentState, + existingRoleName: sheetState.securityPGRoleSheetEditName + ) { + sheetState.showSecurityPGRoleSheet = false + loadServerSecurity(session: session) + } + } + } + .sheet(isPresented: $sheetState.showPgBackupSheet) { + if let dbName = sheetState.pgBackupDatabaseName, + let connID = sheetState.pgBackupConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + PgBackupSheetContainer( + connection: session.connection, + session: session.session, + databaseName: dbName, + isPresented: $sheetState.showPgBackupSheet + ) + } + } + .sheet(isPresented: $sheetState.showPgRestoreSheet) { + if let dbName = sheetState.pgBackupDatabaseName, + let connID = sheetState.pgBackupConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + PgRestoreSheetContainer( + connection: session.connection, + session: session.session, + databaseName: dbName, + connectionSession: session, + isPresented: $sheetState.showPgRestoreSheet + ) + } + } + .sheet(isPresented: $sheetState.showMySQLBackupSheet) { + if let dbName = sheetState.mysqlBackupDatabaseName, + let connID = sheetState.mysqlBackupConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + MySQLBackupSheetContainer( + connection: session.connection, + session: session.session, + databaseName: dbName, + isPresented: $sheetState.showMySQLBackupSheet + ) + } + } + .sheet(isPresented: $sheetState.showMySQLRestoreSheet) { + if let dbName = sheetState.mysqlBackupDatabaseName, + let connID = sheetState.mysqlBackupConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + MySQLRestoreSheetContainer( + connection: session.connection, + session: session.session, + databaseName: dbName, + connectionSession: session, + isPresented: $sheetState.showMySQLRestoreSheet + ) + } + } + .sheet(isPresented: $sheetState.showNewLinkedServerSheet) { + if let connID = sheetState.newLinkedServerSessionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + NewLinkedServerSheet( + session: session, + environmentState: environmentState + ) { + sheetState.showNewLinkedServerSheet = false + loadLinkedServers(session: session) + } + } + } + .sheet(isPresented: $sheetState.showCMSSheet) { + if let connID = sheetState.cmsConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + CMSSheet(session: session, onDismiss: { sheetState.showCMSSheet = false }) + } + } + .sheet(isPresented: $sheetState.showDetachSheet) { + if let dbName = sheetState.detachDatabaseName, + let connID = sheetState.detachConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + if session.connection.databaseType == .sqlite { + SQLiteDetachDatabaseSheet( + databaseName: dbName, + session: session, + environmentState: environmentState, + onDismiss: { sheetState.showDetachSheet = false } + ) + } else { + DetachDatabaseSheet( + databaseName: dbName, + session: session, + environmentState: environmentState, + onDismiss: { sheetState.showDetachSheet = false } + ) + } + } + } + .sheet(isPresented: $sheetState.showAttachSheet) { + if let connID = sheetState.attachConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + if session.connection.databaseType == .sqlite { + SQLiteAttachDatabaseSheet( + session: session, + environmentState: environmentState, + onDismiss: { sheetState.showAttachSheet = false } + ) + } else { + AttachDatabaseSheet( + session: session, + environmentState: environmentState, + onDismiss: { sheetState.showAttachSheet = false } + ) + } + } + } + .sheet(isPresented: $sheetState.showCreateSnapshotSheet) { + if let connID = sheetState.createSnapshotConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + CreateSnapshotSheet( + session: session, + environmentState: environmentState, + onDismiss: { + sheetState.showCreateSnapshotSheet = false + loadDatabaseSnapshots(session: session) + } + ) + } + } + .sheet(isPresented: $sheetState.showNewServerTriggerSheet) { + if let connID = sheetState.newServerTriggerConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + NewServerTriggerSheet(session: session, environmentState: environmentState) { + sheetState.showNewServerTriggerSheet = false + loadServerTriggers(session: session) + } + } + } + .sheet(isPresented: $sheetState.showNewDBDDLTriggerSheet) { + if let connID = sheetState.newDBDDLTriggerConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newDBDDLTriggerDatabaseName { + NewDatabaseDDLTriggerSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewDBDDLTriggerSheet = false + } + } + } + .sheet(isPresented: $sheetState.showNewMessageTypeSheet) { + if let connID = sheetState.newMessageTypeConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newMessageTypeDatabaseName { + NewMessageTypeSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewMessageTypeSheet = false + } + } + } + .sheet(isPresented: $sheetState.showNewContractSheet) { + if let connID = sheetState.newContractConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newContractDatabaseName { + NewContractSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewContractSheet = false + } + } + } + .sheet(isPresented: $sheetState.showNewQueueSheet) { + if let connID = sheetState.newQueueConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newQueueDatabaseName { + NewQueueSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewQueueSheet = false + } + } + } + .sheet(isPresented: $sheetState.showNewServiceSheet) { + if let connID = sheetState.newServiceConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newServiceDatabaseName { + NewServiceSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewServiceSheet = false + } + } + } + .sheet(isPresented: $sheetState.showNewRouteSheet) { + if let connID = sheetState.newRouteConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newRouteDatabaseName { + NewRouteSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewRouteSheet = false + } + } + } + .sheet(isPresented: $sheetState.showNewExternalDataSourceSheet) { + if let connID = sheetState.newExternalDataSourceConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newExternalDataSourceDatabaseName { + NewExternalDataSourceSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewExternalDataSourceSheet = false + } + } + } + .sheet(isPresented: $sheetState.showNewExternalFileFormatSheet) { + if let connID = sheetState.newExternalFileFormatConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newExternalFileFormatDatabaseName { + NewExternalFileFormatSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewExternalFileFormatSheet = false + } + } + } + .sheet(isPresented: $sheetState.showNewExternalTableSheet) { + if let connID = sheetState.newExternalTableConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.newExternalTableDatabaseName { + NewExternalTableSheet(databaseName: dbName, session: session, environmentState: environmentState) { + sheetState.showNewExternalTableSheet = false + } + } + } + .sheet(isPresented: $sheetState.showGenerateScriptsWizard) { + if let connID = sheetState.generateScriptsConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let dbName = sheetState.generateScriptsDatabaseName { + let viewModel = GenerateScriptsWizardViewModel( + session: session.session, + databaseName: dbName, + databaseType: session.connection.databaseType + ) + GenerateScriptsWizardView(viewModel: viewModel) + .onAppear { + viewModel.onOpenInQueryTab = { script in + environmentState.openQueryTab(for: session, presetQuery: script, database: dbName) + } + } + } + } + .sheet(isPresented: $sheetState.showQuickImportSheet) { + if let connID = sheetState.quickImportConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + QuickImportSheet(viewModel: QuickImportViewModel(session: session.session)) + } + } + .sheet(isPresented: $sheetState.showDACWizard) { + if let connID = sheetState.dacWizardConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID) { + DACWizardView( + viewModel: DACWizardViewModel( + session: session.session, + databaseName: sheetState.dacWizardDatabaseName ?? "" + ) + ) + } + } + .sheet(isPresented: $sheetState.showEnableVersioningSheet) { + if let connID = sheetState.enableVersioningConnectionID, + let session = environmentState.sessionGroup.sessionForConnection(connID), + let schema = sheetState.enableVersioningSchemaName, + let table = sheetState.enableVersioningTableName { + EnableSystemVersioningSheet( + tableName: table, + schemaName: schema, + session: session, + environmentState: environmentState + ) { + sheetState.showEnableVersioningSheet = false + } + } + } + .sheet(isPresented: $sheetState.showDataMigrationWizard) { + let viewModel = DataMigrationWizardViewModel() + let sessions = environmentState.sessionGroup.activeSessions + DataMigrationWizardView(viewModel: viewModel) + .onAppear { + viewModel.availableSessions = sessions + if let connID = sheetState.dataMigrationConnectionID, + let session = sessions.first(where: { $0.connection.id == connID }) { + viewModel.sourceSessionID = session.id + viewModel.loadSourceDatabases() + } + viewModel.onOpenInQueryTab = { [weak environmentState] script in + let targetSession = sessions.first(where: { $0.id == viewModel.targetSessionID }) + environmentState?.openQueryTab( + for: targetSession, + presetQuery: script, + database: viewModel.targetDatabaseName + ) + } + } + } + } + + func applyAlerts(to content: V) -> some View { + content + .alert( + "Drop \"\(sheetState.dropDatabaseTarget?.databaseName ?? "")\"?", + isPresented: $sheetState.showDropDatabaseAlert + ) { + Button("Cancel", role: .cancel) { + sheetState.dropDatabaseTarget = nil + } + Button("Drop", role: .destructive) { + guard let target = sheetState.dropDatabaseTarget else { return } + sheetState.dropDatabaseTarget = nil + guard let session = environmentState.sessionGroup.sessionForConnection(target.connectionID) else { return } + Task { + switch target.databaseType { + case .postgresql: + await dropPostgresDatabase( + session: session, + name: target.databaseName, + cascade: target.variant == .cascade, + force: target.variant == .force + ) + default: + await runMSSQLTask(session: session, database: target.databaseName, task: .drop) + } + } + } + } message: { + if let target = sheetState.dropDatabaseTarget { + switch target.variant { + case .cascade: + Text("This will drop the database and all dependent objects. This action cannot be undone.") + case .force: + Text("This will forcefully terminate all connections and drop the database. This action cannot be undone.") + case .standard: + Text("This will permanently delete the database \"\(target.databaseName)\". This action cannot be undone.") + } + } + } + .alert( + "Delete linked server \"\(sheetState.dropLinkedServerTarget?.serverName ?? "")\"?", + isPresented: $sheetState.showDropLinkedServerAlert + ) { + Button("Cancel", role: .cancel) { + sheetState.dropLinkedServerTarget = nil + } + Button("Delete", role: .destructive) { + guard let target = sheetState.dropLinkedServerTarget else { return } + sheetState.dropLinkedServerTarget = nil + guard let session = environmentState.sessionGroup.sessionForConnection(target.connectionID) else { return } + Task { + await executeDropLinkedServer(target, session: session) + } + } + } message: { + Text("This will permanently remove the linked server and all its login mappings. This action cannot be undone.") + } + .alert( + "Drop \(sheetState.dropSecurityPrincipalTarget?.kind.rawValue ?? "") \"\(sheetState.dropSecurityPrincipalTarget?.name ?? "")\"?", + isPresented: $sheetState.showDropSecurityPrincipalAlert + ) { + Button("Cancel", role: .cancel) { + sheetState.dropSecurityPrincipalTarget = nil + } + Button("Drop", role: .destructive) { + guard let target = sheetState.dropSecurityPrincipalTarget else { return } + sheetState.dropSecurityPrincipalTarget = nil + guard let session = environmentState.sessionGroup.sessionForConnection(target.connectionID) else { return } + Task { + await executeDropSecurityPrincipal(target, session: session) + } + } + } message: { + if let target = sheetState.dropSecurityPrincipalTarget { + Text("This will permanently drop the \(target.kind.rawValue.lowercased()) \"\(target.name)\". This action cannot be undone.") + } + } + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Security.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Security.swift new file mode 100644 index 000000000..8bf8a9877 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+Security.swift @@ -0,0 +1,203 @@ +import SwiftUI +import PostgresKit +import SQLServerKit + +extension ExperimentalObjectBrowserSidebarView { + func loadServerSecurityIfNeeded(session: ConnectionSession) { + let connID = session.connection.id + let hasData = !(viewModel.securityLoginsBySession[connID] ?? []).isEmpty + || !(viewModel.securityServerRolesBySession[connID] ?? []).isEmpty + || !(viewModel.securityCredentialsBySession[connID] ?? []).isEmpty + let isLoading = viewModel.securityServerLoadingBySession[connID] ?? false + if !hasData && !isLoading { + loadServerSecurity(session: session) + } + } + + func loadServerSecurity(session: ConnectionSession) { + Task { + await loadServerSecurityAsync(session: session) + } + } + + func loadServerSecurityAsync(session: ConnectionSession) async { + let connID = session.connection.id + viewModel.securityServerLoadingBySession[connID] = true + defer { viewModel.securityServerLoadingBySession[connID] = false } + + switch session.connection.databaseType { + case .microsoftSQL: + await loadMSSQLServerSecurity(session: session, connID: connID) + case .postgresql: + await loadPostgresServerSecurity(session: session, connID: connID) + case .mysql, .sqlite: + break + } + } + + func loadMSSQLServerSecurity(session: ConnectionSession, connID: UUID) async { + guard let mssql = session.session as? MSSQLSession else { return } + let security = mssql.serverSecurity + + do { + let logins = try await security.listLogins(includeSystemLogins: false) + viewModel.securityLoginsBySession[connID] = logins.map { + .init( + id: $0.name, + name: $0.name, + loginType: loginTypeDisplayName($0.type), + isDisabled: $0.isDisabled + ) + } + } catch { + viewModel.securityLoginsBySession[connID] = [] + } + + do { + let roles = try await security.listServerRoles() + viewModel.securityServerRolesBySession[connID] = roles.map { + .init(id: $0.name, name: $0.name, isFixed: $0.isFixed) + } + } catch { + viewModel.securityServerRolesBySession[connID] = [] + } + + do { + let credentials = try await security.listCredentials() + viewModel.securityCredentialsBySession[connID] = credentials.map { + .init(id: $0.name, name: $0.name, identity: $0.identity ?? "") + } + } catch { + viewModel.securityCredentialsBySession[connID] = [] + } + } + + func loadPostgresServerSecurity(session: ConnectionSession, connID: UUID) async { + guard let pg = session.session as? PostgresSession else { return } + + do { + let roles = try await pg.client.security.listRoles() + viewModel.securityLoginsBySession[connID] = roles.map { role in + let typeDescription: String + if role.isSuperuser { + typeDescription = "Superuser" + } else if role.canLogin { + typeDescription = "Login Role" + } else { + typeDescription = "Group Role" + } + return .init( + id: role.name, + name: role.name, + loginType: typeDescription, + isDisabled: false + ) + } + } catch { + viewModel.securityLoginsBySession[connID] = [] + } + } + + func createMSSQLServerRole(session: ConnectionSession) { + sheetState.newSecuritySheetSessionID = session.id + sheetState.showNewServerRoleSheet = true + } + + func createMSSQLCredential(session: ConnectionSession) { + sheetState.newSecuritySheetSessionID = session.id + sheetState.showNewCredentialSheet = true + } + + func dropMSSQLLogin(name: String, session: ConnectionSession) async { + guard let mssql = session.session as? MSSQLSession else { return } + do { + try await mssql.serverSecurity.dropLogin(name: name) + loadServerSecurity(session: session) + environmentState.notificationEngine?.post(category: .securityDropped, message: "Login '\(name)' dropped") + } catch { + environmentState.notificationEngine?.post(category: .generalError, message: "Drop failed: \(readableErrorMessage(error))") + } + } + + func dropMSSQLServerRole(name: String, session: ConnectionSession) async { + guard let mssql = session.session as? MSSQLSession else { return } + do { + try await mssql.serverSecurity.dropServerRole(name: name) + loadServerSecurity(session: session) + environmentState.notificationEngine?.post(category: .securityDropped, message: "Server role '\(name)' dropped") + } catch { + environmentState.notificationEngine?.post(category: .generalError, message: "Drop failed: \(readableErrorMessage(error))") + } + } + + func enableMSSQLLogin(name: String, enabled: Bool, session: ConnectionSession) async { + guard let mssql = session.session as? MSSQLSession else { return } + do { + try await mssql.serverSecurity.enableLogin(name: name, enabled: enabled) + loadServerSecurity(session: session) + } catch { + environmentState.notificationEngine?.post( + category: .securityToggleFailed, + message: "Failed to \(enabled ? "enable" : "disable") login: \(readableErrorMessage(error))" + ) + } + } + + func dropPGRole(name: String, session: ConnectionSession) async { + guard let pg = session.session as? PostgresSession else { return } + do { + try await pg.client.security.dropUser(name: name) + loadServerSecurity(session: session) + environmentState.notificationEngine?.post(category: .securityDropped, message: "Role '\(name)' dropped") + } catch { + environmentState.notificationEngine?.post(category: .generalError, message: "Drop failed: \(readableErrorMessage(error))") + } + } + + func reassignPGRole(name: String, session: ConnectionSession) async { + let sql = """ + -- Reassign all objects owned by "\(name)" to another role. + -- Replace "target_role" with the role to receive the objects. + REASSIGN OWNED BY "\(name)" TO "target_role"; + """ + openScriptTab(sql: sql, session: session) + } + + func executeDropSecurityPrincipal( + _ target: SidebarSheetState.DropSecurityPrincipalTarget, + session: ConnectionSession + ) async { + switch target.kind { + case .pgRole: + await dropPGRole(name: target.name, session: session) + case .mssqlLogin: + await dropMSSQLLogin(name: target.name, session: session) + case .mssqlServerRole: + await dropMSSQLServerRole(name: target.name, session: session) + case .mssqlUser: + break + } + } + + func openScriptTab(sql: String, session: ConnectionSession) { + environmentState.openQueryTab(for: session, presetQuery: sql) + } + + func readableErrorMessage(_ error: Error) -> String { + if let pgError = error as? PostgresKit.PostgresError { + return pgError.message + } + return error.localizedDescription + } + + func loginTypeDisplayName(_ type: ServerLoginType) -> String { + switch type { + case .sql: "SQL" + case .windowsUser: "Windows" + case .windowsGroup: "Windows Group" + case .certificate: "Certificate" + case .asymmetricKey: "Asymmetric Key" + case .external: "External" + } + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+ServerData.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+ServerData.swift new file mode 100644 index 000000000..e763ee22e --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView+ServerData.swift @@ -0,0 +1,312 @@ +import SwiftUI +import PostgresKit +import SQLServerKit + +extension ExperimentalObjectBrowserSidebarView { + enum ExperimentalMSSQLDatabaseTask { + case shrink + case takeOffline + case bringOnline + case drop + } + + func loadAgentJobs(session: ConnectionSession) { + let connID = session.connection.id + guard let mssql = session.session as? MSSQLSession else { return } + viewModel.agentJobsLoadingBySession[connID] = true + + Task { + defer { viewModel.agentJobsLoadingBySession[connID] = false } + + do { + let detailed = try await mssql.agent.listJobDetails() + viewModel.agentJobsBySession[connID] = detailed.map { job in + .init( + id: job.jobId, + name: job.name, + enabled: job.enabled, + lastOutcome: job.lastRunOutcome + ) + } + } catch { + do { + let basic = try await mssql.agent.listJobs() + viewModel.agentJobsBySession[connID] = basic.map { job in + .init( + id: job.name, + name: job.name, + enabled: job.enabled, + lastOutcome: job.lastRunOutcome + ) + } + } catch { + viewModel.agentJobsBySession[connID] = [] + } + } + } + } + + func loadLinkedServers(session: ConnectionSession) { + let connID = session.connection.id + guard let mssql = session.session as? MSSQLSession else { return } + viewModel.linkedServersLoadingBySession[connID] = true + + Task { + defer { viewModel.linkedServersLoadingBySession[connID] = false } + + do { + let servers = try await mssql.linkedServers.list() + viewModel.linkedServersBySession[connID] = servers.map { server in + .init( + id: server.name, + name: server.name, + provider: server.provider, + dataSource: server.dataSource, + product: server.product, + isDataAccessEnabled: server.isDataAccessEnabled + ) + } + } catch { + viewModel.linkedServersBySession[connID] = [] + } + } + } + + func testLinkedServer(name: String, session: ConnectionSession) { + guard let mssql = session.session as? MSSQLSession else { return } + + Task { + do { + let success = try await mssql.linkedServers.test(name: name) + environmentState.toastPresenter.show( + icon: success ? "checkmark.circle" : "xmark.circle", + message: success ? "Connection to \"\(name)\" succeeded." : "Connection to \"\(name)\" failed.", + style: success ? .success : .error + ) + } catch { + environmentState.toastPresenter.show( + icon: "xmark.circle", + message: "Connection test failed: \(error.localizedDescription)", + style: .error + ) + } + } + } + + func executeDropLinkedServer(_ target: SidebarSheetState.DropLinkedServerTarget, session: ConnectionSession) async { + guard let mssql = session.session as? MSSQLSession else { return } + do { + try await mssql.linkedServers.drop(name: target.serverName, dropLogins: true) + loadLinkedServers(session: session) + } catch { + environmentState.toastPresenter.show( + icon: "xmark.circle", + message: "Failed to drop linked server: \(error.localizedDescription)", + style: .error + ) + } + } + + func loadSSISFoldersAsync(session: ConnectionSession) async { + let connID = session.connection.id + guard let mssql = session.session as? MSSQLSession else { return } + + viewModel.ssisLoadingBySession[connID] = true + defer { viewModel.ssisLoadingBySession[connID] = false } + + do { + if try await mssql.ssis.isSSISCatalogAvailable() { + viewModel.ssisFoldersBySession[connID] = try await mssql.ssis.listFolders() + } else { + viewModel.ssisFoldersBySession[connID] = [] + } + } catch { + viewModel.ssisFoldersBySession[connID] = [] + } + } + + func loadDatabaseSnapshots(session: ConnectionSession) { + let connID = session.connection.id + viewModel.databaseSnapshotsLoadingBySession[connID] = true + + Task { + defer { viewModel.databaseSnapshotsLoadingBySession[connID] = false } + + do { + viewModel.databaseSnapshotsBySession[connID] = try await session.session.listDatabaseSnapshots() + } catch { + viewModel.databaseSnapshotsBySession[connID] = [] + } + } + } + + func revertSnapshot(_ snapshot: SQLServerDatabaseSnapshot, session: ConnectionSession) { + Task { + let handle = AppDirector.shared.activityEngine.begin( + "Revert \(snapshot.sourceDatabaseName) to snapshot \(snapshot.name)", + connectionSessionID: session.id + ) + do { + try await session.session.revertToSnapshot(snapshotName: snapshot.name) + handle.succeed() + environmentState.notificationEngine?.post( + category: .maintenanceCompleted, + message: "Reverted \(snapshot.sourceDatabaseName) to snapshot \(snapshot.name)." + ) + } catch { + handle.fail(error.localizedDescription) + environmentState.notificationEngine?.post( + category: .maintenanceFailed, + message: "Revert failed: \(error.localizedDescription)" + ) + } + } + } + + func deleteSnapshot(_ snapshot: SQLServerDatabaseSnapshot, session: ConnectionSession) { + Task { + let handle = AppDirector.shared.activityEngine.begin( + "Delete snapshot \(snapshot.name)", + connectionSessionID: session.id + ) + do { + try await session.session.deleteDatabaseSnapshot(name: snapshot.name) + handle.succeed() + environmentState.notificationEngine?.post( + category: .maintenanceCompleted, + message: "Snapshot \(snapshot.name) deleted." + ) + loadDatabaseSnapshots(session: session) + } catch { + handle.fail(error.localizedDescription) + environmentState.notificationEngine?.post( + category: .maintenanceFailed, + message: "Delete snapshot failed: \(error.localizedDescription)" + ) + } + } + } + + func loadServerTriggers(session: ConnectionSession) { + let connID = session.connection.id + guard let mssql = session.session as? MSSQLSession else { return } + viewModel.serverTriggersLoadingBySession[connID] = true + + Task { + defer { viewModel.serverTriggersLoadingBySession[connID] = false } + + do { + let triggers = try await mssql.triggers.listServerTriggers() + viewModel.serverTriggersBySession[connID] = triggers.map { trigger in + .init( + id: trigger.name, + name: trigger.name, + isDisabled: trigger.isDisabled, + typeDescription: trigger.typeDescription, + events: trigger.events + ) + } + } catch { + viewModel.serverTriggersBySession[connID] = [] + } + } + } + + func setServerTrigger(_ name: String, enabled: Bool, session: ConnectionSession) { + guard let mssql = session.session as? MSSQLSession else { return } + Task { + do { + if enabled { + try await mssql.triggers.enableServerTrigger(name: name) + } else { + try await mssql.triggers.disableServerTrigger(name: name) + } + loadServerTriggers(session: session) + } catch { + environmentState.toastPresenter.show( + icon: "xmark.circle", + message: "Failed to \(enabled ? "enable" : "disable") trigger: \(error.localizedDescription)", + style: .error + ) + } + } + } + + func dropServerTrigger(name: String, session: ConnectionSession) { + guard let mssql = session.session as? MSSQLSession else { return } + Task { + do { + try await mssql.triggers.dropServerTrigger(name: name) + loadServerTriggers(session: session) + } catch { + environmentState.toastPresenter.show( + icon: "xmark.circle", + message: "Failed to drop trigger: \(error.localizedDescription)", + style: .error + ) + } + } + } + + func scriptServerTrigger(name: String, session: ConnectionSession) { + guard let mssql = session.session as? MSSQLSession else { return } + Task { + do { + if let definition = try await mssql.triggers.getServerTriggerDefinition(name: name) { + environmentState.openQueryTab(for: session, presetQuery: definition) + } + } catch { + environmentState.toastPresenter.show( + icon: "xmark.circle", + message: "Failed to get trigger definition: \(error.localizedDescription)", + style: .error + ) + } + } + } + + func dropPostgresDatabase(session: ConnectionSession, name: String, cascade: Bool, force: Bool) async { + guard let pgSession = session.session as? PostgresSession else { return } + + do { + _ = try await pgSession.client.admin.dropDatabase(name: name, ifExists: true, withForce: force) + await environmentState.refreshDatabaseStructure(for: session.id) + } catch { + environmentState.notificationEngine?.post( + category: .generalError, + message: "Drop failed: \(error.localizedDescription)" + ) + } + } + + func runMSSQLTask(session: ConnectionSession, database: String, task: ExperimentalMSSQLDatabaseTask) async { + guard let mssqlSession = session.session as? MSSQLSession else { return } + let admin = mssqlSession.admin + + do { + let messages: [SQLServerStreamMessage] + switch task { + case .shrink: + messages = try await admin.shrinkDatabase(name: database) + case .takeOffline: + messages = try await admin.takeDatabaseOffline(name: database) + case .bringOnline: + messages = try await admin.bringDatabaseOnline(name: database) + case .drop: + messages = try await admin.dropDatabase(name: database) + } + + let infoMessages = messages.filter { $0.kind == .info } + let toastMessage = infoMessages.map(\.message).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + if !toastMessage.isEmpty { + environmentState.notificationEngine?.post(category: .maintenanceCompleted, message: toastMessage) + } + await environmentState.refreshDatabaseStructure(for: session.id) + } catch { + environmentState.notificationEngine?.post( + category: .maintenanceFailed, + message: "Task failed: \(error.localizedDescription)" + ) + } + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView.swift new file mode 100644 index 000000000..178e005fe --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarView.swift @@ -0,0 +1,403 @@ +import SwiftUI + +struct ExperimentalObjectBrowserSidebarView: View { + @Binding var selectedConnectionID: UUID? + + @Environment(ProjectStore.self) var projectStore + @Environment(EnvironmentState.self) var environmentState + @Environment(NavigationStore.self) var navigationStore + @Environment(\.openWindow) var openWindow + + @State var viewModel = ExperimentalObjectBrowserSidebarViewModel() + @State var sheetState = SidebarSheetState() + + private var sessions: [ConnectionSession] { + environmentState.sessionGroup.sessions + } + + private var pendingConnections: [PendingConnection] { + environmentState.pendingConnections + } + + var body: some View { + let roots = ExperimentalObjectBrowserSnapshotBuilder.buildRoots( + pendingConnections: pendingConnections, + sessions: sessions, + settings: projectStore.globalSettings, + viewModel: viewModel + ) + + let mainContent = Group { + if sessions.isEmpty && pendingConnections.isEmpty { + VStack(spacing: SpacingTokens.xs) { + Image(systemName: "server.rack") + .font(TypographyTokens.hero.weight(.medium)) + .foregroundStyle(ColorTokens.Text.tertiary) + Text("No Connection") + .font(TypographyTokens.standard) + .foregroundStyle(ColorTokens.Text.secondary) + } + .padding(.vertical, SpacingTokens.xl2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + ExperimentalObjectBrowserOutlineView( + roots: roots, + expandedNodeIDs: viewModel.expandedNodeIDs, + selectedNodeID: viewModel.selectedNodeID, + rowContent: { node, isExpanded, outlineLevel, outlineOffset, onActivate in + AnyView( + ExperimentalObjectBrowserRowView( + node: node, + isExpanded: isExpanded, + isSelected: viewModel.selectedNodeID == node.id, + outlineLevel: outlineLevel, + outlineOffset: outlineOffset, + isHighlighted: viewModel.highlightedNodeID == node.id, + highlightPulse: viewModel.highlightPulse, + contextMenuBuilder: { contextMenu(for: node) }, + onActivate: onActivate + ) + .environment(projectStore) + .environment(environmentState) + .environment(\.sidebarDensity, projectStore.globalSettings.sidebarDensity) + ) + }, + onExpansionChanged: { node, isExpanded in + handleExpansionChange(of: node, isExpanded: isExpanded) + }, + onActivation: { node in + handleActivation(of: node) + }, + onSelectionChanged: { node in + handleSelectionChange(node) + }, + revealNodeID: viewModel.revealedNodeID, + revealRequestID: viewModel.revealRequestID + ) + .background(Color.clear) + } + } + .environment(sheetState) + .environment(\.sidebarDensity, projectStore.globalSettings.sidebarDensity) + .task(id: projectStore.selectedProject?.id) { + restoreAndSynchronizeState() + } + .onAppear { + schedulePendingNavigationConsumption() + } + .onChange(of: sessions.map(\.connection.id)) { _, _ in + synchronizeDefaults() + } + .onChange(of: viewModel.expandedNodeIDs) { _, _ in + guard !sessions.isEmpty else { return } + viewModel.persistExpansionState(projectID: projectStore.selectedProject?.id) + } + .onChange(of: sessions.map(\.id)) { oldIDs, newIDs in + let added = Set(newIDs).subtracting(oldIDs) + guard let newSession = sessions.first(where: { added.contains($0.id) }) else { return } + focusNewSession(newSession) + } + .onChange(of: navigationStore.pendingExplorerFocus) { _, focus in + guard let focus else { return } + handleExplorerFocus(focus) + } + .onChange(of: navigationStore.pendingExplorerRevealRequestID) { _, _ in + guard let connectionID = navigationStore.pendingExplorerRevealConnectionID else { return } + revealConnection(connectionID) + } + + let withSheets = applySheets(to: mainContent) + let withAlerts = applyAlerts(to: withSheets) + withAlerts + } + + private func synchronizeDefaults() { + viewModel.synchronizeDefaults(sessions: sessions) { databaseType in + projectStore.globalSettings.sidebarExpandSections(for: databaseType) + } + if !sessions.isEmpty { + viewModel.persistExpansionState(projectID: projectStore.selectedProject?.id) + } + + for session in sessions { + let securityNodeID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: session.connection.id, + kind: .security + ) + if viewModel.isExpanded(securityNodeID) { + loadServerSecurityIfNeeded(session: session) + } + } + + if selectedConnectionID == nil { + selectedConnectionID = sessions.first?.connection.id + } + } + + private func restoreAndSynchronizeState() { + viewModel.restoreExpansionState(projectID: projectStore.selectedProject?.id, sessions: sessions) + synchronizeDefaults() + } + + private func schedulePendingNavigationConsumption() { + Task { @MainActor in + await Task.yield() + if let focus = navigationStore.pendingExplorerFocus { + handleExplorerFocus(focus) + return + } + if let connectionID = navigationStore.pendingExplorerRevealConnectionID { + revealConnection(connectionID) + } + } + } + + private func focusNewSession(_ session: ConnectionSession) { + let serverNodeID = ExperimentalObjectBrowserSidebarViewModel.serverNodeID(connectionID: session.connection.id) + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + viewModel.selectedNodeID = serverNodeID + viewModel.setExpanded(true, nodeID: serverNodeID) + viewModel.setExpanded(true, nodeID: ExperimentalObjectBrowserSidebarViewModel.databasesFolderNodeID(connectionID: session.connection.id)) + viewModel.revealAndPulse(nodeID: serverNodeID) + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) + if viewModel.highlightedNodeID == serverNodeID { + viewModel.highlightedNodeID = nil + } + if viewModel.revealedNodeID == serverNodeID { + viewModel.revealedNodeID = nil + } + } + } + + private func revealConnection(_ connectionID: UUID) { + let serverNodeID = ExperimentalObjectBrowserSidebarViewModel.serverNodeID(connectionID: connectionID) + selectedConnectionID = connectionID + viewModel.selectedNodeID = serverNodeID + viewModel.setExpanded(true, nodeID: serverNodeID) + viewModel.revealAndPulse(nodeID: serverNodeID) + navigationStore.pendingExplorerRevealConnectionID = nil + } + + private func handleSelectionChange(_ node: ExperimentalObjectBrowserNode?) { + guard let node else { return } + viewModel.selectedNodeID = node.id + + switch node.row { + case .topSpacer: + break + case .pendingConnection: + break + case .server(let session), + .databasesFolder(let session, _), + .database(let session, _, _), + .objectGroup(let session, _, _, _), + .object(let session, _, _), + .serverFolder(let session, _, _), + .databaseFolder(let session, _, _, _, _), + .databaseSubfolder(let session, _, _, _, _, _), + .databaseNamedItem(let session, _, _, _, _, _), + .securitySection(let session, _, _, _), + .securityLogin(let session, _), + .securityServerRole(let session, _), + .securityCredential(let session, _), + .agentJob(let session, _), + .databaseSnapshot(let session, _), + .linkedServer(let session, _), + .ssisFolder(let session, _), + .serverTrigger(let session, _), + .action(let session, _, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + case .infoLeaf(_, _, _, _), .loading(_, _), .message(_, _, _): + break + } + } + + private func handleActivation(of node: ExperimentalObjectBrowserNode) { + viewModel.selectedNodeID = node.id + + switch node.row { + case .topSpacer: + return + case .pendingConnection: + return + case .server(let session): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + case .databasesFolder(let session, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + case .database(let session, let database, _): + guard database.isAccessible else { return } + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + session.sidebarFocusedDatabase = database.name + case .objectGroup(let session, let databaseName, _, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + session.sidebarFocusedDatabase = databaseName + case .object(let session, let databaseName, let object): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + session.sidebarFocusedDatabase = databaseName + viewModel.selectedNodeID = ExplorerSidebarIdentity.object( + connectionID: session.connection.id, + databaseName: databaseName, + objectID: object.id + ) + case .serverFolder(let session, _, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + case .databaseFolder(let session, let databaseName, _, _, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + session.sidebarFocusedDatabase = databaseName + case .databaseSubfolder(let session, let databaseName, _, _, _, _), + .databaseNamedItem(let session, let databaseName, _, _, _, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + session.sidebarFocusedDatabase = databaseName + case .securitySection(let session, _, _, _), + .securityLogin(let session, _), + .securityServerRole(let session, _), + .securityCredential(let session, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + case .agentJob(let session, _), + .databaseSnapshot(let session, _), + .linkedServer(let session, _), + .ssisFolder(let session, _), + .serverTrigger(let session, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + case .action(let session, let action, _): + selectedConnectionID = session.connection.id + environmentState.sessionGroup.setActiveSession(session.id) + perform(action: action, session: session) + case .infoLeaf(_, _, _, _), .loading(_, _), .message(_, _, _): + break + } + } + + private func handleExpansionChange(of node: ExperimentalObjectBrowserNode, isExpanded: Bool) { + withAnimation(.snappy(duration: 0.18, extraBounce: 0)) { + viewModel.setExpanded(isExpanded, nodeID: node.id) + } + + switch node.row { + case .database(let session, let database, _): + if isExpanded { + loadSchemaIfNeeded(databaseName: database.name, session: session) + } + case .serverFolder(let session, let kind, _): + guard isExpanded else { break } + switch kind { + case .agentJobs: + if (viewModel.agentJobsBySession[session.connection.id] ?? []).isEmpty, + !(viewModel.agentJobsLoadingBySession[session.connection.id] ?? false) { + loadAgentJobs(session: session) + } + case .databaseSnapshots: + if (viewModel.databaseSnapshotsBySession[session.connection.id] ?? []).isEmpty, + !(viewModel.databaseSnapshotsLoadingBySession[session.connection.id] ?? false) { + loadDatabaseSnapshots(session: session) + } + case .ssis: + if (viewModel.ssisFoldersBySession[session.connection.id] ?? []).isEmpty, + !(viewModel.ssisLoadingBySession[session.connection.id] ?? false) { + Task { await loadSSISFoldersAsync(session: session) } + } + case .linkedServers: + if (viewModel.linkedServersBySession[session.connection.id] ?? []).isEmpty, + !(viewModel.linkedServersLoadingBySession[session.connection.id] ?? false) { + loadLinkedServers(session: session) + } + case .serverTriggers: + if (viewModel.serverTriggersBySession[session.connection.id] ?? []).isEmpty, + !(viewModel.serverTriggersLoadingBySession[session.connection.id] ?? false) { + loadServerTriggers(session: session) + } + case .security: + if isExpanded { + loadServerSecurityIfNeeded(session: session) + } + case .management: + break + } + case .databaseFolder(let session, let databaseName, let kind, _, _): + guard isExpanded else { break } + guard let database = session.databaseStructure?.databases.first(where: { $0.name == databaseName }) else { break } + switch kind { + case .security: + loadDatabaseSecurityIfNeeded(database: database, session: session) + case .databaseTriggers: + if (viewModel.dbDDLTriggersByDB[viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: databaseName)] ?? []).isEmpty, + !(viewModel.dbDDLTriggersLoadingByDB[viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: databaseName)] ?? false) { + loadDatabaseDDLTriggers(database: database, session: session) + } + case .serviceBroker: + if viewModel.serviceBrokerQueuesByDB[viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: databaseName)] == nil, + !(viewModel.serviceBrokerLoadingByDB[viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: databaseName)] ?? false) { + loadServiceBrokerData(database: database, session: session) + } + case .externalResources: + if viewModel.externalDataSourcesByDB[viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: databaseName)] == nil, + !(viewModel.externalResourcesLoadingByDB[viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: databaseName)] ?? false) { + loadExternalResources(database: database, session: session) + } + } + default: + break + } + } + + private func perform(action: ExperimentalObjectBrowserActionKind, session: ConnectionSession) { + let connectionID = session.connection.id + + switch action { + case .maintenance: + environmentState.openMaintenanceTab(connectionID: connectionID) + case .serverProperties: + environmentState.openServerPropertiesTab(connectionID: connectionID) + case .activityMonitor: + environmentState.openActivityMonitorTab(connectionID: connectionID) + case .extendedEvents: + environmentState.openActivityMonitorTab(connectionID: connectionID, section: "XEvents") + case .databaseMail: + let value = environmentState.prepareDatabaseMailEditorWindow(connectionSessionID: connectionID) + openWindow(id: DatabaseMailEditorWindow.sceneID, value: value) + case .sqlProfiler: + environmentState.openActivityMonitorTab(connectionID: connectionID, section: "Profiler") + case .resourceGovernor: + environmentState.openResourceGovernorTab(connectionID: connectionID) + case .tuningAdvisor: + environmentState.openTuningAdvisorTab(connectionID: connectionID) + case .policyManagement: + environmentState.openPolicyManagementTab(connectionID: connectionID) + case .sqlServerLogs: + environmentState.openErrorLogTab(connectionID: connectionID) + case .openJobQueue: + environmentState.openJobQueueTab(for: session) + } + } + + private func loadSchemaIfNeeded(databaseName: String, session: ConnectionSession) { + let freshness = session.metadataFreshness(forDatabase: databaseName) + switch freshness { + case .cached, .listOnly: + break + case .refreshing, .live, .failed: + return + } + guard session.beginSchemaLoad(forDatabase: databaseName) else { return } + + Task { @MainActor in + session.markMetadataRefreshStarted(forDatabase: databaseName) + defer { session.finishSchemaLoad(forDatabase: databaseName) } + await environmentState.loadSchemaForDatabase(databaseName, connectionSession: session) + } + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarViewModel+Persistence.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarViewModel+Persistence.swift new file mode 100644 index 000000000..9bc56d3f9 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarViewModel+Persistence.swift @@ -0,0 +1,56 @@ +import Foundation + +extension ExperimentalObjectBrowserSidebarViewModel { + private static let sidebarStateDefaultsKey = "experimentalObjectBrowser.sidebarStateByProject" + + struct PersistedSidebarState: Codable { + let expandedNodeIDs: [String] + let initializedConnectionIDs: [UUID] + } + + func restoreExpansionState(projectID: UUID?, sessions: [ConnectionSession]) { + let payloads = loadPersistedStatePayloads() + let storageKey = persistenceStorageKey(for: projectID) + guard let payload = payloads[storageKey] else { + initializedConnectionIDs = [] + expandedNodeIDs = [] + return + } + + let sessionIDs = Set(sessions.map(\.connection.id)) + let restoredInitialized = Set(payload.initializedConnectionIDs).intersection(sessionIDs) + initializedConnectionIDs = restoredInitialized + + let validPrefixes = restoredInitialized.map { $0.uuidString } + expandedNodeIDs = Set( + payload.expandedNodeIDs.filter { nodeID in + validPrefixes.contains(where: { nodeID.hasPrefix($0) }) + } + ) + } + + func persistExpansionState(projectID: UUID?) { + var payloads = loadPersistedStatePayloads() + let storageKey = persistenceStorageKey(for: projectID) + + payloads[storageKey] = PersistedSidebarState( + expandedNodeIDs: Array(expandedNodeIDs).sorted(), + initializedConnectionIDs: Array(initializedConnectionIDs).sorted { $0.uuidString < $1.uuidString } + ) + + guard let encoded = try? JSONEncoder().encode(payloads) else { return } + UserDefaults.standard.set(encoded, forKey: Self.sidebarStateDefaultsKey) + } + + private func persistenceStorageKey(for projectID: UUID?) -> String { + projectID?.uuidString ?? "global" + } + + private func loadPersistedStatePayloads() -> [String: PersistedSidebarState] { + guard let data = UserDefaults.standard.data(forKey: Self.sidebarStateDefaultsKey), + let decoded = try? JSONDecoder().decode([String: PersistedSidebarState].self, from: data) else { + return [:] + } + return decoded + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarViewModel.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarViewModel.swift new file mode 100644 index 000000000..b90f1c9af --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSidebarViewModel.swift @@ -0,0 +1,271 @@ +import Foundation +import SQLServerKit + +@MainActor @Observable +final class ExperimentalObjectBrowserSidebarViewModel { + var expandedNodeIDs: Set = [] + var selectedNodeID: String? + var hideOfflineDatabasesBySession: [UUID: Bool] = [:] + var revealedNodeID: String? + var revealRequestID = 0 + var highlightedNodeID: String? + var highlightPulse = false + var agentJobsBySession: [UUID: [AgentJobItem]] = [:] + var agentJobsLoadingBySession: [UUID: Bool] = [:] + var linkedServersBySession: [UUID: [LinkedServerItem]] = [:] + var linkedServersLoadingBySession: [UUID: Bool] = [:] + var ssisFoldersBySession: [UUID: [SQLServerSSISFolder]] = [:] + var ssisLoadingBySession: [UUID: Bool] = [:] + var databaseSnapshotsBySession: [UUID: [SQLServerDatabaseSnapshot]] = [:] + var databaseSnapshotsLoadingBySession: [UUID: Bool] = [:] + var serverTriggersBySession: [UUID: [ServerTriggerItem]] = [:] + var serverTriggersLoadingBySession: [UUID: Bool] = [:] + var securityLoginsBySession: [UUID: [SecurityLoginItem]] = [:] + var securityServerRolesBySession: [UUID: [SecurityServerRoleItem]] = [:] + var securityCredentialsBySession: [UUID: [SecurityCredentialItem]] = [:] + var securityServerLoadingBySession: [UUID: Bool] = [:] + var dbSecurityUsersByDB: [String: [SecurityUserItem]] = [:] + var dbSecurityRolesByDB: [String: [SecurityDatabaseRoleItem]] = [:] + var dbSecurityAppRolesByDB: [String: [SecurityAppRoleItem]] = [:] + var dbSecuritySchemasByDB: [String: [SecuritySchemaItem]] = [:] + var dbSecurityLoadingByDB: [String: Bool] = [:] + var dbDDLTriggersByDB: [String: [DatabaseDDLTriggerItem]] = [:] + var dbDDLTriggersLoadingByDB: [String: Bool] = [:] + var serviceBrokerLoadingByDB: [String: Bool] = [:] + var serviceBrokerMessageTypesByDB: [String: [String]] = [:] + var serviceBrokerContractsByDB: [String: [String]] = [:] + var serviceBrokerQueuesByDB: [String: [String]] = [:] + var serviceBrokerServicesByDB: [String: [String]] = [:] + var serviceBrokerRoutesByDB: [String: [String]] = [:] + var serviceBrokerBindingsByDB: [String: [String]] = [:] + var externalResourcesLoadingByDB: [String: Bool] = [:] + var externalDataSourcesByDB: [String: [String]] = [:] + var externalTablesByDB: [String: [String]] = [:] + var externalFileFormatsByDB: [String: [String]] = [:] + + @ObservationIgnored var initializedConnectionIDs: Set = [] + + func synchronizeDefaults( + sessions: [ConnectionSession], + autoExpandSectionsForDatabaseType: (DatabaseType) -> Set + ) { + let validConnectionIDs = Set(sessions.map(\.connection.id)) + initializedConnectionIDs = initializedConnectionIDs.intersection(validConnectionIDs) + var expanded = expandedNodeIDs + + for session in sessions where !initializedConnectionIDs.contains(session.connection.id) { + initializedConnectionIDs.insert(session.connection.id) + + expanded.insert(Self.serverNodeID(connectionID: session.connection.id)) + + let autoExpand = autoExpandSectionsForDatabaseType(session.connection.databaseType) + if autoExpand.contains(.databases) { + expanded.insert(Self.databasesFolderNodeID(connectionID: session.connection.id)) + } + if autoExpand.contains(.security) { + expanded.insert( + Self.serverFolderNodeID(connectionID: session.connection.id, kind: .security) + ) + } + if autoExpand.contains(.management) { + expanded.insert( + Self.serverFolderNodeID(connectionID: session.connection.id, kind: .management) + ) + } + } + + expandedNodeIDs = expanded + } + + func setExpanded(_ isExpanded: Bool, nodeID: String) { + var expanded = expandedNodeIDs + if isExpanded { + expanded.insert(nodeID) + } else { + expanded.remove(nodeID) + } + expandedNodeIDs = expanded + } + + func toggleExpanded(nodeID: String) -> Bool { + var expanded = expandedNodeIDs + if expanded.contains(nodeID) { + expanded.remove(nodeID) + expandedNodeIDs = expanded + return false + } else { + expanded.insert(nodeID) + expandedNodeIDs = expanded + return true + } + } + + func isExpanded(_ nodeID: String) -> Bool { + expandedNodeIDs.contains(nodeID) + } + + func revealAndPulse(nodeID: String) { + revealedNodeID = nodeID + revealRequestID &+= 1 + highlightedNodeID = nodeID + highlightPulse.toggle() + } + + struct LinkedServerItem: Identifiable, Hashable { + let id: String + let name: String + let provider: String + let dataSource: String + let product: String + let isDataAccessEnabled: Bool + } + + struct AgentJobItem: Identifiable, Hashable { + let id: String + let name: String + let enabled: Bool + let lastOutcome: String? + } + + struct ServerTriggerItem: Identifiable, Hashable { + let id: String + let name: String + let isDisabled: Bool + let typeDescription: String + let events: [String] + } + + struct SecurityLoginItem: Identifiable, Hashable { + let id: String + let name: String + let loginType: String + let isDisabled: Bool + } + + struct SecurityServerRoleItem: Identifiable, Hashable { + let id: String + let name: String + let isFixed: Bool + } + + struct SecurityCredentialItem: Identifiable, Hashable { + let id: String + let name: String + let identity: String + } + + struct SecurityUserItem: Identifiable, Hashable { + let id: String + let name: String + let userType: String + let defaultSchema: String? + } + + struct SecurityDatabaseRoleItem: Identifiable, Hashable { + let id: String + let name: String + let isFixed: Bool + let owner: String? + } + + struct SecurityAppRoleItem: Identifiable, Hashable { + let id: String + let name: String + let defaultSchema: String? + } + + struct SecuritySchemaItem: Identifiable, Hashable { + let id: String + let name: String + let owner: String? + } + + struct DatabaseDDLTriggerItem: Identifiable, Hashable { + let id: String + let name: String + let isDisabled: Bool + let events: [String] + } +} + +extension ExperimentalObjectBrowserSidebarViewModel { + static func serverNodeID(connectionID: UUID) -> String { + "\(connectionID.uuidString)#server" + } + + static func databasesFolderNodeID(connectionID: UUID) -> String { + "\(connectionID.uuidString)#folder#databases" + } + + static func databaseNodeID(connectionID: UUID, databaseName: String) -> String { + ExplorerSidebarIdentity.database(connectionID: connectionID, databaseName: databaseName) + } + + static func objectGroupNodeID( + connectionID: UUID, + databaseName: String, + objectType: SchemaObjectInfo.ObjectType + ) -> String { + "\(connectionID.uuidString)#db#\(databaseName)#group#\(objectType.rawValue)" + } + + static func serverFolderNodeID( + connectionID: UUID, + kind: ExperimentalObjectBrowserServerFolderKind + ) -> String { + "\(connectionID.uuidString)#server-folder#\(kind.rawValue)" + } + + static func securitySectionNodeID( + connectionID: UUID, + kind: ExperimentalObjectBrowserSecuritySectionKind, + parentID: String + ) -> String { + "\(parentID)#security-section#\(connectionID.uuidString)#\(kind.rawValue)" + } + + static func securityLeafNodeID( + connectionID: UUID, + parentID: String, + kind: ExperimentalObjectBrowserSecuritySectionKind, + name: String + ) -> String { + "\(parentID)#security-leaf#\(connectionID.uuidString)#\(kind.rawValue)#\(name)" + } + + static func actionNodeID( + connectionID: UUID, + parentID: String?, + kind: ExperimentalObjectBrowserActionKind + ) -> String { + "\(parentID ?? connectionID.uuidString)#action#\(kind.rawValue)" + } + + static func databaseFolderNodeID( + connectionID: UUID, + databaseName: String, + kind: ExperimentalObjectBrowserDatabaseFolderKind + ) -> String { + "\(connectionID.uuidString)#db#\(databaseName)#folder#\(kind.rawValue)" + } + + static func databaseSubfolderNodeID(parentID: String, title: String) -> String { + "\(parentID)#subfolder#\(title)" + } + + static func databaseItemNodeID(parentID: String, title: String) -> String { + "\(parentID)#item#\(title)" + } + + func databaseStorageKey(connectionID: UUID, databaseName: String) -> String { + "\(connectionID.uuidString)#\(databaseName)" + } + + static func infoNodeID(parentID: String, title: String) -> String { + "\(parentID)#info#\(title)" + } + + static func loadingNodeID(parentID: String) -> String { + "\(parentID)#loading" + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+DatabaseHelpers.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+DatabaseHelpers.swift new file mode 100644 index 000000000..e981baa63 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+DatabaseHelpers.swift @@ -0,0 +1,42 @@ +import Foundation + +extension ExperimentalObjectBrowserSnapshotBuilder { + static func visibleDatabases( + for session: ConnectionSession, + structure: DatabaseStructure?, + settings: GlobalSettings, + hideOffline: Bool + ) -> [DatabaseInfo] { + let hideInaccessible = settings.hideInaccessibleDatabases + return (structure?.databases ?? []) + .filter { !hideInaccessible || $0.isAccessible } + .filter { !hideOffline || $0.isOnline } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + static func groupedObjects( + for database: DatabaseInfo, + supportedTypes: [SchemaObjectInfo.ObjectType] + ) -> [SchemaObjectInfo.ObjectType: [SchemaObjectInfo]] { + var grouped: [SchemaObjectInfo.ObjectType: [SchemaObjectInfo]] = [:] + let supportedSet = Set(supportedTypes) + + for schema in database.schemas { + for object in schema.objects where supportedSet.contains(object.type) { + grouped[object.type, default: []].append(object) + } + } + + for ext in database.extensions where supportedSet.contains(.extension) { + grouped[.extension, default: []].append(ext) + } + + for key in grouped.keys { + grouped[key]?.sort { + $0.fullName.localizedCaseInsensitiveCompare($1.fullName) == .orderedAscending + } + } + + return grouped + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+DatabaseSections.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+DatabaseSections.swift new file mode 100644 index 000000000..1f342cd98 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+DatabaseSections.swift @@ -0,0 +1,219 @@ +import Foundation + +extension ExperimentalObjectBrowserSnapshotBuilder { + static func databaseSupplementaryChildren( + for session: ConnectionSession, + database: DatabaseInfo, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + switch session.connection.databaseType { + case .microsoftSQL: + guard database.isOnline else { return [] } + return [ + databaseSecurityFolderNode(for: session, database: database, viewModel: viewModel), + databaseDDLTriggersFolderNode(for: session, database: database, viewModel: viewModel), + serviceBrokerFolderNode(for: session, database: database, viewModel: viewModel), + externalResourcesFolderNode(for: session, database: database, viewModel: viewModel) + ] + case .postgresql, .mysql, .sqlite: + return [] + } + } + + private static func databaseSecurityFolderNode( + for session: ConnectionSession, + database: DatabaseInfo, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> ExperimentalObjectBrowserNode { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + let nodeID = ExperimentalObjectBrowserSidebarViewModel.databaseFolderNodeID( + connectionID: session.connection.id, + databaseName: database.name, + kind: .security + ) + let isLoading = viewModel.dbSecurityLoadingByDB[dbKey] ?? false + let users = viewModel.dbSecurityUsersByDB[dbKey] ?? [] + let roles = viewModel.dbSecurityRolesByDB[dbKey] ?? [] + let appRoles = viewModel.dbSecurityAppRolesByDB[dbKey] ?? [] + let schemas = viewModel.dbSecuritySchemasByDB[dbKey] ?? [] + + let children: [ExperimentalObjectBrowserNode] = [ + databaseSubfolderNode( + session: session, + databaseName: database.name, + parentID: nodeID, + title: "Users", + systemImage: "person", + paletteTitle: "Users", + items: users.map { ($0.name, "person", "Users", $0.defaultSchema) }, + emptyTitle: "No users found" + ), + databaseSubfolderNode( + session: session, + databaseName: database.name, + parentID: nodeID, + title: "Database Roles", + systemImage: "shield", + paletteTitle: "Database Roles", + items: roles.map { ($0.name, "shield", "Database Roles", $0.isFixed ? "Fixed" : nil) }, + emptyTitle: "No database roles found" + ), + databaseSubfolderNode( + session: session, + databaseName: database.name, + parentID: nodeID, + title: "Application Roles", + systemImage: "app.badge", + paletteTitle: "Application Roles", + items: appRoles.map { ($0.name, "app.badge", "Application Roles", $0.defaultSchema) }, + emptyTitle: "No application roles found" + ), + databaseSubfolderNode( + session: session, + databaseName: database.name, + parentID: nodeID, + title: "Schemas", + systemImage: "folder", + paletteTitle: "Schemas", + items: schemas.map { ($0.name, "folder", "Schemas", $0.owner) }, + emptyTitle: "No schemas found" + ) + ] + + return ExperimentalObjectBrowserNode( + id: nodeID, + row: .databaseFolder(session, database.name, .security, count: nil, isLoading: isLoading), + children: children + ) + } + + private static func databaseDDLTriggersFolderNode( + for session: ConnectionSession, + database: DatabaseInfo, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> ExperimentalObjectBrowserNode { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + let nodeID = ExperimentalObjectBrowserSidebarViewModel.databaseFolderNodeID( + connectionID: session.connection.id, + databaseName: database.name, + kind: .databaseTriggers + ) + let items = viewModel.dbDDLTriggersByDB[dbKey] ?? [] + let isLoading = viewModel.dbDDLTriggersLoadingByDB[dbKey] ?? false + let children: [ExperimentalObjectBrowserNode] + + if isLoading && items.isEmpty { + children = [ExperimentalObjectBrowserNode(id: "\(nodeID)#loading", row: .loading("Loading database triggers…", depth: 3))] + } else if items.isEmpty { + children = [ExperimentalObjectBrowserNode(id: "\(nodeID)#empty", row: .infoLeaf("No database triggers", systemImage: "bolt", paletteTitle: "Database Triggers", depth: 3))] + } else { + children = items.map { + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.databaseItemNodeID(parentID: nodeID, title: $0.name), + row: .databaseNamedItem(session, database.name, title: $0.name, systemImage: "bolt", paletteTitle: "Database Triggers", detail: $0.isDisabled ? "Disabled" : nil) + ) + } + } + + return ExperimentalObjectBrowserNode( + id: nodeID, + row: .databaseFolder(session, database.name, .databaseTriggers, count: items.isEmpty ? nil : items.count, isLoading: isLoading), + children: children + ) + } + + private static func serviceBrokerFolderNode( + for session: ConnectionSession, + database: DatabaseInfo, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> ExperimentalObjectBrowserNode { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + let nodeID = ExperimentalObjectBrowserSidebarViewModel.databaseFolderNodeID( + connectionID: session.connection.id, + databaseName: database.name, + kind: .serviceBroker + ) + let isLoading = viewModel.serviceBrokerLoadingByDB[dbKey] ?? false + let children = [ + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "Message Types", systemImage: "tray", paletteTitle: "Service Broker", items: (viewModel.serviceBrokerMessageTypesByDB[dbKey] ?? []).map { ($0, "doc", "Service Broker", nil) }, emptyTitle: "None"), + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "Contracts", systemImage: "tray", paletteTitle: "Service Broker", items: (viewModel.serviceBrokerContractsByDB[dbKey] ?? []).map { ($0, "doc", "Service Broker", nil) }, emptyTitle: "None"), + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "Queues", systemImage: "tray", paletteTitle: "Service Broker", items: (viewModel.serviceBrokerQueuesByDB[dbKey] ?? []).map { ($0, "doc", "Service Broker", nil) }, emptyTitle: "None"), + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "Services", systemImage: "tray", paletteTitle: "Service Broker", items: (viewModel.serviceBrokerServicesByDB[dbKey] ?? []).map { ($0, "doc", "Service Broker", nil) }, emptyTitle: "None"), + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "Routes", systemImage: "tray", paletteTitle: "Service Broker", items: (viewModel.serviceBrokerRoutesByDB[dbKey] ?? []).map { ($0, "doc", "Service Broker", nil) }, emptyTitle: "None"), + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "Remote Service Bindings", systemImage: "tray", paletteTitle: "Service Broker", items: (viewModel.serviceBrokerBindingsByDB[dbKey] ?? []).map { ($0, "doc", "Service Broker", nil) }, emptyTitle: "None") + ] + + return ExperimentalObjectBrowserNode( + id: nodeID, + row: .databaseFolder(session, database.name, .serviceBroker, count: nil, isLoading: isLoading), + children: children + ) + } + + private static func externalResourcesFolderNode( + for session: ConnectionSession, + database: DatabaseInfo, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> ExperimentalObjectBrowserNode { + let dbKey = viewModel.databaseStorageKey(connectionID: session.connection.id, databaseName: database.name) + let nodeID = ExperimentalObjectBrowserSidebarViewModel.databaseFolderNodeID( + connectionID: session.connection.id, + databaseName: database.name, + kind: .externalResources + ) + let isLoading = viewModel.externalResourcesLoadingByDB[dbKey] ?? false + let children = [ + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "External Data Sources", systemImage: "externaldrive", paletteTitle: "External Resources", items: (viewModel.externalDataSourcesByDB[dbKey] ?? []).map { ($0, "doc", "External Resources", nil) }, emptyTitle: "None"), + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "External Tables", systemImage: "externaldrive", paletteTitle: "External Resources", items: (viewModel.externalTablesByDB[dbKey] ?? []).map { ($0, "doc", "External Resources", nil) }, emptyTitle: "None"), + databaseSubfolderNode(session: session, databaseName: database.name, parentID: nodeID, title: "External File Formats", systemImage: "externaldrive", paletteTitle: "External Resources", items: (viewModel.externalFileFormatsByDB[dbKey] ?? []).map { ($0, "doc", "External Resources", nil) }, emptyTitle: "None") + ] + + return ExperimentalObjectBrowserNode( + id: nodeID, + row: .databaseFolder(session, database.name, .externalResources, count: nil, isLoading: isLoading), + children: children + ) + } + + private static func databaseSubfolderNode( + session: ConnectionSession, + databaseName: String, + parentID: String, + title: String, + systemImage: String, + paletteTitle: String, + items: [(title: String, systemImage: String, paletteTitle: String, detail: String?)], + emptyTitle: String + ) -> ExperimentalObjectBrowserNode { + let nodeID = ExperimentalObjectBrowserSidebarViewModel.databaseSubfolderNodeID(parentID: parentID, title: title) + let children: [ExperimentalObjectBrowserNode] + if items.isEmpty { + children = [ + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.databaseItemNodeID(parentID: nodeID, title: emptyTitle), + row: .infoLeaf(emptyTitle, systemImage: systemImage, paletteTitle: paletteTitle, depth: 4) + ) + ] + } else { + children = items.map { + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.databaseItemNodeID(parentID: nodeID, title: $0.title), + row: .databaseNamedItem( + session, + databaseName, + title: $0.title, + systemImage: $0.systemImage, + paletteTitle: $0.paletteTitle, + detail: $0.detail + ) + ) + } + } + + return ExperimentalObjectBrowserNode( + id: nodeID, + row: .databaseSubfolder(session, databaseName, title: title, systemImage: systemImage, paletteTitle: paletteTitle, count: items.isEmpty ? nil : items.count), + children: children + ) + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+Security.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+Security.swift new file mode 100644 index 000000000..1ce12f1c3 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+Security.swift @@ -0,0 +1,328 @@ +import Foundation + +extension ExperimentalObjectBrowserSnapshotBuilder { + static func securityChildren( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let connectionID = session.connection.id + let parentID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: connectionID, + kind: .security + ) + let isLoading = viewModel.securityServerLoadingBySession[connectionID] ?? false + + switch session.connection.databaseType { + case .microsoftSQL: + return mssqlSecurityChildren( + for: session, + parentID: parentID, + isLoading: isLoading, + viewModel: viewModel + ) + case .postgresql: + return postgresSecurityChildren( + for: session, + parentID: parentID, + isLoading: isLoading, + viewModel: viewModel + ) + case .mysql, .sqlite: + return [] + } + } + + private static func mssqlSecurityChildren( + for session: ConnectionSession, + parentID: String, + isLoading: Bool, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let connectionID = session.connection.id + let loginsSectionID = ExperimentalObjectBrowserSidebarViewModel.securitySectionNodeID( + connectionID: connectionID, + kind: .logins, + parentID: parentID + ) + let serverRolesSectionID = ExperimentalObjectBrowserSidebarViewModel.securitySectionNodeID( + connectionID: connectionID, + kind: .serverRoles, + parentID: parentID + ) + let credentialsSectionID = ExperimentalObjectBrowserSidebarViewModel.securitySectionNodeID( + connectionID: connectionID, + kind: .credentials, + parentID: parentID + ) + let allLogins = viewModel.securityLoginsBySession[connectionID] ?? [] + let standardLogins = allLogins.filter { !certificateLoginTypes.contains($0.loginType) } + let certificateLogins = allLogins.filter { certificateLoginTypes.contains($0.loginType) } + let serverRoles = viewModel.securityServerRolesBySession[connectionID] ?? [] + let credentials = viewModel.securityCredentialsBySession[connectionID] ?? [] + + return [ + securitySectionNode( + .logins, + session: session, + count: standardLogins.count, + isLoading: isLoading, + parentID: parentID, + children: standardLoginChildren( + for: session, + parentID: loginsSectionID, + items: standardLogins, + certificateLogins: certificateLogins, + isLoading: isLoading + ) + ), + securitySectionNode( + .serverRoles, + session: session, + count: serverRoles.count, + isLoading: isLoading, + parentID: parentID, + children: serverRoleChildren(for: session, parentID: serverRolesSectionID, items: serverRoles, isLoading: isLoading) + ), + securitySectionNode( + .credentials, + session: session, + count: credentials.count, + isLoading: isLoading, + parentID: parentID, + children: credentialChildren(for: session, parentID: credentialsSectionID, items: credentials, isLoading: isLoading) + ) + ] + } + + private static func postgresSecurityChildren( + for session: ConnectionSession, + parentID: String, + isLoading: Bool, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let connectionID = session.connection.id + let loginRolesSectionID = ExperimentalObjectBrowserSidebarViewModel.securitySectionNodeID( + connectionID: connectionID, + kind: .pgLoginRoles, + parentID: parentID + ) + let groupRolesSectionID = ExperimentalObjectBrowserSidebarViewModel.securitySectionNodeID( + connectionID: connectionID, + kind: .pgGroupRoles, + parentID: parentID + ) + let allRoles = viewModel.securityLoginsBySession[connectionID] ?? [] + let loginRoles = allRoles.filter { $0.loginType.contains("Login") || $0.loginType.contains("Superuser") } + let groupRoles = allRoles.filter { $0.loginType == "Group Role" } + + return [ + securitySectionNode( + .pgLoginRoles, + session: session, + count: loginRoles.count, + isLoading: isLoading, + parentID: parentID, + children: securityLoginChildren( + for: session, + parentID: loginRolesSectionID, + sectionKind: .pgLoginRoles, + items: loginRoles, + emptyTitle: "No login roles found", + isLoading: isLoading + ) + ), + securitySectionNode( + .pgGroupRoles, + session: session, + count: groupRoles.count, + isLoading: isLoading, + parentID: parentID, + children: securityLoginChildren( + for: session, + parentID: groupRolesSectionID, + sectionKind: .pgGroupRoles, + items: groupRoles, + emptyTitle: "No group roles found", + isLoading: isLoading + ) + ) + ] + } + + private static func standardLoginChildren( + for session: ConnectionSession, + parentID: String, + items: [ExperimentalObjectBrowserSidebarViewModel.SecurityLoginItem], + certificateLogins: [ExperimentalObjectBrowserSidebarViewModel.SecurityLoginItem], + isLoading: Bool + ) -> [ExperimentalObjectBrowserNode] { + let connectionID = session.connection.id + let loginsParentID = ExperimentalObjectBrowserSidebarViewModel.securitySectionNodeID( + connectionID: connectionID, + kind: .logins, + parentID: parentID + ) + var children = securityLoginChildren( + for: session, + parentID: loginsParentID, + sectionKind: .logins, + items: items, + emptyTitle: "No logins found", + isLoading: isLoading + ) + + if !certificateLogins.isEmpty { + children.append( + securitySectionNode( + .certificateLogins, + session: session, + count: certificateLogins.count, + isLoading: false, + parentID: loginsParentID, + children: securityLoginChildren( + for: session, + parentID: loginsParentID, + sectionKind: .certificateLogins, + items: certificateLogins, + emptyTitle: "No certificate logins found", + isLoading: isLoading + ) + ) + ) + } + + return children + } + + private static func securityLoginChildren( + for session: ConnectionSession, + parentID: String, + sectionKind: ExperimentalObjectBrowserSecuritySectionKind, + items: [ExperimentalObjectBrowserSidebarViewModel.SecurityLoginItem], + emptyTitle: String, + isLoading: Bool + ) -> [ExperimentalObjectBrowserNode] { + if isLoading && items.isEmpty { + return [ + ExperimentalObjectBrowserNode( + id: "\(parentID)#loading", + row: .loading("Loading \(sectionKind.title.lowercased())…", depth: 2) + ) + ] + } + if items.isEmpty { + return [ + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.infoNodeID(parentID: parentID, title: emptyTitle), + row: .infoLeaf(emptyTitle, systemImage: sectionKind.systemImage, paletteTitle: sectionKind.title, depth: 2) + ) + ] + } + + return items.map { + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.securityLeafNodeID( + connectionID: session.connection.id, + parentID: parentID, + kind: sectionKind, + name: $0.name + ), + row: .securityLogin(session, $0) + ) + } + } + + private static func serverRoleChildren( + for session: ConnectionSession, + parentID: String, + items: [ExperimentalObjectBrowserSidebarViewModel.SecurityServerRoleItem], + isLoading: Bool + ) -> [ExperimentalObjectBrowserNode] { + if isLoading && items.isEmpty { + return [ + ExperimentalObjectBrowserNode( + id: "\(parentID)#loading", + row: .loading("Loading server roles…", depth: 2) + ) + ] + } + if items.isEmpty { + return [ + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.infoNodeID(parentID: parentID, title: "No server roles found"), + row: .infoLeaf("No server roles found", systemImage: "shield", paletteTitle: "Server Roles", depth: 2) + ) + ] + } + + return items.map { + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.securityLeafNodeID( + connectionID: session.connection.id, + parentID: parentID, + kind: .serverRoles, + name: $0.name + ), + row: .securityServerRole(session, $0) + ) + } + } + + private static func credentialChildren( + for session: ConnectionSession, + parentID: String, + items: [ExperimentalObjectBrowserSidebarViewModel.SecurityCredentialItem], + isLoading: Bool + ) -> [ExperimentalObjectBrowserNode] { + if isLoading && items.isEmpty { + return [ + ExperimentalObjectBrowserNode( + id: "\(parentID)#loading", + row: .loading("Loading credentials…", depth: 2) + ) + ] + } + if items.isEmpty { + return [ + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.infoNodeID(parentID: parentID, title: "No credentials found"), + row: .infoLeaf("No credentials found", systemImage: "key", paletteTitle: "Credentials", depth: 2) + ) + ] + } + + return items.map { + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.securityLeafNodeID( + connectionID: session.connection.id, + parentID: parentID, + kind: .credentials, + name: $0.name + ), + row: .securityCredential(session, $0) + ) + } + } + + private static func securitySectionNode( + _ kind: ExperimentalObjectBrowserSecuritySectionKind, + session: ConnectionSession, + count: Int, + isLoading: Bool, + parentID: String, + children: [ExperimentalObjectBrowserNode] + ) -> ExperimentalObjectBrowserNode { + let sectionID = ExperimentalObjectBrowserSidebarViewModel.securitySectionNodeID( + connectionID: session.connection.id, + kind: kind, + parentID: parentID + ) + return ExperimentalObjectBrowserNode( + id: sectionID, + row: .securitySection(session, kind, count: count, isLoading: isLoading), + children: children + ) + } + + private static let certificateLoginTypes: Set = ["Certificate", "Asymmetric Key"] +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+ServerSections.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+ServerSections.swift new file mode 100644 index 000000000..43a008b8e --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot+ServerSections.swift @@ -0,0 +1,272 @@ +import Foundation +import SQLServerKit + +extension ExperimentalObjectBrowserSnapshotBuilder { + static func serverSupplementaryChildren( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + switch session.connection.databaseType { + case .microsoftSQL: + return [ + serverFolderNode(.security, session: session, count: nil, children: securityChildren(for: session, viewModel: viewModel)), + serverFolderNode(.databaseSnapshots, session: session, count: snapshotCount(for: session, viewModel: viewModel), children: snapshotChildren(for: session, viewModel: viewModel)), + serverFolderNode(.agentJobs, session: session, count: agentJobCount(for: session, viewModel: viewModel), children: agentJobChildren(for: session, viewModel: viewModel)), + serverFolderNode(.management, session: session, count: nil, children: managementChildren(for: session)), + serverFolderNode(.ssis, session: session, count: ssisCount(for: session, viewModel: viewModel), children: ssisChildren(for: session, viewModel: viewModel)), + serverFolderNode(.linkedServers, session: session, count: linkedServerCount(for: session, viewModel: viewModel), children: linkedServerChildren(for: session, viewModel: viewModel)), + serverFolderNode(.serverTriggers, session: session, count: serverTriggerCount(for: session, viewModel: viewModel), children: serverTriggerChildren(for: session, viewModel: viewModel)) + ] + case .postgresql: + return [ + serverFolderNode(.security, session: session, count: nil, children: securityChildren(for: session, viewModel: viewModel)) + ] + case .mysql: + return [ + actionNode(.maintenance, session: session, depth: 0), + actionNode(.serverProperties, session: session, depth: 0), + actionNode(.activityMonitor, session: session, depth: 0) + ] + case .sqlite: + return [ + actionNode(.maintenance, session: session, depth: 0) + ] + } + } + + private static func serverFolderNode( + _ kind: ExperimentalObjectBrowserServerFolderKind, + session: ConnectionSession, + count: Int?, + children: [ExperimentalObjectBrowserNode] + ) -> ExperimentalObjectBrowserNode { + let nodeID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: session.connection.id, + kind: kind + ) + return ExperimentalObjectBrowserNode( + id: nodeID, + row: .serverFolder(session, kind, count: count), + children: children + ) + } + + private static func managementChildren(for session: ConnectionSession) -> [ExperimentalObjectBrowserNode] { + let parentID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: session.connection.id, + kind: .management + ) + + return [ + actionNode(.extendedEvents, session: session, depth: 1, parentID: parentID), + actionNode(.databaseMail, session: session, depth: 1, parentID: parentID), + actionNode(.sqlProfiler, session: session, depth: 1, parentID: parentID), + actionNode(.resourceGovernor, session: session, depth: 1, parentID: parentID), + actionNode(.tuningAdvisor, session: session, depth: 1, parentID: parentID), + actionNode(.policyManagement, session: session, depth: 1, parentID: parentID), + actionNode(.activityMonitor, session: session, depth: 1, parentID: parentID), + actionNode(.sqlServerLogs, session: session, depth: 1, parentID: parentID) + ] + } + + private static func snapshotCount( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> Int? { + let items = viewModel.databaseSnapshotsBySession[session.connection.id] ?? [] + return items.isEmpty ? nil : items.count + } + + private static func snapshotChildren( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let parentID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: session.connection.id, + kind: .databaseSnapshots + ) + let items = viewModel.databaseSnapshotsBySession[session.connection.id] ?? [] + let isLoading = viewModel.databaseSnapshotsLoadingBySession[session.connection.id] ?? false + + if isLoading { + return [ExperimentalObjectBrowserNode(id: "\(parentID)#loading", row: .loading("Loading snapshots…", depth: 1))] + } + if items.isEmpty { + return [infoNode(title: "No snapshots", systemImage: "camera", paletteTitle: "Database Snapshots", depth: 1, parentID: parentID)] + } + return items.map { + ExperimentalObjectBrowserNode( + id: "\(parentID)#snapshot#\($0.name)", + row: .databaseSnapshot(session, $0) + ) + } + } + + private static func agentJobCount( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> Int? { + let items = viewModel.agentJobsBySession[session.connection.id] ?? [] + return items.isEmpty ? nil : items.count + } + + private static func agentJobChildren( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let parentID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: session.connection.id, + kind: .agentJobs + ) + let items = viewModel.agentJobsBySession[session.connection.id] ?? [] + let isLoading = viewModel.agentJobsLoadingBySession[session.connection.id] ?? false + var children: [ExperimentalObjectBrowserNode] = [ + actionNode(.openJobQueue, session: session, depth: 1, parentID: parentID) + ] + + if isLoading { + children.append(ExperimentalObjectBrowserNode(id: "\(parentID)#loading", row: .loading("Loading jobs…", depth: 1))) + return children + } + if items.isEmpty { + children.append(infoNode(title: "No jobs found", systemImage: "clock", paletteTitle: "Agent Jobs", depth: 1, parentID: parentID)) + return children + } + children.append(contentsOf: items.map { + ExperimentalObjectBrowserNode( + id: "\(parentID)#job#\($0.id)", + row: .agentJob(session, $0) + ) + }) + return children + } + + private static func ssisCount( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> Int? { + let items = viewModel.ssisFoldersBySession[session.connection.id] ?? [] + return items.isEmpty ? nil : items.count + } + + private static func ssisChildren( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let parentID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: session.connection.id, + kind: .ssis + ) + let items = viewModel.ssisFoldersBySession[session.connection.id] ?? [] + let isLoading = viewModel.ssisLoadingBySession[session.connection.id] ?? false + + if isLoading { + return [ExperimentalObjectBrowserNode(id: "\(parentID)#loading", row: .loading("Loading catalogs…", depth: 1))] + } + if items.isEmpty { + return [infoNode(title: "No catalogs found", systemImage: "shippingbox", paletteTitle: "Integration Services Catalogs", depth: 1, parentID: parentID)] + } + return items.map { + ExperimentalObjectBrowserNode( + id: "\(parentID)#ssis#\($0.name)", + row: .ssisFolder(session, $0) + ) + } + } + + private static func linkedServerCount( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> Int? { + let items = viewModel.linkedServersBySession[session.connection.id] ?? [] + return items.isEmpty ? nil : items.count + } + + private static func linkedServerChildren( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let parentID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: session.connection.id, + kind: .linkedServers + ) + let items = viewModel.linkedServersBySession[session.connection.id] ?? [] + let isLoading = viewModel.linkedServersLoadingBySession[session.connection.id] ?? false + + if isLoading { + return [ExperimentalObjectBrowserNode(id: "\(parentID)#loading", row: .loading("Loading linked servers…", depth: 1))] + } + if items.isEmpty { + return [infoNode(title: "No linked servers", systemImage: "link", paletteTitle: "Linked Servers", depth: 1, parentID: parentID)] + } + return items.map { + ExperimentalObjectBrowserNode( + id: "\(parentID)#linked#\($0.id)", + row: .linkedServer(session, $0) + ) + } + } + + private static func serverTriggerCount( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> Int? { + let items = viewModel.serverTriggersBySession[session.connection.id] ?? [] + return items.isEmpty ? nil : items.count + } + + private static func serverTriggerChildren( + for session: ConnectionSession, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let parentID = ExperimentalObjectBrowserSidebarViewModel.serverFolderNodeID( + connectionID: session.connection.id, + kind: .serverTriggers + ) + let items = viewModel.serverTriggersBySession[session.connection.id] ?? [] + let isLoading = viewModel.serverTriggersLoadingBySession[session.connection.id] ?? false + + if isLoading { + return [ExperimentalObjectBrowserNode(id: "\(parentID)#loading", row: .loading("Loading server triggers…", depth: 1))] + } + if items.isEmpty { + return [infoNode(title: "No server triggers", systemImage: "bolt", paletteTitle: "Server Triggers", depth: 1, parentID: parentID)] + } + return items.map { + ExperimentalObjectBrowserNode( + id: "\(parentID)#trigger#\($0.id)", + row: .serverTrigger(session, $0) + ) + } + } + + private static func actionNode( + _ kind: ExperimentalObjectBrowserActionKind, + session: ConnectionSession, + depth: Int, + parentID: String? = nil + ) -> ExperimentalObjectBrowserNode { + let nodeID = ExperimentalObjectBrowserSidebarViewModel.actionNodeID( + connectionID: session.connection.id, + parentID: parentID, + kind: kind + ) + return ExperimentalObjectBrowserNode( + id: nodeID, + row: .action(session, kind, depth: depth) + ) + } + + private static func infoNode( + title: String, + systemImage: String, + paletteTitle: String, + depth: Int, + parentID: String + ) -> ExperimentalObjectBrowserNode { + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.infoNodeID(parentID: parentID, title: title), + row: .infoLeaf(title, systemImage: systemImage, paletteTitle: paletteTitle, depth: depth) + ) + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot.swift new file mode 100644 index 000000000..a15918bb7 --- /dev/null +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/ExperimentalObjectBrowserSnapshot.swift @@ -0,0 +1,220 @@ +import Foundation + +@MainActor +enum ExperimentalObjectBrowserSnapshotBuilder { + static func buildRoots( + pendingConnections: [PendingConnection], + sessions: [ConnectionSession], + settings: GlobalSettings, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + let topSpacer = ExperimentalObjectBrowserNode( + id: "explorer-lab#top-spacer", + row: .topSpacer(SpacingTokens.xs) + ) + + var rows: [ExperimentalObjectBrowserNode] = [topSpacer] + + for pending in pendingConnections { + rows.append( + ExperimentalObjectBrowserNode( + id: "\(pending.id.uuidString)#pending", + row: .pendingConnection(pending) + ) + ) + } + + if !pendingConnections.isEmpty && !sessions.isEmpty { + rows.append( + ExperimentalObjectBrowserNode( + id: "explorer-lab#pending-gap", + row: .topSpacer(SpacingTokens.xs) + ) + ) + } + + for (index, session) in sessions.enumerated() { + if index > 0 { + rows.append( + ExperimentalObjectBrowserNode( + id: "explorer-lab#server-gap#\(session.connection.id.uuidString)", + row: .topSpacer(SpacingTokens.xs) + ) + ) + } + + let serverID = ExperimentalObjectBrowserSidebarViewModel.serverNodeID(connectionID: session.connection.id) + rows.append( + ExperimentalObjectBrowserNode( + id: serverID, + row: .server(session), + children: serverChildren( + for: session, + settings: settings, + viewModel: viewModel + ) + ) + ) + } + + return rows + } + + static func serverChildren( + for session: ConnectionSession, + settings: GlobalSettings, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + switch session.structureLoadingState { + case .failed(let message): + return [ + ExperimentalObjectBrowserNode( + id: "\(session.connection.id.uuidString)#failed", + row: .message(message ?? "Failed to load", systemImage: "exclamationmark.triangle.fill", depth: 1) + ) + ] + case .idle: + return [ + ExperimentalObjectBrowserNode( + id: "\(session.connection.id.uuidString)#server-loading", + row: .loading("Loading server…", depth: 1) + ) + ] + case .loading where session.databaseStructure == nil: + return [ + ExperimentalObjectBrowserNode( + id: "\(session.connection.id.uuidString)#server-loading", + row: .loading("Loading server…", depth: 1) + ) + ] + default: + let structure = session.databaseStructure + let visibleDatabases = visibleDatabases( + for: session, + structure: structure, + settings: settings, + hideOffline: viewModel.hideOfflineDatabasesBySession[session.connection.id] ?? false + ) + let folderID = ExperimentalObjectBrowserSidebarViewModel.databasesFolderNodeID(connectionID: session.connection.id) + let folderChildren = visibleDatabases.map { + databaseNode( + for: session, + database: $0, + settings: settings, + expandedNodeIDs: viewModel.expandedNodeIDs, + viewModel: viewModel + ) + } + + var children = [ + ExperimentalObjectBrowserNode( + id: folderID, + row: .databasesFolder(session, count: visibleDatabases.count), + children: folderChildren + ) + ] + children.append(contentsOf: serverSupplementaryChildren(for: session, viewModel: viewModel)) + return children + } + } + + private static func databaseNode( + for session: ConnectionSession, + database: DatabaseInfo, + settings: GlobalSettings, + expandedNodeIDs: Set, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> ExperimentalObjectBrowserNode { + let databaseID = ExperimentalObjectBrowserSidebarViewModel.databaseNodeID( + connectionID: session.connection.id, + databaseName: database.name + ) + + let isLoading = session.schemaLoadsInFlight.contains(session.schemaLoadKey(database.name)) + let children = databaseChildren( + for: session, + database: database, + settings: settings, + expandedNodeIDs: expandedNodeIDs, + isLoading: isLoading, + viewModel: viewModel + ) + + return ExperimentalObjectBrowserNode( + id: databaseID, + row: .database(session, database, isLoading: isLoading), + children: children + ) + } + + private static func databaseChildren( + for session: ConnectionSession, + database: DatabaseInfo, + settings: GlobalSettings, + expandedNodeIDs: Set, + isLoading: Bool, + viewModel: ExperimentalObjectBrowserSidebarViewModel + ) -> [ExperimentalObjectBrowserNode] { + if isLoading { + return [ + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.loadingNodeID( + parentID: ExperimentalObjectBrowserSidebarViewModel.databaseNodeID( + connectionID: session.connection.id, + databaseName: database.name + ) + ), + row: .loading("Loading schema…", depth: 2) + ) + ] + } + + guard session.hasLoadedSchema(forDatabase: database.name) else { + return [ + ExperimentalObjectBrowserNode( + id: ExperimentalObjectBrowserSidebarViewModel.loadingNodeID( + parentID: ExperimentalObjectBrowserSidebarViewModel.databaseNodeID( + connectionID: session.connection.id, + databaseName: database.name + ) + ), + row: .loading(session.metadataFreshness(forDatabase: database.name) == .failed ? "Schema refresh failed" : "Expand to load objects…", depth: 2) + ) + ] + } + + let supportedTypes = SchemaObjectInfo.ObjectType.supported(for: session.connection.databaseType) + let snapshot = groupedObjects(for: database, supportedTypes: supportedTypes) + + let objectGroupNodes = supportedTypes.map { type in + let objects = snapshot[type] ?? [] + let groupID = ExperimentalObjectBrowserSidebarViewModel.objectGroupNodeID( + connectionID: session.connection.id, + databaseName: database.name, + objectType: type + ) + let groupChildren = objects.map { + ExperimentalObjectBrowserNode( + id: ExplorerSidebarIdentity.object( + connectionID: session.connection.id, + databaseName: database.name, + objectID: $0.id + ), + row: .object(session, database.name, $0) + ) + } + + return ExperimentalObjectBrowserNode( + id: groupID, + row: .objectGroup(session, database.name, type, count: objects.count), + children: groupChildren + ) + } + + return objectGroupNodes + databaseSupplementaryChildren( + for: session, + database: database, + viewModel: viewModel + ) + } +} diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/SidebarMenuView+Content.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/SidebarMenuView+Content.swift index 53bb690e4..f58487ac1 100644 --- a/Echo/Sources/Features/ObjectBrowser/Views/Components/SidebarMenuView+Content.swift +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/SidebarMenuView+Content.swift @@ -5,6 +5,8 @@ extension SidebarMenu { var contentView: some View { switch selectedNavSection { case .folder: + ExperimentalObjectBrowserSidebarView(selectedConnectionID: $selectedConnectionID) + case .experimentalFolder: ObjectBrowserSidebarView(selectedConnectionID: $selectedConnectionID) case .bookmark: BookmarksSidebarView() diff --git a/Echo/Sources/Features/ObjectBrowser/Views/Components/SidebarMenuView.swift b/Echo/Sources/Features/ObjectBrowser/Views/Components/SidebarMenuView.swift index f4462c4cc..7c37725f1 100644 --- a/Echo/Sources/Features/ObjectBrowser/Views/Components/SidebarMenuView.swift +++ b/Echo/Sources/Features/ObjectBrowser/Views/Components/SidebarMenuView.swift @@ -17,6 +17,7 @@ struct SidebarMenu: View { enum NavSection: String, CaseIterable { case folder = "Explorer" + case experimentalFolder = "Explorer Lab" case bookmark = "Bookmarks" case search = "Search" case clipboard = "Clipboard" @@ -27,6 +28,7 @@ struct SidebarMenu: View { var icon: String { switch self { case .folder: return "folder" + case .experimentalFolder: return "testtube.2" case .bookmark: return "bookmark" case .search: return "magnifyingglass" case .clipboard: return "clipboard" @@ -39,6 +41,7 @@ struct SidebarMenu: View { var activeIcon: String { switch self { case .folder: return "folder.fill" + case .experimentalFolder: return "testtube.2" case .bookmark: return "bookmark.fill" case .search: return "magnifyingglass" case .clipboard: return "clipboard.fill" @@ -89,6 +92,12 @@ struct SidebarMenu: View { selectedNavSection = .folder } } + .onChange(of: navigationStore.pendingExplorerRevealRequestID) { _, _ in + guard navigationStore.pendingExplorerRevealConnectionID != nil else { return } + withAnimation(.easeInOut(duration: 0.2)) { + selectedNavSection = .folder + } + } .onReceive(NotificationCenter.default.publisher(for: .activateSidebarSearch)) { _ in withAnimation(.easeInOut(duration: 0.2)) { selectedNavSection = .search diff --git a/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseContent.swift b/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseContent.swift index 4835c8a9c..a2a7b66e3 100644 --- a/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseContent.swift +++ b/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseContent.swift @@ -5,7 +5,7 @@ extension ObjectBrowserSidebarView { @ViewBuilder func databaseContent(database: DatabaseInfo, session: ConnectionSession, hasSchemas: Bool, proxy: ScrollViewProxy) -> some View { let connID = session.connection.id - let isLoading = viewModel.isDatabaseLoading(connectionID: connID, databaseName: database.name) + let isLoading = session.isRefreshingMetadata(forDatabase: database.name) Group { if hasSchemas { @@ -88,30 +88,33 @@ extension ObjectBrowserSidebarView { private func loadSchemaIfNeeded(connID: UUID, database: DatabaseInfo, session: ConnectionSession) { let hasSchemas = !database.schemas.isEmpty && database.schemas.contains(where: { !$0.objects.isEmpty }) - let isLoading = viewModel.isDatabaseLoading(connectionID: connID, databaseName: database.name) - let alreadyLoaded = viewModel.isDatabaseSchemaLoadedOnce(connectionID: connID, databaseName: database.name) - let needsLoad = !hasSchemas && !isLoading && !alreadyLoaded + let freshness = session.metadataFreshness(forDatabase: database.name) + let isLoading = session.isRefreshingMetadata(forDatabase: database.name) + let needsLoad = switch freshness { + case .cached: + !isLoading + case .listOnly: + !isLoading + case .refreshing, .live, .failed: + false + } guard needsLoad else { return } Task { @MainActor in let loadStart = CFAbsoluteTimeGetCurrent() let structureState = session.structureLoadingState print("[PERF] \(database.name): load started (structureState=\(structureState), existingDBCount=\(session.databaseStructure?.databases.count ?? 0))") - viewModel.setDatabaseLoading(connectionID: connID, databaseName: database.name, loading: true) + session.markMetadataRefreshStarted(forDatabase: database.name) + guard session.beginSchemaLoad(forDatabase: database.name) else { return } await environmentState.loadSchemaForDatabase(database.name, connectionSession: session) + session.finishSchemaLoad(forDatabase: database.name) let loadEnd = CFAbsoluteTimeGetCurrent() print("[PERF] \(database.name): load completed in \(String(format: "%.3f", loadEnd - loadStart))s, UI update pending") - // Only mark as "loaded once" if schemas actually arrived. - // If the load returned empty, don't set the flag — allow retry. let updatedDB = session.databaseStructure?.databases.first(where: { $0.name == database.name }) let gotSchemas = updatedDB?.schemas.contains(where: { !$0.objects.isEmpty }) ?? false - if gotSchemas { - viewModel.setDatabaseLoading(connectionID: connID, databaseName: database.name, loading: false) - } else { - // Clear loading state but DON'T mark as loaded-once — allow future retry - let key = viewModel.pinnedStorageKey(connectionID: connID, databaseName: database.name) - viewModel.databaseSchemaLoadingStates[key] = false + if !gotSchemas && freshness == .cached && hasSchemas { + session.markMetadataRefreshCompleted(forDatabase: database.name, hasSchemas: true) } if session.connection.databaseType == .postgresql { diff --git a/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseContextMenu.swift b/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseContextMenu.swift index 4f6a0df45..40eef1e73 100644 --- a/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseContextMenu.swift +++ b/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseContextMenu.swift @@ -21,12 +21,11 @@ func buildDatabaseNSMenu( // Group 1: Refresh menu.addActionItem("Refresh Schema", systemImage: "arrow.clockwise") { viewModel.ensureDatabaseExpanded(connectionID: connID, databaseName: database.name) - viewModel.setDatabaseLoading(connectionID: connID, databaseName: database.name, loading: true) Task { let handle = AppDirector.shared.activityEngine.begin("Refreshing schema for \(database.name)", connectionSessionID: session.id) + session.markMetadataRefreshStarted(forDatabase: database.name) await environmentState.loadSchemaForDatabase(database.name, connectionSession: session) handle.succeed() - viewModel.setDatabaseLoading(connectionID: connID, databaseName: database.name, loading: false) } } @@ -217,12 +216,11 @@ extension ObjectBrowserSidebarView { // Group 1: Refresh Button { viewModel.ensureDatabaseExpanded(connectionID: connID, databaseName: database.name) - viewModel.setDatabaseLoading(connectionID: connID, databaseName: database.name, loading: true) Task { let handle = AppDirector.shared.activityEngine.begin("Refreshing schema for \(database.name)", connectionSessionID: session.id) + session.markMetadataRefreshStarted(forDatabase: database.name) await environmentState.loadSchemaForDatabase(database.name, connectionSession: session) handle.succeed() - viewModel.setDatabaseLoading(connectionID: connID, databaseName: database.name, loading: false) } } label: { Label("Refresh Schema", systemImage: "arrow.clockwise") @@ -246,12 +244,6 @@ extension ObjectBrowserSidebarView { Label("Postgres Console", systemImage: "terminal") } } - if projectStore.globalSettings.nativePsqlEnabled { - Button {} label: { - Label("Native psql (Coming Soon)", systemImage: "chevron.left.forwardslash.chevron.right") - } - .disabled(true) - } } Divider() diff --git a/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseSections.swift b/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseSections.swift index 97de6093e..7c4991c23 100644 --- a/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseSections.swift +++ b/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/DatabaseSections/ObjectBrowserSidebarView+DatabaseSections.swift @@ -133,7 +133,7 @@ extension ObjectBrowserSidebarView { func databaseHeaderRow(database: DatabaseInfo, session: ConnectionSession, isExpanded: Bool, isSelected: Bool, accentColor: Color) -> some View { let connID = session.connection.id - let isLoading = viewModel.isDatabaseLoading(connectionID: connID, databaseName: database.name) + let isLoading = session.isRefreshingMetadata(forDatabase: database.name) let isAvailable = database.isOnline && database.isAccessible let expandedBinding = Binding( get: { isExpanded }, diff --git a/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/ObjectBrowserSidebarView+ContextMenus.swift b/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/ObjectBrowserSidebarView+ContextMenus.swift index cccdfc8f1..ccd1ed917 100644 --- a/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/ObjectBrowserSidebarView+ContextMenus.swift +++ b/Echo/Sources/Features/ObjectBrowser/Views/ObjectBrowserSidebar/ObjectBrowserSidebarView+ContextMenus.swift @@ -11,12 +11,11 @@ extension ObjectBrowserSidebarView { // Group 1: Refresh Button { - viewModel.setDatabaseLoading(connectionID: connID, databaseName: database.name, loading: true) Task { let handle = AppDirector.shared.activityEngine.begin("Refreshing \(type.pluralDisplayName)", connectionSessionID: session.id) + session.markMetadataRefreshStarted(forDatabase: database.name) await environmentState.loadSchemaForDatabase(database.name, connectionSession: session) handle.succeed() - viewModel.setDatabaseLoading(connectionID: connID, databaseName: database.name, loading: false) } } label: { Label("Refresh", systemImage: "arrow.clockwise") diff --git a/Echo/Sources/Features/ObjectBrowser/Views/SearchSidebar/SearchSidebarView+Actions.swift b/Echo/Sources/Features/ObjectBrowser/Views/SearchSidebar/SearchSidebarView+Actions.swift index 4c8eddcc5..3fa9b3e00 100644 --- a/Echo/Sources/Features/ObjectBrowser/Views/SearchSidebar/SearchSidebarView+Actions.swift +++ b/Echo/Sources/Features/ObjectBrowser/Views/SearchSidebar/SearchSidebarView+Actions.swift @@ -12,15 +12,10 @@ extension SearchSidebarView { switch payload { case .schemaObject(let schema, let name, let type): - switch type { - case .table: - if openInNewTab { - openQueryPreview(forTable: name, schema: schema, session: session, database: databaseName) - } else { - focusExplorer(on: session, database: databaseName, schema: schema, objectName: name, columnName: nil, objectType: .table) - } - case .view, .materializedView, .function, .procedure, .trigger, .extension, .sequence, .type, .synonym: - openDefinition(for: name, schema: schema, type: type, in: session, database: databaseName) + if openInNewTab, type == .table { + openQueryPreview(forTable: name, schema: schema, session: session, database: databaseName) + } else { + focusExplorer(on: session, database: databaseName, schema: schema, objectName: name, columnName: nil, objectType: type) } case .column(let schema, let table, let column): @@ -45,12 +40,12 @@ extension SearchSidebarView { } case .function(let schema, let name): - openDefinition(for: name, schema: schema, type: .function, in: session, database: databaseName) + focusExplorer(on: session, database: databaseName, schema: schema, objectName: name, columnName: nil, objectType: .function) case .procedure(let schema, let name): - openDefinition(for: name, schema: schema, type: .procedure, in: session, database: databaseName) + focusExplorer(on: session, database: databaseName, schema: schema, objectName: name, columnName: nil, objectType: .procedure) case .trigger(let schema, _, let name): - openDefinition(for: name, schema: schema, type: .trigger, in: session, database: databaseName) + focusExplorer(on: session, database: databaseName, schema: schema, objectName: name, columnName: nil, objectType: .trigger) case .queryTab(let tabID, let connectionSessionID): environmentState.sessionGroup.setActiveSession(connectionSessionID) diff --git a/Echo/Sources/Features/Preferences/Domain/GlobalSettings.swift b/Echo/Sources/Features/Preferences/Domain/GlobalSettings.swift index d60d1d006..8617911b0 100644 --- a/Echo/Sources/Features/Preferences/Domain/GlobalSettings.swift +++ b/Echo/Sources/Features/Preferences/Domain/GlobalSettings.swift @@ -16,18 +16,6 @@ enum AccentColorSource: String, Codable, Hashable, CaseIterable { } } -enum NativePsqlRuntimePreference: String, Codable, Hashable, CaseIterable { - case bundled - case system - - var displayName: String { - switch self { - case .bundled: return "Bundled Binary" - case .system: return "System Binary" - } - } -} - enum SidebarAutoExpandSection: String, Codable, Hashable, CaseIterable, Identifiable { case databases case tables @@ -148,6 +136,7 @@ struct GlobalSettings: Codable, Hashable { var resultSpoolMaxBytes: Int = 5 * 1_024 * 1_024 * 1_024 var resultSpoolRetentionHours: Int = 72 var resultSpoolCustomLocation: String? + var objectBrowserCacheMaxBytes: Int = 512 * 1_024 * 1_024 var autoOpenInspectorOnSelection: Bool = true var autoOpenBottomPanel: Bool = true var diagramPrefetchMode: DiagramPrefetchMode = .off @@ -163,11 +152,6 @@ struct GlobalSettings: Codable, Hashable { var sidebarAutoExpandSQLServer: Set? var sidebarAutoExpandMySQL: Set? var managedPostgresConsoleEnabled: Bool = true - var nativePsqlEnabled: Bool = false - var nativePsqlRuntimePreference: NativePsqlRuntimePreference = .bundled - var nativePsqlAllowSystemBinaryFallback: Bool = false - var nativePsqlAllowShellEscape: Bool = true - var nativePsqlAllowFileCommands: Bool = true var pgToolCustomPath: String? var mysqlToolCustomPath: String? var sidebarIconColorMode: SidebarIconColorMode = .colorful @@ -233,6 +217,7 @@ struct GlobalSettings: Codable, Hashable { case showForeignKeysInInspector, showJsonInInspector case resultsInitialRowLimit case resultSpoolMaxBytes, resultSpoolRetentionHours, resultSpoolCustomLocation + case objectBrowserCacheMaxBytes case autoOpenInspectorOnSelection, autoOpenBottomPanel case diagramPrefetchMode, diagramRefreshCadence, diagramCacheMaxBytes case diagramVerifyBeforeRefresh, diagramRenderRelationshipsForLargeDiagrams, diagramUseThemedAppearance @@ -240,11 +225,6 @@ struct GlobalSettings: Codable, Hashable { case sidebarAutoExpandSections, sidebarCustomizePerDatabaseType case sidebarAutoExpandPostgresql, sidebarAutoExpandSQLServer, sidebarAutoExpandMySQL case managedPostgresConsoleEnabled - case nativePsqlEnabled - case nativePsqlRuntimePreference - case nativePsqlAllowSystemBinaryFallback - case nativePsqlAllowShellEscape - case nativePsqlAllowFileCommands case pgToolCustomPath case mysqlToolCustomPath case sidebarIconColorMode @@ -300,6 +280,7 @@ struct GlobalSettings: Codable, Hashable { resultSpoolMaxBytes = try container.decodeIfPresent(Int.self, forKey: .resultSpoolMaxBytes) ?? 5 * 1_024 * 1_024 * 1_024 resultSpoolRetentionHours = try container.decodeIfPresent(Int.self, forKey: .resultSpoolRetentionHours) ?? 72 resultSpoolCustomLocation = try container.decodeIfPresent(String.self, forKey: .resultSpoolCustomLocation) + objectBrowserCacheMaxBytes = max(64 * 1_024 * 1_024, try container.decodeIfPresent(Int.self, forKey: .objectBrowserCacheMaxBytes) ?? 512 * 1_024 * 1_024) autoOpenInspectorOnSelection = try container.decodeIfPresent(Bool.self, forKey: .autoOpenInspectorOnSelection) ?? true autoOpenBottomPanel = try container.decodeIfPresent(Bool.self, forKey: .autoOpenBottomPanel) ?? true diagramPrefetchMode = try container.decodeIfPresent(DiagramPrefetchMode.self, forKey: .diagramPrefetchMode) ?? .off @@ -315,11 +296,6 @@ struct GlobalSettings: Codable, Hashable { sidebarAutoExpandSQLServer = try container.decodeIfPresent(Set.self, forKey: .sidebarAutoExpandSQLServer) sidebarAutoExpandMySQL = try container.decodeIfPresent(Set.self, forKey: .sidebarAutoExpandMySQL) managedPostgresConsoleEnabled = try container.decodeIfPresent(Bool.self, forKey: .managedPostgresConsoleEnabled) ?? true - nativePsqlEnabled = try container.decodeIfPresent(Bool.self, forKey: .nativePsqlEnabled) ?? false - nativePsqlRuntimePreference = try container.decodeIfPresent(NativePsqlRuntimePreference.self, forKey: .nativePsqlRuntimePreference) ?? .bundled - nativePsqlAllowSystemBinaryFallback = try container.decodeIfPresent(Bool.self, forKey: .nativePsqlAllowSystemBinaryFallback) ?? false - nativePsqlAllowShellEscape = try container.decodeIfPresent(Bool.self, forKey: .nativePsqlAllowShellEscape) ?? true - nativePsqlAllowFileCommands = try container.decodeIfPresent(Bool.self, forKey: .nativePsqlAllowFileCommands) ?? true pgToolCustomPath = try container.decodeIfPresent(String.self, forKey: .pgToolCustomPath) mysqlToolCustomPath = try container.decodeIfPresent(String.self, forKey: .mysqlToolCustomPath) @@ -381,6 +357,7 @@ struct GlobalSettings: Codable, Hashable { try container.encode(resultSpoolMaxBytes, forKey: .resultSpoolMaxBytes) try container.encode(resultSpoolRetentionHours, forKey: .resultSpoolRetentionHours) try container.encodeIfPresent(resultSpoolCustomLocation, forKey: .resultSpoolCustomLocation) + try container.encode(objectBrowserCacheMaxBytes, forKey: .objectBrowserCacheMaxBytes) try container.encode(autoOpenInspectorOnSelection, forKey: .autoOpenInspectorOnSelection) try container.encode(autoOpenBottomPanel, forKey: .autoOpenBottomPanel) try container.encode(diagramPrefetchMode, forKey: .diagramPrefetchMode) @@ -399,11 +376,6 @@ struct GlobalSettings: Codable, Hashable { try container.encodeIfPresent(sidebarAutoExpandSQLServer, forKey: .sidebarAutoExpandSQLServer) try container.encodeIfPresent(sidebarAutoExpandMySQL, forKey: .sidebarAutoExpandMySQL) try container.encode(managedPostgresConsoleEnabled, forKey: .managedPostgresConsoleEnabled) - try container.encode(nativePsqlEnabled, forKey: .nativePsqlEnabled) - try container.encode(nativePsqlRuntimePreference, forKey: .nativePsqlRuntimePreference) - try container.encode(nativePsqlAllowSystemBinaryFallback, forKey: .nativePsqlAllowSystemBinaryFallback) - try container.encode(nativePsqlAllowShellEscape, forKey: .nativePsqlAllowShellEscape) - try container.encode(nativePsqlAllowFileCommands, forKey: .nativePsqlAllowFileCommands) try container.encodeIfPresent(pgToolCustomPath, forKey: .pgToolCustomPath) try container.encodeIfPresent(mysqlToolCustomPath, forKey: .mysqlToolCustomPath) try container.encode(sidebarIconColorMode, forKey: .sidebarIconColorMode) diff --git a/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Actions.swift b/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Actions.swift index 2ee426cd8..19faba6d9 100644 --- a/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Actions.swift +++ b/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Actions.swift @@ -67,4 +67,25 @@ extension ApplicationCacheSettingsView { await refreshDiagramCacheUsage() } } + + func refreshObjectBrowserCacheUsage() async { + let shouldContinue = await MainActor.run { () -> Bool in + if isRefreshingObjectBrowserCache { return false } + isRefreshingObjectBrowserCache = true + return true + } + guard shouldContinue else { return } + let usage = await environmentState.objectBrowserCacheUsageBytes() + await MainActor.run { + objectBrowserCacheUsage = usage + isRefreshingObjectBrowserCache = false + } + } + + func clearObjectBrowserCache() { + Task { + await environmentState.clearObjectBrowserCache() + await refreshObjectBrowserCacheUsage() + } + } } diff --git a/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Bindings.swift b/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Bindings.swift index 784de94c4..5d304e6e9 100644 --- a/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Bindings.swift +++ b/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Bindings.swift @@ -26,6 +26,21 @@ extension ApplicationCacheSettingsView { ) } + var objectBrowserCacheMaxBinding: Binding { + Binding( + get: { projectStore.globalSettings.objectBrowserCacheMaxBytes }, + set: { newValue in + var settings = projectStore.globalSettings + settings.objectBrowserCacheMaxBytes = max(64 * 1_024 * 1_024, newValue) + Task { + try? await projectStore.updateGlobalSettings(settings) + await environmentState.objectBrowserCacheStore.pruneToLimit(settings.objectBrowserCacheMaxBytes) + await refreshObjectBrowserCacheUsage() + } + } + ) + } + var clipboardEnabledBinding: Binding { Binding( get: { clipboardHistory.isEnabled }, diff --git a/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Sections.swift b/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Sections.swift index 8b2c41e4c..607655dbd 100644 --- a/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Sections.swift +++ b/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView+Sections.swift @@ -14,6 +14,17 @@ extension ApplicationCacheSettingsView { .pickerStyle(.menu) .frame(minWidth: 120, idealWidth: 160, maxWidth: 200, alignment: .trailing) } + + PropertyRow(title: "Object browser cache") { + Picker("", selection: objectBrowserCacheMaxBinding) { + ForEach(Self.perTypeStorageOptions, id: \.bytes) { option in + Text(option.label).tag(option.bytes) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(minWidth: 120, idealWidth: 160, maxWidth: 200, alignment: .trailing) + } } } } diff --git a/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView.swift b/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView.swift index a13fbe158..f620820e2 100644 --- a/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView.swift +++ b/Echo/Sources/Features/Preferences/Views/ApplicationCacheSettings/ApplicationCacheSettingsView.swift @@ -18,6 +18,8 @@ struct ApplicationCacheSettingsView: View { @State var isRefreshingAutocompleteHistory = false @State var diagramCacheUsage: UInt64 = 0 @State var isRefreshingDiagramCache = false + @State var objectBrowserCacheUsage: UInt64 = 0 + @State var isRefreshingObjectBrowserCache = false var body: some View { Form { @@ -32,6 +34,7 @@ struct ApplicationCacheSettingsView: View { await refreshResultCacheUsage() await refreshAutocompleteHistoryUsage() await refreshDiagramCacheUsage() + await refreshObjectBrowserCacheUsage() } .alert("Disable Clipboard History?", isPresented: $confirmDisableHistory) { Button("Disable", role: .destructive) { @@ -79,6 +82,14 @@ struct ApplicationCacheSettingsView: View { onClear: { clearResultCache() } ) + storageUsageRow( + title: "Object Browser Cache", + usage: objectBrowserCacheUsage, + isRefreshing: isRefreshingObjectBrowserCache, + onRefresh: { await refreshObjectBrowserCacheUsage() }, + onClear: { clearObjectBrowserCache() } + ) + storageUsageRow( title: "Diagram Cache", usage: diagramCacheUsage, diff --git a/Echo/Sources/Features/Preferences/Views/DatabasesSettings/DatabasesSettingsView+Bindings.swift b/Echo/Sources/Features/Preferences/Views/DatabasesSettings/DatabasesSettingsView+Bindings.swift index f67cf3b3f..a38950b66 100644 --- a/Echo/Sources/Features/Preferences/Views/DatabasesSettings/DatabasesSettingsView+Bindings.swift +++ b/Echo/Sources/Features/Preferences/Views/DatabasesSettings/DatabasesSettingsView+Bindings.swift @@ -24,26 +24,6 @@ extension DatabasesSettingsView { binding(for: \.managedPostgresConsoleEnabled) } - var nativePsqlBinding: Binding { - binding(for: \.nativePsqlEnabled) - } - - var runtimePreferenceBinding: Binding { - binding(for: \.nativePsqlRuntimePreference) - } - - var systemFallbackBinding: Binding { - binding(for: \.nativePsqlAllowSystemBinaryFallback) - } - - var shellEscapeBinding: Binding { - binding(for: \.nativePsqlAllowShellEscape) - } - - var fileCommandsBinding: Binding { - binding(for: \.nativePsqlAllowFileCommands) - } - var pgToolCustomPathBinding: Binding { Binding( get: { settings.pgToolCustomPath ?? "" }, diff --git a/Echo/Sources/Features/Preferences/Views/DatabasesSettings/DatabasesSettingsView+PostgresSettings.swift b/Echo/Sources/Features/Preferences/Views/DatabasesSettings/DatabasesSettingsView+PostgresSettings.swift index 385702321..9b28f11c2 100644 --- a/Echo/Sources/Features/Preferences/Views/DatabasesSettings/DatabasesSettingsView+PostgresSettings.swift +++ b/Echo/Sources/Features/Preferences/Views/DatabasesSettings/DatabasesSettingsView+PostgresSettings.swift @@ -14,13 +14,13 @@ extension DatabasesSettingsView { return "pg_dump not found" } - /// PostgreSQL-specific settings: managed console, native psql policy, restrictions. + /// PostgreSQL-specific settings: managed console, backup tools. @ViewBuilder var postgresSettings: some View { Section("Managed Console") { PropertyRow( title: "Enable Postgres Console", - info: "The Postgres Console is Echo's managed PostgreSQL console powered by the app's Postgres engine. Use this for the current PostgreSQL console inside Echo. Native psql is configured separately." + info: "The Postgres Console is Echo's managed PostgreSQL console powered by the app's Postgres engine." ) { Toggle("", isOn: managedConsoleBinding) .labelsHidden() @@ -28,39 +28,6 @@ extension DatabasesSettingsView { } } - Section("Native psql") { - PropertyRow( - title: "Enable Native psql", - info: "Expose the future native psql entry point in Echo. This currently controls policy and UI availability only. Native psql is intended for exact CLI compatibility." - ) { - Toggle("", isOn: nativePsqlBinding) - .labelsHidden() - .toggleStyle(.switch) - } - - PropertyRow(title: "Runtime Preference") { - Picker("", selection: runtimePreferenceBinding) { - ForEach(NativePsqlRuntimePreference.allCases, id: \.self) { preference in - Text(preference.displayName) - .tag(preference) - } - } - .labelsHidden() - .pickerStyle(.menu) - } - .disabled(!settings.nativePsqlEnabled) - - PropertyRow( - title: "Allow System Binary Fallback", - info: "If Echo cannot use its preferred psql runtime, allow falling back to a system-installed psql binary." - ) { - Toggle("", isOn: systemFallbackBinding) - .labelsHidden() - .toggleStyle(.switch) - } - .disabled(!settings.nativePsqlEnabled) - } - Section("Backup & Restore Tools") { PropertyRow( title: "Tool Path", @@ -93,26 +60,5 @@ extension DatabasesSettingsView { } } - Section("Restrictions") { - PropertyRow( - title: "Allow Shell Escape", - info: "Controls whether a future native psql session should permit shell escape commands such as \\!. These toggles establish the policy model now so the app can grow without redesigning database settings later." - ) { - Toggle("", isOn: shellEscapeBinding) - .labelsHidden() - .toggleStyle(.switch) - } - .disabled(!settings.nativePsqlEnabled) - - PropertyRow( - title: "Allow File Commands", - info: "Controls whether a future native psql session should permit filesystem-driven commands such as \\i and copy workflows." - ) { - Toggle("", isOn: fileCommandsBinding) - .labelsHidden() - .toggleStyle(.switch) - } - .disabled(!settings.nativePsqlEnabled) - } } } diff --git a/Echo/Sources/Features/Preferences/Views/NotificationSettingsView+Bindings.swift b/Echo/Sources/Features/Preferences/Views/NotificationSettingsView+Bindings.swift new file mode 100644 index 000000000..655dea0d4 --- /dev/null +++ b/Echo/Sources/Features/Preferences/Views/NotificationSettingsView+Bindings.swift @@ -0,0 +1,64 @@ +import SwiftUI + +extension NotificationSettingsView { + var hasAnyEnabledNotifications: Bool { + NotificationCategory.allCases.contains { preferences.isEnabled($0) } + } + + var allEnabledBinding: Binding { + Binding( + get: { hasAnyEnabledNotifications }, + set: { enabled in + var updated = preferences + if enabled { + updated.enableAll() + } else { + updated.disableAll() + } + save(updated) + } + ) + } + + var deliveryBinding: Binding { + Binding( + get: { preferences.delivery }, + set: { newValue in + var updated = preferences + updated.delivery = newValue + save(updated) + } + ) + } + + func groupBinding(for group: NotificationGroup) -> Binding { + Binding( + get: { preferences.isGroupEnabled(group) }, + set: { enabled in + var updated = preferences + updated.setGroupEnabled(enabled, for: group) + save(updated) + } + ) + } + + func categoryBinding(for category: NotificationCategory) -> Binding { + Binding( + get: { preferences.isEnabled(category) }, + set: { enabled in + var updated = preferences + updated.markExplicitPreferences() + updated.setEnabled(enabled, for: category) + save(updated) + } + ) + } + + func save(_ preferences: NotificationPreferences) { + var settings = projectStore.globalSettings + settings.notificationPreferences = preferences + Task { + try? await projectStore.updateGlobalSettings(settings) + } + } +} diff --git a/Echo/Sources/Features/Preferences/Views/NotificationSettingsView+Components.swift b/Echo/Sources/Features/Preferences/Views/NotificationSettingsView+Components.swift new file mode 100644 index 000000000..4d8093add --- /dev/null +++ b/Echo/Sources/Features/Preferences/Views/NotificationSettingsView+Components.swift @@ -0,0 +1,96 @@ +import SwiftUI +import AppKit + +extension NotificationSettingsView { + var detailContent: some View { + Form { + overviewSection + deliverySection + ForEach(NotificationGroup.allCases) { group in + categorySection(for: group) + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + var overviewSection: some View { + Section { + HStack(alignment: .center, spacing: SpacingTokens.md) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 14)) + + VStack(alignment: .leading, spacing: SpacingTokens.xxxs) { + Text("Echo Notifications") + .font(TypographyTokens.title3.weight(.semibold)) + + Text("Control how Echo appears in Notification Center and inside the app.") + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Text.secondary) + } + + Spacer() + } + + PropertyRow( + title: "Allow notifications", + subtitle: "Turn all Echo notifications on or off." + ) { + Toggle("", isOn: allEnabledBinding) + .labelsHidden() + .toggleStyle(.switch) + } + } footer: { + Text("Echo follows these preferences for both in-app toasts and native macOS notifications.") + } + } + + var deliverySection: some View { + Section("Delivery") { + PropertyRow( + title: "Notification delivery", + subtitle: preferences.delivery.displayDescription, + info: "Choose whether Echo shows banners inside the app, through macOS Notification Center, or both." + ) { + Picker("", selection: deliveryBinding) { + ForEach(NotificationDelivery.allCases, id: \.self) { method in + Text(method.displayName) + .tag(method) + } + } + .labelsHidden() + .pickerStyle(.menu) + .disabled(!allEnabledBinding.wrappedValue) + } + } + } + + func categorySection(for group: NotificationGroup) -> some View { + Section(group.displayName) { + PropertyRow( + title: "Allow \(group.displayName.lowercased()) notifications", + subtitle: group.displayDescription + ) { + Toggle("", isOn: groupBinding(for: group)) + .labelsHidden() + .toggleStyle(.switch) + .disabled(!allEnabledBinding.wrappedValue) + } + + ForEach(group.categories) { category in + PropertyRow( + title: category.displayName, + subtitle: category.displayDescription + ) { + Toggle("", isOn: categoryBinding(for: category)) + .labelsHidden() + .toggleStyle(.switch) + .disabled(!allEnabledBinding.wrappedValue) + } + } + } + } +} diff --git a/Echo/Sources/Features/Preferences/Views/NotificationSettingsView.swift b/Echo/Sources/Features/Preferences/Views/NotificationSettingsView.swift index 506474b30..29b593a5c 100644 --- a/Echo/Sources/Features/Preferences/Views/NotificationSettingsView.swift +++ b/Echo/Sources/Features/Preferences/Views/NotificationSettingsView.swift @@ -1,76 +1,14 @@ import SwiftUI +import AppKit struct NotificationSettingsView: View { - @Environment(ProjectStore.self) private var projectStore + @Environment(ProjectStore.self) internal var projectStore - private var preferences: NotificationPreferences { + internal var preferences: NotificationPreferences { projectStore.globalSettings.notificationPreferences } var body: some View { - Form { - deliverySection - categoriesSection - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - } - - // MARK: - Delivery - - private var deliverySection: some View { - Section("Delivery Method") { - PropertyRow(title: "Deliver notifications via") { - Picker("", selection: deliveryBinding) { - ForEach(NotificationDelivery.allCases, id: \.self) { method in - Text(method.displayName).tag(method) - } - } - .labelsHidden() - .pickerStyle(.menu) - } - } - } - - private var deliveryBinding: Binding { - Binding( - get: { preferences.delivery }, - set: { newValue in - var settings = projectStore.globalSettings - settings.notificationPreferences.delivery = newValue - Task { try? await projectStore.updateGlobalSettings(settings) } - } - ) - } - - // MARK: - Categories - - private var categoriesSection: some View { - Section("Notification Categories") { - ForEach(NotificationGroup.allCases) { group in - DisclosureGroup { - ForEach(group.categories) { category in - PropertyRow(title: category.displayName) { - Toggle("", isOn: categoryBinding(for: category)) - .labelsHidden() - .toggleStyle(.switch) - } - } - } label: { - Label(group.displayName, systemImage: group.systemImage) - } - } - } - } - - private func categoryBinding(for category: NotificationCategory) -> Binding { - Binding( - get: { preferences.isEnabled(category) }, - set: { enabled in - var settings = projectStore.globalSettings - settings.notificationPreferences.setEnabled(enabled, for: category) - Task { try? await projectStore.updateGlobalSettings(settings) } - } - ) + detailContent } } diff --git a/Echo/Sources/Features/Preferences/Views/QueryResultsSettings/ResultGridColorSettingsSection.swift b/Echo/Sources/Features/Preferences/Views/QueryResultsSettings/ResultGridColorSettingsSection.swift index 576a15f1c..8920b8f07 100644 --- a/Echo/Sources/Features/Preferences/Views/QueryResultsSettings/ResultGridColorSettingsSection.swift +++ b/Echo/Sources/Features/Preferences/Views/QueryResultsSettings/ResultGridColorSettingsSection.swift @@ -99,7 +99,7 @@ struct ResultGridColorSettingsSection: View { } .padding(SpacingTokens.xs) .background(ColorTokens.Background.secondary.opacity(0.5)) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.small)) } } } diff --git a/Echo/Sources/Features/QueryBuilder/Views/QueryBuilderTableNode.swift b/Echo/Sources/Features/QueryBuilder/Views/QueryBuilderTableNode.swift index 94627e68f..dc884adc5 100644 --- a/Echo/Sources/Features/QueryBuilder/Views/QueryBuilderTableNode.swift +++ b/Echo/Sources/Features/QueryBuilder/Views/QueryBuilderTableNode.swift @@ -42,8 +42,9 @@ struct QueryBuilderTableNode: View { if column.isPrimaryKey { Image(systemName: "key.fill") - .font(.system(size: 8)) - .foregroundStyle(.yellow) + .font(TypographyTokens.compact) + .imageScale(.small) + .foregroundStyle(ColorTokens.Status.warning) } Text(column.name) @@ -63,16 +64,16 @@ struct QueryBuilderTableNode: View { .lineLimit(1) } .padding(.horizontal, SpacingTokens.xs) - .padding(.vertical, 2) + .padding(.vertical, SpacingTokens.xxs2) } } .padding(.vertical, SpacingTokens.xxs2) } .frame(width: 220) .background(ColorTokens.Background.primary) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .clipShape(RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.small)) .overlay( - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.small) .stroke(ColorTokens.Separator.secondary, lineWidth: 1) ) .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) diff --git a/Echo/Sources/Features/QueryWorkspace/Domain/PSQL/PSQLTabViewModel+MetaCommands.swift b/Echo/Sources/Features/QueryWorkspace/Domain/PSQL/PSQLTabViewModel+MetaCommands.swift index 6103c744e..631d9875c 100644 --- a/Echo/Sources/Features/QueryWorkspace/Domain/PSQL/PSQLTabViewModel+MetaCommands.swift +++ b/Echo/Sources/Features/QueryWorkspace/Domain/PSQL/PSQLTabViewModel+MetaCommands.swift @@ -320,9 +320,8 @@ extension PSQLTabViewModel { \\du list roles \\x toggle expanded output - Not supported in the managed console: - shell/file/client-local commands such as \\!, \\i, \\ir, \\o, \\w, \\copy, \\cd, \\set, \\watch - These belong to native psql, which is intended for exact CLI compatibility. + Not available in the managed console (use "Open in psql" for these): + \\!, \\i, \\ir, \\o, \\w, \\copy, \\cd, \\set, \\watch """ } @@ -334,7 +333,7 @@ extension PSQLTabViewModel { func unsupportedCommandMessage(for command: String) -> String { let nativeOnlyCommands: Set = ["\\!", "\\i", "\\ir", "\\o", "\\w", "\\copy", "\\cd", "\\set", "\\watch", "\\prompt", "\\password"] if nativeOnlyCommands.contains(command) { - return "ERROR: \(command) is a native psql client command and is not supported in Echo's managed Postgres Console.\n" + return "ERROR: \(command) requires the native psql CLI. Use \"Open in psql\" from the database context menu.\n" } return "ERROR: Unsupported psql command: \(command)\n" } diff --git a/Echo/Sources/Features/QueryWorkspace/Domain/PSQL/PSQLTabViewModel.swift b/Echo/Sources/Features/QueryWorkspace/Domain/PSQL/PSQLTabViewModel.swift index 6589b2737..008d1040e 100644 --- a/Echo/Sources/Features/QueryWorkspace/Domain/PSQL/PSQLTabViewModel.swift +++ b/Echo/Sources/Features/QueryWorkspace/Domain/PSQL/PSQLTabViewModel.swift @@ -41,8 +41,8 @@ final class PSQLTabViewModel: Identifiable { let version = connection.serverVersion ?? "unknown" history = "Postgres Console (Echo), server \(version)\n" - history += "This is Echo's managed PostgreSQL console powered by a dedicated connection.\n" - history += "Native psql is a separate feature and is not wired into this build yet.\n\n" + history += "Type SQL or use backslash commands (\\? for help).\n" + history += "For the native psql CLI, right-click a database and choose \"Open in psql\".\n\n" prompt() Task { await resolveActiveDatabase() diff --git a/Echo/Sources/Features/QueryWorkspace/Domain/QueryEditorState/QueryEditorState.swift b/Echo/Sources/Features/QueryWorkspace/Domain/QueryEditorState/QueryEditorState.swift index 8d58868ef..4a21e758a 100644 --- a/Echo/Sources/Features/QueryWorkspace/Domain/QueryEditorState/QueryEditorState.swift +++ b/Echo/Sources/Features/QueryWorkspace/Domain/QueryEditorState/QueryEditorState.swift @@ -21,6 +21,7 @@ import OSLog var currentExecutionTime: TimeInterval = 0 var rowProgress: RowProgress = RowProgress() var messages: [QueryExecutionMessage] = [] + var prefersMessagesAfterExecution: Bool = false var hasExecutedAtLeastOnce: Bool = false var splitRatio: CGFloat = 0.5 var wasCancelled: Bool = false diff --git a/Echo/Sources/Features/QueryWorkspace/Domain/TableStructureEditor/TableStructureEditorViewModel+Logic.swift b/Echo/Sources/Features/QueryWorkspace/Domain/TableStructureEditor/TableStructureEditorViewModel+Logic.swift index 85b2d29e9..d1ed33f9f 100644 --- a/Echo/Sources/Features/QueryWorkspace/Domain/TableStructureEditor/TableStructureEditorViewModel+Logic.swift +++ b/Echo/Sources/Features/QueryWorkspace/Domain/TableStructureEditor/TableStructureEditorViewModel+Logic.swift @@ -1,7 +1,7 @@ import Foundation extension TableStructureEditorViewModel { - + func reset(to details: TableStructureDetails) { apply(details: details) } @@ -48,19 +48,20 @@ extension TableStructureEditorViewModel { let columns = index.columns.map { column in IndexModel.Column(name: column.name, sortOrder: column.sortOrder == .descending ? .descending : .ascending, isIncluded: column.isIncluded) } + let resolvedIndexType = resolvedIndexType(for: index.indexType) return IndexModel( original: IndexModel.Snapshot( name: index.name, columns: columns.map { $0.snapshot }, isUnique: index.isUnique, filterCondition: index.filterCondition, - indexType: index.indexType + indexType: resolvedIndexType ), name: index.name, columns: columns, isUnique: index.isUnique, filterCondition: index.filterCondition ?? "", - indexType: index.indexType ?? (databaseType == .microsoftSQL ? "nonclustered" : "btree") + indexType: resolvedIndexType ) } @@ -137,6 +138,14 @@ extension TableStructureEditorViewModel { tableProperties = details.tableProperties } + private func resolvedIndexType(for indexType: String?) -> String { + if let trimmed = indexType?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { + return trimmed + } + + return databaseType == .microsoftSQL ? "nonclustered" : "btree" + } + internal func generateStatements() -> [String] { let dialect = dialectGenerator let qualifiedTable = dialect.qualifiedTable(schema: schemaName, table: tableName) diff --git a/Echo/Sources/Features/QueryWorkspace/Domain/TableStructureEditor/TableStructureEditorViewModel.swift b/Echo/Sources/Features/QueryWorkspace/Domain/TableStructureEditor/TableStructureEditorViewModel.swift index 74c975421..f360bafc6 100644 --- a/Echo/Sources/Features/QueryWorkspace/Domain/TableStructureEditor/TableStructureEditorViewModel.swift +++ b/Echo/Sources/Features/QueryWorkspace/Domain/TableStructureEditor/TableStructureEditorViewModel.swift @@ -41,6 +41,14 @@ enum TableStructureSection: String, CaseIterable, Identifiable { @MainActor @Observable final class TableStructureEditorViewModel { + enum PendingAddAction: Equatable { + case column + case index + case foreignKey + case uniqueConstraint + case checkConstraint + } + var columns: [ColumnModel] = [] var indexes: [IndexModel] = [] var uniqueConstraints: [UniqueConstraintModel] = [] @@ -50,11 +58,13 @@ final class TableStructureEditorViewModel { var primaryKey: PrimaryKeyModel? var tableProperties: TableStructureDetails.TableProperties? var requestedSection: TableStructureSection? + var pendingAddAction: PendingAddAction? var isLoading: Bool = false var isApplying: Bool = false var lastError: String? var lastSuccessMessage: String? + private var isInitialized: Bool = false /// nil = not yet checked, true = has data, false = no data var partitionsAvailable: Bool? @@ -69,6 +79,7 @@ final class TableStructureEditorViewModel { internal var removedPrimaryKeyName: String? @ObservationIgnored var activityEngine: ActivityEngine? @ObservationIgnored var connectionSessionID: UUID? + @ObservationIgnored let sheetCoordinator = TableStructureSheetCoordinator() /// Update the session to point at a different database connection. /// Always triggers a reload since the previous session may have returned empty results. @@ -98,6 +109,8 @@ final class TableStructureEditorViewModel { // so the view shows a spinner immediately instead of an empty state flash. if details.columns.isEmpty { isLoading = true + } else { + isInitialized = true } } @@ -105,12 +118,18 @@ final class TableStructureEditorViewModel { requestedSection = section } + func requestAddAction(_ action: PendingAddAction, section: TableStructureSection) { + requestedSection = section + pendingAddAction = action + } + func reload() async { isLoading = true defer { isLoading = false } do { let details = try await session.getTableStructureDetails(schema: schemaName, table: tableName) apply(details: details) + isInitialized = true } catch { lastError = error.localizedDescription } @@ -124,23 +143,22 @@ final class TableStructureEditorViewModel { isApplying = true defer { isApplying = false } let handle = activityEngine?.begin("Alter \(tableName)", connectionSessionID: connectionSessionID) - let dialect = dialectGenerator do { - for statement in [dialect.beginTransaction()] + statements + [dialect.commitTransaction()] { - _ = try await session.executeUpdate(statement) - } + try await session.executeUpdatesAtomically(statements) let refreshed = try await session.getTableStructureDetails(schema: schemaName, table: tableName) apply(details: refreshed) lastSuccessMessage = "Structure updated" handle?.succeed() } catch { - _ = try? await session.executeUpdate(dialect.rollbackTransaction()) lastError = error.localizedDescription handle?.fail(error.localizedDescription) } } var hasPendingChanges: Bool { + // Don't report pending changes during initial load or reload + guard !isLoading && isInitialized else { return false } + if columns.contains(where: { $0.isDirty }) { return true } if indexes.contains(where: { $0.isDirty }) { return true } if uniqueConstraints.contains(where: { $0.isDirty }) { return true } diff --git a/Echo/Sources/Features/QueryWorkspace/Formatting/SQLFormatter.swift b/Echo/Sources/Features/QueryWorkspace/Formatting/SQLFormatter.swift index b815674a0..6a03859f6 100644 --- a/Echo/Sources/Features/QueryWorkspace/Formatting/SQLFormatter.swift +++ b/Echo/Sources/Features/QueryWorkspace/Formatting/SQLFormatter.swift @@ -102,10 +102,18 @@ final class SQLFormatter: SQLFormatterProtocol, Sendable { throw SQLFormatterError.formattingFailed(message) } + if result?.isUndefined == true || result?.isNull == true { + return sql + } + guard let formatted = result?.toString(), !formatted.isEmpty else { return sql } + if formatted == "undefined" || formatted == "null" { + return sql + } + return formatted } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/ExtensionStructure/PostgresExtensionStructureView.swift b/Echo/Sources/Features/QueryWorkspace/Views/ExtensionStructure/PostgresExtensionStructureView.swift index 02c3b6b82..315b20780 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/ExtensionStructure/PostgresExtensionStructureView.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/ExtensionStructure/PostgresExtensionStructureView.swift @@ -99,11 +99,11 @@ struct PostgresExtensionStructureView: View { ProgressView().controlSize(.small) } else { Label("Update to v\(viewModel.latestVersion ?? "?")", systemImage: "arrow.up.circle.fill") - .foregroundStyle(Color.white) + .foregroundStyle(.white) .padding(.horizontal, SpacingTokens.xs) .padding(.vertical, SpacingTokens.xxs) .background(ColorTokens.Status.info) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.small)) } } .buttonStyle(.plain) diff --git a/Echo/Sources/Features/QueryWorkspace/Views/Results/Section/QueryResultsSection+Logic.swift b/Echo/Sources/Features/QueryWorkspace/Views/Results/Section/QueryResultsSection+Logic.swift index 19c02bf75..beb1cbb8d 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/Results/Section/QueryResultsSection+Logic.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/Results/Section/QueryResultsSection+Logic.swift @@ -1,6 +1,10 @@ import SwiftUI extension QueryResultsSection { + private var prefersMessagesAfterExecution: Bool { + guard query.errorMessage == nil, !query.isExecuting else { return false } + return query.prefersMessagesAfterExecution + } internal func handleResultTokenChange() { let newIDs = tableColumns.map(\.id) @@ -9,8 +13,10 @@ extension QueryResultsSection { sortCriteria = nil highlightedColumnIndex = nil } - if panelState.selectedSegment == .messages, query.errorMessage == nil, - !(connection.databaseType == .microsoftSQL && query.statisticsEnabled) { + if prefersMessagesAfterExecution { + panelState.selectedSegment = .messages + if !panelState.isOpen { panelState.isOpen = true } + } else if panelState.selectedSegment == .messages, query.errorMessage == nil { panelState.selectedSegment = .results } rebuildRowOrder() @@ -25,11 +31,7 @@ extension QueryResultsSection { panelState.selectedSegment = .results } } else { - // When statistics mode is on, auto-switch to Messages so the user - // sees the IO/TIME output without having to click manually. - if connection.databaseType == .microsoftSQL, - query.statisticsEnabled, - query.errorMessage == nil { + if prefersMessagesAfterExecution { panelState.selectedSegment = .messages if !panelState.isOpen { panelState.isOpen = true } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/Results/Spatial/SpatialBrowserLinkBuilder.swift b/Echo/Sources/Features/QueryWorkspace/Views/Results/Spatial/SpatialBrowserLinkBuilder.swift index b5a128e86..dacaa530e 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/Results/Spatial/SpatialBrowserLinkBuilder.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/Results/Spatial/SpatialBrowserLinkBuilder.swift @@ -61,7 +61,11 @@ enum SpatialBrowserLinkBuilder { private static func browserCoordinateString(_ value: Double) -> String { let roundedValue = (value * 1_000_000).rounded() / 1_000_000 - return roundedValue.formatted(.number.precision(.fractionLength(0...6))) + return roundedValue.formatted( + .number + .precision(.fractionLength(0...6)) + .locale(Locale(identifier: "en_US_POSIX")) + ) } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/CheckConstraintEditorSheet+Draft.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/CheckConstraintEditorSheet+Draft.swift index 180c7577c..a6ef91174 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/CheckConstraintEditorSheet+Draft.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/CheckConstraintEditorSheet+Draft.swift @@ -3,8 +3,17 @@ import Foundation extension CheckConstraintEditorSheet { func applyDraftChanges() { - constraint.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) - constraint.expression = draft.expression.trimmingCharacters(in: .whitespacesAndNewlines) + let updatedConstraint = TableStructureEditorViewModel.CheckConstraintModel( + original: constraint.original, + name: draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + expression: draft.expression.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + if draft.isEditingExisting { + constraint = updatedConstraint + } else { + onSaveNew?(updatedConstraint) + } } func cancelIfNew() { diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/CheckConstraintEditorSheet.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/CheckConstraintEditorSheet.swift index 7e1af25c7..2983adcb9 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/CheckConstraintEditorSheet.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/CheckConstraintEditorSheet.swift @@ -4,6 +4,7 @@ struct CheckConstraintEditorSheet: View { @Binding var constraint: TableStructureEditorViewModel.CheckConstraintModel let onDelete: () -> Void let onCancelNew: () -> Void + let onSaveNew: ((TableStructureEditorViewModel.CheckConstraintModel) -> Void)? @Environment(\.dismiss) private var dismiss @State var draft: Draft @@ -11,11 +12,13 @@ struct CheckConstraintEditorSheet: View { init( constraint: Binding, onDelete: @escaping () -> Void, - onCancelNew: @escaping () -> Void + onCancelNew: @escaping () -> Void, + onSaveNew: ((TableStructureEditorViewModel.CheckConstraintModel) -> Void)? = nil ) { self._constraint = constraint self.onDelete = onDelete self.onCancelNew = onCancelNew + self.onSaveNew = onSaveNew _draft = State(initialValue: Draft(model: constraint.wrappedValue)) } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ColumnEditorSheet+Draft.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ColumnEditorSheet+Draft.swift index 2401537be..f14837577 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ColumnEditorSheet+Draft.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ColumnEditorSheet+Draft.swift @@ -84,31 +84,39 @@ extension ColumnEditorSheet { } func applyDraft() { - column.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) - column.dataType = draft.dataType.trimmingCharacters(in: .whitespacesAndNewlines) - column.isNullable = draft.isNullable - let defaultTrimmed = draft.defaultValue.trimmingCharacters(in: .whitespacesAndNewlines) - column.defaultValue = defaultTrimmed.isEmpty ? nil : defaultTrimmed - let expressionTrimmed = draft.generatedExpression.trimmingCharacters(in: .whitespacesAndNewlines) - column.generatedExpression = expressionTrimmed.isEmpty ? nil : expressionTrimmed - - column.isIdentity = draft.isIdentity - column.identitySeed = draft.isIdentity ? Int(draft.identitySeed) : nil - column.identityIncrement = draft.isIdentity ? Int(draft.identityIncrement) : nil - column.identityGeneration = draft.isIdentity ? draft.identityGeneration : nil - let collationTrimmed = draft.collation.trimmingCharacters(in: .whitespacesAndNewlines) - column.collation = collationTrimmed.isEmpty ? nil : collationTrimmed let characterSetTrimmed = draft.characterSet.trimmingCharacters(in: .whitespacesAndNewlines) - column.characterSet = characterSetTrimmed.isEmpty ? nil : characterSetTrimmed let commentTrimmed = draft.comment.trimmingCharacters(in: .whitespacesAndNewlines) - column.comment = commentTrimmed.isEmpty ? nil : commentTrimmed - column.isUnsigned = draft.isUnsigned - column.isZerofill = draft.isZerofill + let updatedColumn = TableStructureEditorViewModel.ColumnModel( + original: column.original, + name: draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + dataType: draft.dataType.trimmingCharacters(in: .whitespacesAndNewlines), + isNullable: draft.isNullable, + defaultValue: defaultTrimmed.isEmpty ? nil : defaultTrimmed, + generatedExpression: expressionTrimmed.isEmpty ? nil : expressionTrimmed, + isIdentity: draft.isIdentity, + identitySeed: draft.isIdentity ? Int(draft.identitySeed) : nil, + identityIncrement: draft.isIdentity ? Int(draft.identityIncrement) : nil, + identityGeneration: draft.isIdentity ? draft.identityGeneration : nil, + collation: collationTrimmed.isEmpty ? nil : collationTrimmed, + characterSet: characterSetTrimmed.isEmpty ? nil : characterSetTrimmed, + comment: commentTrimmed.isEmpty ? nil : commentTrimmed, + isUnsigned: draft.isUnsigned, + isZerofill: draft.isZerofill, + ordinalPosition: column.ordinalPosition + ) - dismiss() + if draft.isEditingExisting { + column = updatedColumn + dismiss() + } else { + dismiss() + Task { @MainActor in + onSaveNew?(updatedColumn) + } + } } func cancelEditing() { diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ColumnEditorSheet.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ColumnEditorSheet.swift index 605c8d521..01fe10217 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ColumnEditorSheet.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ColumnEditorSheet.swift @@ -9,6 +9,7 @@ struct ColumnEditorSheet: View { let databaseType: DatabaseType let onDelete: () -> Void let onCancelNew: () -> Void + let onSaveNew: ((TableStructureEditorViewModel.ColumnModel) -> Void)? @Environment(\.dismiss) internal var dismiss @State internal var draft: Draft @@ -17,12 +18,14 @@ struct ColumnEditorSheet: View { column: Binding, databaseType: DatabaseType, onDelete: @escaping () -> Void, - onCancelNew: @escaping () -> Void + onCancelNew: @escaping () -> Void, + onSaveNew: ((TableStructureEditorViewModel.ColumnModel) -> Void)? = nil ) { self._column = column self.databaseType = databaseType self.onDelete = onDelete self.onCancelNew = onCancelNew + self.onSaveNew = onSaveNew _draft = State(initialValue: Draft(model: column.wrappedValue, databaseType: databaseType)) } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ForeignKeyEditorSheet+Draft.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ForeignKeyEditorSheet+Draft.swift index fc092d5b7..3463d5dfd 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ForeignKeyEditorSheet+Draft.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ForeignKeyEditorSheet+Draft.swift @@ -4,22 +4,30 @@ import Foundation extension ForeignKeyEditorSheet { func applyDraftToModel() { - foreignKey.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) - foreignKey.columns = draft.mappings.map(\.localColumn) - foreignKey.referencedSchema = draft.referencedSchema.trimmingCharacters(in: .whitespacesAndNewlines) - foreignKey.referencedTable = draft.referencedTable.trimmingCharacters(in: .whitespacesAndNewlines) - foreignKey.referencedColumns = draft.mappings.map { + let referencedColumns = draft.mappings.map { $0.referencedColumn.trimmingCharacters(in: .whitespacesAndNewlines) } let updateValue = draft.onUpdate.trimmingCharacters(in: .whitespacesAndNewlines) - foreignKey.onUpdate = updateValue.isEmpty || updateValue == ForeignKeyAction.noAction.rawValue ? nil : updateValue - let deleteValue = draft.onDelete.trimmingCharacters(in: .whitespacesAndNewlines) - foreignKey.onDelete = deleteValue.isEmpty || deleteValue == ForeignKeyAction.noAction.rawValue ? nil : deleteValue + let updatedForeignKey = TableStructureEditorViewModel.ForeignKeyModel( + original: foreignKey.original, + name: draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + columns: draft.mappings.map(\.localColumn), + referencedSchema: draft.referencedSchema.trimmingCharacters(in: .whitespacesAndNewlines), + referencedTable: draft.referencedTable.trimmingCharacters(in: .whitespacesAndNewlines), + referencedColumns: referencedColumns, + onUpdate: updateValue.isEmpty || updateValue == ForeignKeyAction.noAction.rawValue ? nil : updateValue, + onDelete: deleteValue.isEmpty || deleteValue == ForeignKeyAction.noAction.rawValue ? nil : deleteValue, + isDeferrable: draft.isDeferrable, + isInitiallyDeferred: draft.isInitiallyDeferred + ) - foreignKey.isDeferrable = draft.isDeferrable - foreignKey.isInitiallyDeferred = draft.isInitiallyDeferred + if draft.isEditingExisting { + foreignKey = updatedForeignKey + } else { + onSaveNew?(updatedForeignKey) + } } struct Draft { diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ForeignKeyEditorSheet.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ForeignKeyEditorSheet.swift index cd5b08f71..b956c836b 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ForeignKeyEditorSheet.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/ForeignKeyEditorSheet.swift @@ -11,6 +11,7 @@ struct ForeignKeyEditorSheet: View { let session: DatabaseSession let onDelete: () -> Void let onCancelNew: () -> Void + let onSaveNew: ((TableStructureEditorViewModel.ForeignKeyModel) -> Void)? @Environment(\.dismiss) var dismiss @State var draft: Draft @@ -24,7 +25,8 @@ struct ForeignKeyEditorSheet: View { databaseType: DatabaseType, session: DatabaseSession, onDelete: @escaping () -> Void, - onCancelNew: @escaping () -> Void + onCancelNew: @escaping () -> Void, + onSaveNew: ((TableStructureEditorViewModel.ForeignKeyModel) -> Void)? = nil ) { self._foreignKey = foreignKey self.availableColumns = availableColumns @@ -32,6 +34,7 @@ struct ForeignKeyEditorSheet: View { self.session = session self.onDelete = onDelete self.onCancelNew = onCancelNew + self.onSaveNew = onSaveNew _draft = State(initialValue: Draft(model: foreignKey.wrappedValue, availableColumns: availableColumns)) } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/IndexEditorSheet+Draft.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/IndexEditorSheet+Draft.swift index 86f69165d..72ffbf3be 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/IndexEditorSheet+Draft.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/IndexEditorSheet+Draft.swift @@ -49,12 +49,21 @@ extension IndexEditorSheet { } func applyDraft() { - index.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) - index.isUnique = draft.isUnique - index.filterCondition = draft.filterCondition.trimmingCharacters(in: .whitespacesAndNewlines) - index.indexType = draft.indexType - index.columns = draft.columns.map { column in + let updatedIndex = TableStructureEditorViewModel.IndexModel( + original: index.original, + name: draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + columns: draft.columns.map { column in TableStructureEditorViewModel.IndexModel.Column(name: column.name, sortOrder: column.sortOrder, isIncluded: column.isIncluded) + }, + isUnique: draft.isUnique, + filterCondition: draft.filterCondition.trimmingCharacters(in: .whitespacesAndNewlines), + indexType: draft.indexType + ) + + if draft.isEditingExisting { + index = updatedIndex + } else { + onSaveNew?(updatedIndex) } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/IndexEditorSheet.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/IndexEditorSheet.swift index 5ae17653b..f004b5be2 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/IndexEditorSheet.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/IndexEditorSheet.swift @@ -6,6 +6,7 @@ struct IndexEditorSheet: View { let databaseType: DatabaseType let onDelete: () -> Void let onCancelNew: () -> Void + let onSaveNew: ((TableStructureEditorViewModel.IndexModel) -> Void)? @Environment(\.dismiss) internal var dismiss @State internal var draft: Draft @@ -17,13 +18,15 @@ struct IndexEditorSheet: View { availableColumns: [String], databaseType: DatabaseType, onDelete: @escaping () -> Void, - onCancelNew: @escaping () -> Void + onCancelNew: @escaping () -> Void, + onSaveNew: ((TableStructureEditorViewModel.IndexModel) -> Void)? = nil ) { self._index = index self.availableColumns = availableColumns self.databaseType = databaseType self.onDelete = onDelete self.onCancelNew = onCancelNew + self.onSaveNew = onSaveNew _draft = State(initialValue: Draft(model: index.wrappedValue, availableColumns: availableColumns)) } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/PrimaryKeyEditorSheet+Draft.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/PrimaryKeyEditorSheet+Draft.swift index 2e0a86a89..d03544603 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/PrimaryKeyEditorSheet+Draft.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/PrimaryKeyEditorSheet+Draft.swift @@ -3,10 +3,19 @@ import Foundation extension PrimaryKeyEditorSheet { func applyDraftChanges() { - primaryKey.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) - primaryKey.columns = draft.columns.map { $0.name } - primaryKey.isDeferrable = draft.isDeferrable - primaryKey.isInitiallyDeferred = draft.isInitiallyDeferred + let updatedPrimaryKey = TableStructureEditorViewModel.PrimaryKeyModel( + original: primaryKey.original, + name: draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + columns: draft.columns.map { $0.name }, + isDeferrable: draft.isDeferrable, + isInitiallyDeferred: draft.isInitiallyDeferred + ) + + if draft.isEditingExisting { + primaryKey = updatedPrimaryKey + } else { + onSaveNew?(updatedPrimaryKey) + } } func cancelIfNew() { diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/PrimaryKeyEditorSheet.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/PrimaryKeyEditorSheet.swift index 8d65e0363..2c0d09462 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/PrimaryKeyEditorSheet.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/PrimaryKeyEditorSheet.swift @@ -6,6 +6,7 @@ struct PrimaryKeyEditorSheet: View { let databaseType: DatabaseType let onDelete: () -> Void let onCancelNew: () -> Void + let onSaveNew: ((TableStructureEditorViewModel.PrimaryKeyModel) -> Void)? @Environment(\.dismiss) private var dismiss @State var draft: Draft @@ -15,13 +16,15 @@ struct PrimaryKeyEditorSheet: View { availableColumns: [String], databaseType: DatabaseType, onDelete: @escaping () -> Void, - onCancelNew: @escaping () -> Void + onCancelNew: @escaping () -> Void, + onSaveNew: ((TableStructureEditorViewModel.PrimaryKeyModel) -> Void)? = nil ) { self._primaryKey = primaryKey self.availableColumns = availableColumns self.databaseType = databaseType self.onDelete = onDelete self.onCancelNew = onCancelNew + self.onSaveNew = onSaveNew _draft = State(initialValue: Draft(model: primaryKey.wrappedValue, availableColumns: availableColumns)) } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/StructureApplyReviewSheet.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/StructureApplyReviewSheet.swift new file mode 100644 index 000000000..83886fea6 --- /dev/null +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/StructureApplyReviewSheet.swift @@ -0,0 +1,153 @@ +import SwiftUI + +struct StructureApplyReviewSheet: View { + private struct StatementDescriptor: Identifiable { + let id = UUID() + let title: String + let symbol: String + let statement: String + } + + let tableName: String + let statements: [String] + let onApply: () async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var isApplying = false + @State private var errorMessage: String? + + private var descriptors: [StatementDescriptor] { + statements.map { statement in + StatementDescriptor( + title: statementTitle(for: statement), + symbol: statementSymbol(for: statement), + statement: statement + ) + } + } + + var body: some View { + SheetLayout( + title: "Review Changes", + icon: "list.clipboard", + subtitle: "Echo will apply \(statements.count) change\(statements.count == 1 ? "" : "s") to \(tableName).", + primaryAction: "Apply Changes", + canSubmit: !statements.isEmpty && !isApplying, + isSubmitting: isApplying, + errorMessage: errorMessage, + onSubmit: { await submit() }, + onCancel: { dismiss() } + ) { + ScrollView { + VStack(alignment: .leading, spacing: SpacingTokens.md) { + overviewCard + + ForEach(Array(descriptors.enumerated()), id: \.element.id) { offset, descriptor in + statementCard(descriptor, index: offset + 1) + } + } + .padding(SpacingTokens.lg) + } + .background(ColorTokens.Background.secondary.opacity(0.35)) + } + .frame(minWidth: 700, idealWidth: 820, minHeight: 480, idealHeight: 620) + } + + private var overviewCard: some View { + HStack(spacing: SpacingTokens.sm) { + Image(systemName: "server.rack") + .font(TypographyTokens.prominent) + .foregroundStyle(ColorTokens.Status.info) + + VStack(alignment: .leading, spacing: SpacingTokens.xxxs) { + Text(tableName) + .font(TypographyTokens.prominent.weight(.semibold)) + Text("Review the exact SQL below before Echo sends it to the server.") + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Text.secondary) + } + + Spacer() + + CountBadge(count: statements.count, tint: ColorTokens.Status.info, opacity: 0.12) + } + .padding(SpacingTokens.md) + .background(ColorTokens.Background.primary, in: RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.medium, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.medium, style: .continuous) + .strokeBorder(ColorTokens.Text.primary.opacity(0.08)) + } + } + + private func statementCard(_ descriptor: StatementDescriptor, index: Int) -> some View { + VStack(alignment: .leading, spacing: SpacingTokens.sm) { + HStack(spacing: SpacingTokens.xs) { + Image(systemName: descriptor.symbol) + .font(TypographyTokens.standard) + .foregroundStyle(ColorTokens.Status.info) + + Text("\(index). \(descriptor.title)") + .font(TypographyTokens.standard.weight(.semibold)) + .foregroundStyle(ColorTokens.Text.primary) + + Spacer() + } + + Text(descriptor.statement) + .font(TypographyTokens.code) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(SpacingTokens.md) + .background(ColorTokens.Background.secondary.opacity(0.45), in: RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.small, style: .continuous)) + } + .padding(SpacingTokens.md) + .background(ColorTokens.Background.primary, in: RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.medium, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.medium, style: .continuous) + .strokeBorder(ColorTokens.Text.primary.opacity(0.08)) + } + } + + private func submit() async { + isApplying = true + errorMessage = nil + let didApply = await onApply() + isApplying = false + + if didApply { + dismiss() + } else { + errorMessage = "Failed to apply the reviewed changes." + } + } + + private func statementTitle(for statement: String) -> String { + let uppercased = statement.uppercased() + + if uppercased.contains(" ADD COLUMN ") { return "Add Column" } + if uppercased.contains(" DROP COLUMN ") { return "Drop Column" } + if uppercased.contains(" ALTER COLUMN ") { return "Alter Column" } + if uppercased.contains(" RENAME COLUMN ") || uppercased.contains("SP_RENAME") { return "Rename Column" } + if uppercased.contains("CREATE") && uppercased.contains("INDEX") { return "Create Index" } + if uppercased.contains("DROP INDEX") { return "Drop Index" } + if uppercased.contains("PRIMARY KEY") { return "Primary Key Change" } + if uppercased.contains("FOREIGN KEY") { return "Foreign Key Change" } + if uppercased.contains("CHECK") && uppercased.contains("CONSTRAINT") { return "Check Constraint Change" } + if uppercased.contains("UNIQUE") && uppercased.contains("CONSTRAINT") { return "Unique Constraint Change" } + if uppercased.contains("ADD CONSTRAINT") { return "Add Constraint" } + if uppercased.contains("DROP CONSTRAINT") { return "Drop Constraint" } + return "Schema Change" + } + + private func statementSymbol(for statement: String) -> String { + let uppercased = statement.uppercased() + + if uppercased.contains("INDEX") { return "list.bullet.rectangle" } + if uppercased.contains("FOREIGN KEY") { return "link" } + if uppercased.contains("PRIMARY KEY") { return "key" } + if uppercased.contains("CHECK") || uppercased.contains("UNIQUE") || uppercased.contains("CONSTRAINT") { + return "checkmark.shield" + } + return "tablecells" + } +} diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/StructureScriptPreviewSheet.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/StructureScriptPreviewSheet.swift new file mode 100644 index 000000000..2d4757de4 --- /dev/null +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/StructureScriptPreviewSheet.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct StructureScriptPreviewSheet: View { + let context: SQLPopoutContext + let onOpenInWindow: (_ sql: String, _ database: String?) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var formattedSQL: String? + + private var displaySQL: String { formattedSQL ?? context.sql } + private var canOpenInQueryWindow: Bool { + !displaySQL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + SheetLayoutCustomFooter(title: context.title) { + ScrollView { + Text(displaySQL) + .font(TypographyTokens.code) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(SpacingTokens.lg) + } + .background(ColorTokens.Background.secondary.opacity(0.5)) + } footer: { + Button("Copy SQL") { + PlatformClipboard.copy(displaySQL) + } + .buttonStyle(.bordered) + .disabled(!canOpenInQueryWindow) + + Spacer() + + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button("Open in Query Window…") { + onOpenInWindow(displaySQL, context.databaseName) + dismiss() + } + .buttonStyle(.bordered) + .disabled(!canOpenInQueryWindow) + } + .frame(minWidth: 640, idealWidth: 760, minHeight: 420, idealHeight: 520) + .task { + if let formatted = try? await SQLFormatter.shared.format(sql: context.sql, dialect: context.formatterDialect) { + formattedSQL = formatted + } + } + } +} diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/UniqueConstraintEditorSheet+Draft.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/UniqueConstraintEditorSheet+Draft.swift index 13851222e..a526c990f 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/UniqueConstraintEditorSheet+Draft.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/UniqueConstraintEditorSheet+Draft.swift @@ -3,10 +3,19 @@ import Foundation extension UniqueConstraintEditorSheet { func applyDraftChanges() { - constraint.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) - constraint.columns = draft.columns.map { $0.name } - constraint.isDeferrable = draft.isDeferrable - constraint.isInitiallyDeferred = draft.isInitiallyDeferred + let updatedConstraint = TableStructureEditorViewModel.UniqueConstraintModel( + original: constraint.original, + name: draft.name.trimmingCharacters(in: .whitespacesAndNewlines), + columns: draft.columns.map { $0.name }, + isDeferrable: draft.isDeferrable, + isInitiallyDeferred: draft.isInitiallyDeferred + ) + + if draft.isEditingExisting { + constraint = updatedConstraint + } else { + onSaveNew?(updatedConstraint) + } } func cancelIfNew() { diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/UniqueConstraintEditorSheet.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/UniqueConstraintEditorSheet.swift index 88c0fd257..a0d456952 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/UniqueConstraintEditorSheet.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/Sheets/UniqueConstraintEditorSheet.swift @@ -6,6 +6,7 @@ struct UniqueConstraintEditorSheet: View { let databaseType: DatabaseType let onDelete: () -> Void let onCancelNew: () -> Void + let onSaveNew: ((TableStructureEditorViewModel.UniqueConstraintModel) -> Void)? @Environment(\.dismiss) private var dismiss @State var draft: Draft @@ -15,13 +16,15 @@ struct UniqueConstraintEditorSheet: View { availableColumns: [String], databaseType: DatabaseType, onDelete: @escaping () -> Void, - onCancelNew: @escaping () -> Void + onCancelNew: @escaping () -> Void, + onSaveNew: ((TableStructureEditorViewModel.UniqueConstraintModel) -> Void)? = nil ) { self._constraint = constraint self.availableColumns = availableColumns self.databaseType = databaseType self.onDelete = onDelete self.onCancelNew = onCancelNew + self.onSaveNew = onSaveNew _draft = State(initialValue: Draft(model: constraint.wrappedValue, availableColumns: availableColumns)) } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ColumnActions.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ColumnActions.swift index 657f1dde6..b6bbe4247 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ColumnActions.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ColumnActions.swift @@ -12,7 +12,7 @@ extension TableStructureEditorView { internal func presentBulkEditor(mode: BulkColumnEditorPresentation.Mode, columns: [TableStructureEditorViewModel.ColumnModel]) { guard !columns.isEmpty else { return } - activeSheet = .bulkColumn(BulkColumnEditorPresentation(mode: mode, columnIDs: columns.map(\.id))) + viewModel.sheetCoordinator.activeSheet = .bulkColumn(BulkColumnEditorPresentation(mode: mode, columnIDs: columns.map(\.id))) } internal func pruneSelectedColumns() { @@ -21,12 +21,11 @@ extension TableStructureEditorView { } internal func presentNewColumn() { - let model = viewModel.addColumn() - activeSheet = .column(ColumnEditorPresentation(columnID: model.id, isNew: true)) + viewModel.sheetCoordinator.activeSheet = .newColumn } internal func presentColumnEditor(for column: TableStructureEditorViewModel.ColumnModel) { - activeSheet = .column(ColumnEditorPresentation(columnID: column.id, isNew: column.isNew)) + viewModel.sheetCoordinator.activeSheet = .column(ColumnEditorPresentation(columnID: column.id, isNew: column.isNew)) } internal func columnChangeDescription(for column: TableStructureEditorViewModel.ColumnModel) -> String? { diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Constraints.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Constraints.swift index 4ad179734..4ac8c76f5 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Constraints.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Constraints.swift @@ -6,35 +6,46 @@ extension TableStructureEditorView { internal func presentPrimaryKeyEditor(isNew: Bool) { if isNew { - viewModel.primaryKey = TableStructureEditorViewModel.PrimaryKeyModel( + viewModel.sheetCoordinator.pendingNewPrimaryKey = TableStructureEditorViewModel.PrimaryKeyModel( original: nil, name: "pk_\(viewModel.tableName)", columns: [], isDeferrable: false, isInitiallyDeferred: false ) - viewModel.clearPrimaryKeyRemoval() + viewModel.sheetCoordinator.activeSheet = .newPrimaryKey + return } guard viewModel.primaryKey != nil else { return } - activeSheet = .primaryKey(PrimaryKeyEditorPresentation(isNew: isNew)) + viewModel.sheetCoordinator.activeSheet = .primaryKey(PrimaryKeyEditorPresentation(isNew: isNew)) } internal func presentNewUniqueConstraint() { - let model = viewModel.addUniqueConstraint() - activeSheet = .uniqueConstraint(UniqueConstraintEditorPresentation(constraintID: model.id, isNew: true)) + viewModel.sheetCoordinator.pendingNewUniqueConstraint = TableStructureEditorViewModel.UniqueConstraintModel( + original: nil, + name: "uq_\(viewModel.tableName)_\(viewModel.uniqueConstraints.count + 1)", + columns: [], + isDeferrable: false, + isInitiallyDeferred: false + ) + viewModel.sheetCoordinator.activeSheet = .newUniqueConstraint } internal func presentUniqueConstraintEditor(for constraint: TableStructureEditorViewModel.UniqueConstraintModel) { - activeSheet = .uniqueConstraint(UniqueConstraintEditorPresentation(constraintID: constraint.id, isNew: constraint.isNew)) + viewModel.sheetCoordinator.activeSheet = .uniqueConstraint(UniqueConstraintEditorPresentation(constraintID: constraint.id, isNew: constraint.isNew)) } internal func presentNewCheckConstraint() { - let model = viewModel.addCheckConstraint() - activeSheet = .checkConstraint(CheckConstraintEditorPresentation(constraintID: model.id, isNew: true)) + viewModel.sheetCoordinator.pendingNewCheckConstraint = TableStructureEditorViewModel.CheckConstraintModel( + original: nil, + name: "ck_\(viewModel.tableName)_\(viewModel.checkConstraints.count + 1)", + expression: "" + ) + viewModel.sheetCoordinator.activeSheet = .newCheckConstraint } internal func presentCheckConstraintEditor(for constraint: TableStructureEditorViewModel.CheckConstraintModel) { - activeSheet = .checkConstraint(CheckConstraintEditorPresentation(constraintID: constraint.id, isNew: constraint.isNew)) + viewModel.sheetCoordinator.activeSheet = .checkConstraint(CheckConstraintEditorPresentation(constraintID: constraint.id, isNew: constraint.isNew)) } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ConstraintsTable.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ConstraintsTable.swift index 39a8b826b..0f005495f 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ConstraintsTable.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ConstraintsTable.swift @@ -122,6 +122,14 @@ extension TableStructureEditorView { } .tableStyle(.inset(alternatesRowBackgrounds: true)) .tableColumnAutoResize() + .onContinuousHover { phase in + switch phase { + case .active: + NSCursor.arrow.set() + case .ended: + break + } + } } @ViewBuilder diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ForeignKeys.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ForeignKeys.swift index 57f485326..a44877f5d 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ForeignKeys.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+ForeignKeys.swift @@ -38,7 +38,7 @@ extension TableStructureEditorView { TableColumn("Kind") { _ in Text("FK") .font(TypographyTokens.Table.kindBadge) - .foregroundStyle(.green) + .foregroundStyle(ColorTokens.Status.success) } .width(35) @@ -106,6 +106,14 @@ extension TableStructureEditorView { } .tableStyle(.inset(alternatesRowBackgrounds: true)) .tableColumnAutoResize() + .onContinuousHover { phase in + switch phase { + case .active: + NSCursor.arrow.set() + case .ended: + break + } + } } @ViewBuilder @@ -208,11 +216,24 @@ extension TableStructureEditorView { } internal func presentNewForeignKey() { - let model = viewModel.addForeignKey() - activeSheet = .foreignKey(ForeignKeyEditorPresentation(foreignKeyID: model.id, isNew: true)) + viewModel.sheetCoordinator.pendingNewForeignKey = TableStructureEditorViewModel.ForeignKeyModel( + original: nil, + name: "fk_\(viewModel.tableName)_\(viewModel.foreignKeys.count + 1)", + columns: [], + referencedSchema: viewModel.schemaName, + referencedTable: viewModel.tableName, + referencedColumns: [], + onUpdate: nil, + onDelete: nil, + isDeferrable: false, + isInitiallyDeferred: false + ) + viewModel.sheetCoordinator.activeSheet = .newForeignKey } private func presentForeignKeyEditor(for foreignKey: TableStructureEditorViewModel.ForeignKeyModel) { - activeSheet = .foreignKey(ForeignKeyEditorPresentation(foreignKeyID: foreignKey.id, isNew: foreignKey.isNew)) + viewModel.sheetCoordinator.activeSheet = .foreignKey( + ForeignKeyEditorPresentation(foreignKeyID: foreignKey.id, isNew: foreignKey.isNew) + ) } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Indexes.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Indexes.swift index 2de342a53..01ef0dbb4 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Indexes.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Indexes.swift @@ -10,10 +10,7 @@ extension TableStructureEditorView { } description: { Text("Indexes improve query performance on frequently searched columns.") } actions: { - Button("Add Index") { - let newIndex = viewModel.addIndex() - activeSheet = .index(IndexEditorPresentation(indexID: newIndex.id)) - } + Button("Add Index") { presentNewIndex() } } } else { indexesTable @@ -74,14 +71,11 @@ extension TableStructureEditorView { } .contextMenu(forSelectionType: TableStructureEditorViewModel.IndexModel.ID.self) { selection in if selection.isEmpty { - Button("Add Index") { - let newIndex = viewModel.addIndex() - activeSheet = .index(IndexEditorPresentation(indexID: newIndex.id)) - } + Button("Add Index") { presentNewIndex() } } else if let indexID = selection.first, let index = activeIndexes.first(where: { $0.id == indexID }) { Button("Edit Index") { - activeSheet = .index(IndexEditorPresentation(indexID: index.id)) + viewModel.sheetCoordinator.activeSheet = .index(IndexEditorPresentation(indexID: index.id)) } if !index.isNew { @@ -98,11 +92,19 @@ extension TableStructureEditorView { } } primaryAction: { selection in if let indexID = selection.first { - activeSheet = .index(IndexEditorPresentation(indexID: indexID)) + viewModel.sheetCoordinator.activeSheet = .index(IndexEditorPresentation(indexID: indexID)) } } .tableStyle(.inset(alternatesRowBackgrounds: true)) .tableColumnAutoResize() + .onContinuousHover { phase in + switch phase { + case .active: + NSCursor.arrow.set() + case .ended: + break + } + } } private func rebuildIndex(_ index: TableStructureEditorViewModel.IndexModel) async { @@ -124,4 +126,21 @@ extension TableStructureEditorView { private func indexIncludeColumns(_ index: TableStructureEditorViewModel.IndexModel) -> String { index.columns.filter { $0.isIncluded }.map(\.name).joined(separator: ", ") } + + internal func presentNewIndex() { + let availableColumns = viewModel.columns.filter { !$0.isDeleted } + let initialColumns = availableColumns.prefix(1).map { + TableStructureEditorViewModel.IndexModel.Column(name: $0.name, sortOrder: .ascending, isIncluded: false) + } + let defaultType = viewModel.databaseType == .microsoftSQL ? "nonclustered" : "btree" + viewModel.sheetCoordinator.pendingNewIndex = TableStructureEditorViewModel.IndexModel( + original: nil, + name: "new_index", + columns: Array(initialColumns), + isUnique: false, + filterCondition: "", + indexType: defaultType + ) + viewModel.sheetCoordinator.activeSheet = .newIndex + } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Layout.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Layout.swift index 5bceade61..a404116fe 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Layout.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+Layout.swift @@ -1,7 +1,7 @@ import SwiftUI extension TableStructureEditorView { - + internal func isSectionEnabled(_ section: TableStructureSection) -> Bool { switch section { case .partitions: @@ -12,17 +12,15 @@ extension TableStructureEditorView { return true } } - + internal var header: some View { TabSectionToolbar { structureSectionPicker } controls: { - sectionAddButton - .frame(minWidth: 70, alignment: .trailing) - actionButtons + EmptyView() } } - + private var structureSectionPicker: some View { Picker(selection: $selectedSection) { ForEach(TableStructureSection.sections(for: viewModel.databaseType)) { section in @@ -34,7 +32,7 @@ extension TableStructureEditorView { .pickerStyle(.segmented) .fixedSize() } - + @ViewBuilder private var sectionAddButton: some View { switch selectedSection { @@ -44,17 +42,14 @@ extension TableStructureEditorView { } .controlSize(.small) .buttonStyle(.bordered) - + case .indexes: - Button { - let newIndex = viewModel.addIndex() - activeSheet = .index(IndexEditorPresentation(indexID: newIndex.id)) - } label: { + Button { presentNewIndex() } label: { Label("Add", systemImage: "plus") } .controlSize(.small) .buttonStyle(.bordered) - + case .constraints: Menu { if viewModel.primaryKey == nil { @@ -67,19 +62,19 @@ extension TableStructureEditorView { } .controlSize(.small) .buttonStyle(.bordered) - + case .relations: Button { presentNewForeignKey() } label: { Label("Add", systemImage: "plus") } .controlSize(.small) .buttonStyle(.bordered) - + case .partitions, .inheritance: EmptyView() } } - + internal var content: some View { VStack(spacing: 0) { if viewModel.isLoading && viewModel.columns.isEmpty { @@ -93,16 +88,16 @@ extension TableStructureEditorView { switch selectedSection { case .columns: columnsContent - + case .indexes: indexesContent - + case .constraints: constraintsContent - + case .relations: relationsContent - + case .partitions: if isSectionEnabled(.partitions) { TableStructurePartitionsView(viewModel: viewModel) @@ -113,7 +108,7 @@ extension TableStructureEditorView { Text("This table is not partitioned.") } } - + case .inheritance: if isSectionEnabled(.inheritance) { TableStructureInheritanceView(viewModel: viewModel) @@ -130,32 +125,4 @@ extension TableStructureEditorView { } } } - - @ViewBuilder - private var actionButtons: some View { - Button { - scriptPreviewStatements = viewModel.generateStatements() - activeSheet = .scriptPreview - } label: { - Label("Script", systemImage: "doc.text") - } - .controlSize(.small) - .buttonStyle(.bordered) - .disabled(!viewModel.hasPendingChanges || viewModel.isApplying) - - if viewModel.isApplying { - ProgressView() - .controlSize(.small) - } else { - Button { - applyChanges() - } label: { - Label("Apply", systemImage: "checkmark.circle") - } - .controlSize(.small) - .buttonStyle(.bordered) - .disabled(!viewModel.hasPendingChanges) - .keyboardShortcut(.return, modifiers: [.command, .shift]) - } - } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+SheetModifiers.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+SheetModifiers.swift deleted file mode 100644 index 63855f675..000000000 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView+SheetModifiers.swift +++ /dev/null @@ -1,152 +0,0 @@ -import SwiftUI - -extension TableStructureEditorView { - @ViewBuilder - var sheetModifiers: some View { - Color.clear - .sheet(item: $activeSheet) { sheet in - sheetContent(for: sheet) - } - } - - @ViewBuilder - private func sheetContent(for sheet: TableStructureSheet) -> some View { - switch sheet { - case .index(let presentation): - if let binding = indexBinding(for: presentation.indexID) { - IndexEditorSheet( - index: binding, - availableColumns: viewModel.columns.filter { !$0.isDeleted }.map { $0.name }, - databaseType: tab.connection.databaseType, - onDelete: { - viewModel.removeIndex(binding.wrappedValue) - activeSheet = nil - }, - onCancelNew: { - if binding.wrappedValue.isNew { - viewModel.removeIndex(binding.wrappedValue) - } - activeSheet = nil - } - ) - } - - case .column(let presentation): - if let binding = columnBinding(for: presentation.columnID) { - ColumnEditorSheet( - column: binding, - databaseType: tab.connection.databaseType, - onDelete: { - viewModel.removeColumn(binding.wrappedValue) - activeSheet = nil - }, - onCancelNew: { - if binding.wrappedValue.isNew { - viewModel.removeColumn(binding.wrappedValue) - } - activeSheet = nil - } - ) - } - - case .primaryKey(let presentation): - if let binding = primaryKeyBinding { - PrimaryKeyEditorSheet( - primaryKey: binding, - availableColumns: viewModel.columns.filter { !$0.isDeleted }.map { $0.name }, - databaseType: tab.connection.databaseType, - onDelete: { - viewModel.removePrimaryKey() - activeSheet = nil - }, - onCancelNew: { - if presentation.isNew { - viewModel.removePrimaryKey() - } - activeSheet = nil - } - ) - } - - case .uniqueConstraint(let presentation): - if let binding = uniqueConstraintBinding(for: presentation.constraintID) { - UniqueConstraintEditorSheet( - constraint: binding, - availableColumns: viewModel.columns.filter { !$0.isDeleted }.map { $0.name }, - databaseType: tab.connection.databaseType, - onDelete: { - viewModel.removeUniqueConstraint(binding.wrappedValue) - activeSheet = nil - }, - onCancelNew: { - if binding.wrappedValue.isNew { - viewModel.removeUniqueConstraint(binding.wrappedValue) - } - activeSheet = nil - } - ) - } - - case .foreignKey(let presentation): - if let binding = foreignKeyBinding(for: presentation.foreignKeyID) { - ForeignKeyEditorSheet( - foreignKey: binding, - availableColumns: viewModel.columns.filter { !$0.isDeleted }.map { $0.name }, - databaseType: tab.connection.databaseType, - session: viewModel.session, - onDelete: { - viewModel.removeForeignKey(binding.wrappedValue) - activeSheet = nil - }, - onCancelNew: { - if binding.wrappedValue.isNew { - viewModel.removeForeignKey(binding.wrappedValue) - } - activeSheet = nil - } - ) - } - - case .checkConstraint(let presentation): - if let binding = checkConstraintBinding(for: presentation.constraintID) { - CheckConstraintEditorSheet( - constraint: binding, - onDelete: { - viewModel.removeCheckConstraint(binding.wrappedValue) - activeSheet = nil - }, - onCancelNew: { - if binding.wrappedValue.isNew { - viewModel.removeCheckConstraint(binding.wrappedValue) - } - activeSheet = nil - } - ) - } - - case .scriptPreview: - SQLInspectorSheet( - context: SQLPopoutContext( - sql: scriptPreviewStatements.joined(separator: "\n\n"), - title: "Script Preview" - ) - ) { sql, _ in - if let session = environmentState.sessionGroup.sessionForConnection(tab.connection.id) { - environmentState.openQueryTab(for: session, presetQuery: sql) - } - } - - case .bulkColumn(let presentation): - BulkColumnEditorSheet( - mode: presentation.mode, - columnNames: presentation.columnIDs.compactMap { id in visibleColumns.first(where: { $0.id == id })?.name }, - databaseType: tab.connection.databaseType, - onApply: { value in - let targets = presentation.columnIDs.compactMap { id in columnBinding(for: id) } - applyBulkEdit(mode: presentation.mode, value: value, bindings: targets) - }, - onCancel: { activeSheet = nil } - ) - } - } -} diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView.swift index 4717a75ab..e3d2923a2 100644 --- a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView.swift +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureEditorView.swift @@ -6,43 +6,17 @@ struct TableStructureEditorView: View { @Environment(ProjectStore.self) internal var projectStore @Environment(EnvironmentState.self) internal var environmentState - - @State internal var activeSheet: TableStructureSheet? + @State internal var selectedSection: TableStructureSection @State internal var selectedColumnIDs: Set = [] @State internal var selectedIndexIDs: Set = [] @State internal var selectedForeignKeyIDs: Set = [] @State internal var selectedConstraintIDs: Set = [] - @State internal var scriptPreviewStatements: [String] = [] internal var columnIndexLookup: [UUID: Int] { Dictionary(uniqueKeysWithValues: viewModel.columns.enumerated().map { ($0.element.id, $0.offset) }) } - enum TableStructureSheet: Identifiable { - case index(IndexEditorPresentation) - case column(ColumnEditorPresentation) - case primaryKey(PrimaryKeyEditorPresentation) - case uniqueConstraint(UniqueConstraintEditorPresentation) - case foreignKey(ForeignKeyEditorPresentation) - case checkConstraint(CheckConstraintEditorPresentation) - case scriptPreview - case bulkColumn(BulkColumnEditorPresentation) - - var id: String { - switch self { - case .index(let p): "index-\(p.indexID)" - case .column(let p): "column-\(p.columnID)" - case .primaryKey: "primaryKey" - case .uniqueConstraint(let p): "uniqueConstraint-\(p.constraintID)" - case .foreignKey(let p): "foreignKey-\(p.foreignKeyID)" - case .checkConstraint(let p): "checkConstraint-\(p.constraintID)" - case .scriptPreview: "scriptPreview" - case .bulkColumn: "bulkColumn" - } - } - } - init(tab: WorkspaceTab, viewModel: TableStructureEditorViewModel) { self.tab = tab self.viewModel = viewModel @@ -64,15 +38,31 @@ struct TableStructureEditorView: View { selectedSection = requested viewModel.requestedSection = nil } + consumePendingAddActionIfNeeded() if viewModel.columns.isEmpty && !viewModel.isLoading { Task { await viewModel.reload() } } } + .onChange(of: viewModel.requestedSection) { _, newSection in + guard let newSection else { return } + selectedSection = newSection + viewModel.requestedSection = nil + } + .onChange(of: viewModel.pendingAddAction) { _, _ in + consumePendingAddActionIfNeeded() + } .onChange(of: selectedSection) { viewModel.lastError = nil viewModel.lastSuccessMessage = nil } - .background { sheetModifiers } + .onContinuousHover { phase in + switch phase { + case .active: + NSCursor.arrow.set() + case .ended: + break + } + } } internal func columnBinding(for columnID: UUID) -> Binding? { @@ -154,6 +144,29 @@ struct TableStructureEditorView: View { } } - activeSheet = nil + viewModel.sheetCoordinator.activeSheet = nil + } + + internal func consumePendingAddActionIfNeeded() { + guard let action = viewModel.pendingAddAction else { return } + viewModel.pendingAddAction = nil + + switch action { + case .column: + selectedSection = .columns + presentNewColumn() + case .index: + selectedSection = .indexes + presentNewIndex() + case .foreignKey: + selectedSection = .relations + presentNewForeignKey() + case .uniqueConstraint: + selectedSection = .constraints + presentNewUniqueConstraint() + case .checkConstraint: + selectedSection = .constraints + presentNewCheckConstraint() + } } } diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureSheetCoordinator.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureSheetCoordinator.swift new file mode 100644 index 000000000..7e27514ba --- /dev/null +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureSheetCoordinator.swift @@ -0,0 +1,46 @@ +import SwiftUI + +@MainActor +@Observable +final class TableStructureSheetCoordinator { + var activeSheet: TableStructureSheet? + var pendingNewIndex: TableStructureEditorViewModel.IndexModel? + var pendingNewPrimaryKey: TableStructureEditorViewModel.PrimaryKeyModel? + var pendingNewUniqueConstraint: TableStructureEditorViewModel.UniqueConstraintModel? + var pendingNewForeignKey: TableStructureEditorViewModel.ForeignKeyModel? + var pendingNewCheckConstraint: TableStructureEditorViewModel.CheckConstraintModel? +} + +enum TableStructureSheet: Identifiable { + case index(IndexEditorPresentation) + case column(ColumnEditorPresentation) + case primaryKey(PrimaryKeyEditorPresentation) + case uniqueConstraint(UniqueConstraintEditorPresentation) + case foreignKey(ForeignKeyEditorPresentation) + case checkConstraint(CheckConstraintEditorPresentation) + case newIndex + case newColumn + case newPrimaryKey + case newUniqueConstraint + case newForeignKey + case newCheckConstraint + case bulkColumn(BulkColumnEditorPresentation) + + var id: String { + switch self { + case .index(let presentation): "index-\(presentation.indexID)" + case .column(let presentation): "column-\(presentation.columnID)" + case .primaryKey: "primaryKey" + case .uniqueConstraint(let presentation): "uniqueConstraint-\(presentation.constraintID)" + case .foreignKey(let presentation): "foreignKey-\(presentation.foreignKeyID)" + case .checkConstraint(let presentation): "checkConstraint-\(presentation.constraintID)" + case .newIndex: "newIndex" + case .newColumn: "newColumn" + case .newPrimaryKey: "newPrimaryKey" + case .newUniqueConstraint: "newUniqueConstraint" + case .newForeignKey: "newForeignKey" + case .newCheckConstraint: "newCheckConstraint" + case .bulkColumn: "bulkColumn" + } + } +} diff --git a/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureSheetHost.swift b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureSheetHost.swift new file mode 100644 index 000000000..782c0fbdf --- /dev/null +++ b/Echo/Sources/Features/QueryWorkspace/Views/TableStructure/TableStructureSheetHost.swift @@ -0,0 +1,413 @@ +import SwiftUI + +struct TableStructureSheetHost: View { + @Bindable var tab: WorkspaceTab + @Bindable var viewModel: TableStructureEditorViewModel + + var body: some View { + Color.clear + .sheet( + item: Binding( + get: { viewModel.sheetCoordinator.activeSheet }, + set: { viewModel.sheetCoordinator.activeSheet = $0 } + ) + ) { sheet in + sheetContent(for: sheet) + } + } + + @ViewBuilder + private func sheetContent(for sheet: TableStructureSheet) -> some View { + switch sheet { + case .newIndex: + if let binding = pendingNewIndexBinding { + IndexEditorSheet( + index: binding, + availableColumns: availableColumnNames, + databaseType: tab.connection.databaseType, + onDelete: { dismissNewIndex() }, + onCancelNew: { dismissNewIndex() }, + onSaveNew: { model in + viewModel.indexes.append(model) + dismissNewIndex() + } + ) + } + + case .index(let presentation): + if let binding = indexBinding(for: presentation.indexID) { + IndexEditorSheet( + index: binding, + availableColumns: availableColumnNames, + databaseType: tab.connection.databaseType, + onDelete: { + viewModel.removeIndex(binding.wrappedValue) + dismiss() + }, + onCancelNew: { dismiss() }, + onSaveNew: nil + ) + } + + case .newColumn: + NewColumnSheetHost(databaseType: tab.connection.databaseType) { model in + viewModel.columns.append(model) + dismiss() + } onCancel: { + dismiss() + } + + case .column(let presentation): + if let binding = columnBinding(for: presentation.columnID) { + ColumnEditorSheet( + column: binding, + databaseType: tab.connection.databaseType, + onDelete: { + viewModel.removeColumn(binding.wrappedValue) + dismiss() + }, + onCancelNew: { dismiss() }, + onSaveNew: nil + ) + } + + case .newPrimaryKey: + if let binding = pendingNewPrimaryKeyBinding { + PrimaryKeyEditorSheet( + primaryKey: binding, + availableColumns: availableColumnNames, + databaseType: tab.connection.databaseType, + onDelete: { dismissNewPrimaryKey() }, + onCancelNew: { dismissNewPrimaryKey() }, + onSaveNew: { model in + viewModel.primaryKey = model + viewModel.clearPrimaryKeyRemoval() + dismissNewPrimaryKey() + } + ) + } + + case .primaryKey(let presentation): + if let binding = primaryKeyBinding { + PrimaryKeyEditorSheet( + primaryKey: binding, + availableColumns: availableColumnNames, + databaseType: tab.connection.databaseType, + onDelete: { + viewModel.removePrimaryKey() + dismiss() + }, + onCancelNew: { + if presentation.isNew { + viewModel.removePrimaryKey() + } + dismiss() + }, + onSaveNew: nil + ) + } + + case .newUniqueConstraint: + if let binding = pendingNewUniqueConstraintBinding { + UniqueConstraintEditorSheet( + constraint: binding, + availableColumns: availableColumnNames, + databaseType: tab.connection.databaseType, + onDelete: { dismissNewUniqueConstraint() }, + onCancelNew: { dismissNewUniqueConstraint() }, + onSaveNew: { model in + viewModel.uniqueConstraints.append(model) + dismissNewUniqueConstraint() + } + ) + } + + case .uniqueConstraint(let presentation): + if let binding = uniqueConstraintBinding(for: presentation.constraintID) { + UniqueConstraintEditorSheet( + constraint: binding, + availableColumns: availableColumnNames, + databaseType: tab.connection.databaseType, + onDelete: { + viewModel.removeUniqueConstraint(binding.wrappedValue) + dismiss() + }, + onCancelNew: { + if binding.wrappedValue.isNew { + viewModel.removeUniqueConstraint(binding.wrappedValue) + } + dismiss() + }, + onSaveNew: nil + ) + } + + case .newForeignKey: + if let binding = pendingNewForeignKeyBinding { + ForeignKeyEditorSheet( + foreignKey: binding, + availableColumns: availableColumnNames, + databaseType: tab.connection.databaseType, + session: viewModel.session, + onDelete: { dismissNewForeignKey() }, + onCancelNew: { dismissNewForeignKey() }, + onSaveNew: { model in + viewModel.foreignKeys.append(model) + dismissNewForeignKey() + } + ) + } + + case .foreignKey(let presentation): + if let binding = foreignKeyBinding(for: presentation.foreignKeyID) { + ForeignKeyEditorSheet( + foreignKey: binding, + availableColumns: availableColumnNames, + databaseType: tab.connection.databaseType, + session: viewModel.session, + onDelete: { + viewModel.removeForeignKey(binding.wrappedValue) + dismiss() + }, + onCancelNew: { + if binding.wrappedValue.isNew { + viewModel.removeForeignKey(binding.wrappedValue) + } + dismiss() + }, + onSaveNew: nil + ) + } + + case .newCheckConstraint: + if let binding = pendingNewCheckConstraintBinding { + CheckConstraintEditorSheet( + constraint: binding, + onDelete: { dismissNewCheckConstraint() }, + onCancelNew: { dismissNewCheckConstraint() }, + onSaveNew: { model in + viewModel.checkConstraints.append(model) + dismissNewCheckConstraint() + } + ) + } + + case .checkConstraint(let presentation): + if let binding = checkConstraintBinding(for: presentation.constraintID) { + CheckConstraintEditorSheet( + constraint: binding, + onDelete: { + viewModel.removeCheckConstraint(binding.wrappedValue) + dismiss() + }, + onCancelNew: { + if binding.wrappedValue.isNew { + viewModel.removeCheckConstraint(binding.wrappedValue) + } + dismiss() + }, + onSaveNew: nil + ) + } + + case .bulkColumn(let presentation): + BulkColumnEditorSheet( + mode: presentation.mode, + columnNames: presentation.columnIDs.compactMap { id in + viewModel.columns.first(where: { $0.id == id && !$0.isDeleted })?.name + }, + databaseType: tab.connection.databaseType, + onApply: { value in + let targets = presentation.columnIDs.compactMap { id in columnBinding(for: id) } + applyBulkEdit(mode: presentation.mode, value: value, bindings: targets) + }, + onCancel: { dismiss() } + ) + } + } + + private var availableColumnNames: [String] { + viewModel.columns.filter { !$0.isDeleted }.map(\.name) + } + + private func dismiss() { + viewModel.sheetCoordinator.activeSheet = nil + } + + private func columnBinding(for columnID: UUID) -> Binding? { + guard let index = viewModel.columns.firstIndex(where: { $0.id == columnID }) else { return nil } + return $viewModel.columns[index] + } + + private func indexBinding(for indexID: UUID) -> Binding? { + guard let index = viewModel.indexes.firstIndex(where: { $0.id == indexID }) else { return nil } + return $viewModel.indexes[index] + } + + private func uniqueConstraintBinding(for constraintID: UUID) -> Binding? { + guard let index = viewModel.uniqueConstraints.firstIndex(where: { $0.id == constraintID }) else { return nil } + return $viewModel.uniqueConstraints[index] + } + + private func foreignKeyBinding(for foreignKeyID: UUID) -> Binding? { + guard let index = viewModel.foreignKeys.firstIndex(where: { $0.id == foreignKeyID }) else { return nil } + return $viewModel.foreignKeys[index] + } + + private func checkConstraintBinding(for constraintID: UUID) -> Binding? { + guard let index = viewModel.checkConstraints.firstIndex(where: { $0.id == constraintID }) else { return nil } + return $viewModel.checkConstraints[index] + } + + private var primaryKeyBinding: Binding? { + guard let primaryKey = viewModel.primaryKey else { return nil } + return Binding( + get: { viewModel.primaryKey ?? primaryKey }, + set: { viewModel.primaryKey = $0 } + ) + } + + private var pendingNewIndexBinding: Binding? { + guard let pendingNewIndex = viewModel.sheetCoordinator.pendingNewIndex else { return nil } + return Binding( + get: { viewModel.sheetCoordinator.pendingNewIndex ?? pendingNewIndex }, + set: { viewModel.sheetCoordinator.pendingNewIndex = $0 } + ) + } + + private var pendingNewPrimaryKeyBinding: Binding? { + guard let pendingNewPrimaryKey = viewModel.sheetCoordinator.pendingNewPrimaryKey else { return nil } + return Binding( + get: { viewModel.sheetCoordinator.pendingNewPrimaryKey ?? pendingNewPrimaryKey }, + set: { viewModel.sheetCoordinator.pendingNewPrimaryKey = $0 } + ) + } + + private var pendingNewUniqueConstraintBinding: Binding? { + guard let pendingNewUniqueConstraint = viewModel.sheetCoordinator.pendingNewUniqueConstraint else { return nil } + return Binding( + get: { viewModel.sheetCoordinator.pendingNewUniqueConstraint ?? pendingNewUniqueConstraint }, + set: { viewModel.sheetCoordinator.pendingNewUniqueConstraint = $0 } + ) + } + + private var pendingNewForeignKeyBinding: Binding? { + guard let pendingNewForeignKey = viewModel.sheetCoordinator.pendingNewForeignKey else { return nil } + return Binding( + get: { viewModel.sheetCoordinator.pendingNewForeignKey ?? pendingNewForeignKey }, + set: { viewModel.sheetCoordinator.pendingNewForeignKey = $0 } + ) + } + + private var pendingNewCheckConstraintBinding: Binding? { + guard let pendingNewCheckConstraint = viewModel.sheetCoordinator.pendingNewCheckConstraint else { return nil } + return Binding( + get: { viewModel.sheetCoordinator.pendingNewCheckConstraint ?? pendingNewCheckConstraint }, + set: { viewModel.sheetCoordinator.pendingNewCheckConstraint = $0 } + ) + } + + private func dismissNewIndex() { + viewModel.sheetCoordinator.pendingNewIndex = nil + dismiss() + } + + private func dismissNewPrimaryKey() { + viewModel.sheetCoordinator.pendingNewPrimaryKey = nil + dismiss() + } + + private func dismissNewUniqueConstraint() { + viewModel.sheetCoordinator.pendingNewUniqueConstraint = nil + dismiss() + } + + private func dismissNewForeignKey() { + viewModel.sheetCoordinator.pendingNewForeignKey = nil + dismiss() + } + + private func dismissNewCheckConstraint() { + viewModel.sheetCoordinator.pendingNewCheckConstraint = nil + dismiss() + } + + private func applyBulkEdit( + mode: BulkColumnEditorPresentation.Mode, + value: BulkColumnEditValue, + bindings: [Binding] + ) { + for binding in bindings { + switch mode { + case .dataType: + if case let .dataType(newType) = value { + binding.wrappedValue.dataType = newType + } + case .defaultValue: + if case let .defaultValue(newValue) = value { + binding.wrappedValue.defaultValue = newValue + } + case .generatedExpression: + if case let .generatedExpression(newValue) = value { + binding.wrappedValue.generatedExpression = newValue + } + } + } + + dismiss() + } +} + +private struct NewColumnSheetHost: View { + @State private var column: TableStructureEditorViewModel.ColumnModel + let databaseType: DatabaseType + let onSave: (TableStructureEditorViewModel.ColumnModel) -> Void + let onCancel: () -> Void + + init( + databaseType: DatabaseType, + onSave: @escaping (TableStructureEditorViewModel.ColumnModel) -> Void, + onCancel: @escaping () -> Void + ) { + self.databaseType = databaseType + self.onSave = onSave + self.onCancel = onCancel + + let defaultType: String = switch databaseType { + case .mysql: "varchar(255)" + case .microsoftSQL: "nvarchar(255)" + default: "text" + } + + _column = State( + initialValue: TableStructureEditorViewModel.ColumnModel( + original: nil, + name: "new_column", + dataType: defaultType, + isNullable: true, + defaultValue: nil, + generatedExpression: nil, + isIdentity: false, + identitySeed: nil, + identityIncrement: nil, + identityGeneration: nil, + collation: nil, + characterSet: nil, + comment: nil, + isUnsigned: false, + isZerofill: false, + ordinalPosition: nil + ) + ) + } + + var body: some View { + ColumnEditorSheet( + column: $column, + databaseType: databaseType, + onDelete: onCancel, + onCancelNew: onCancel, + onSaveNew: onSave + ) + } +} diff --git a/Echo/Sources/Features/SchemaDiagram/Views/DiagramAnnotationView.swift b/Echo/Sources/Features/SchemaDiagram/Views/DiagramAnnotationView.swift index 82ff8e480..905eda424 100644 --- a/Echo/Sources/Features/SchemaDiagram/Views/DiagramAnnotationView.swift +++ b/Echo/Sources/Features/SchemaDiagram/Views/DiagramAnnotationView.swift @@ -41,9 +41,9 @@ struct DiagramAnnotationView: View { } .padding(SpacingTokens.xs) .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color.yellow.opacity(0.15)) - .stroke(Color.yellow.opacity(0.4), lineWidth: 1) + RoundedRectangle(cornerRadius: ShapeTokens.CornerRadius.small) + .fill(ColorTokens.Status.warning.opacity(0.15)) + .stroke(ColorTokens.Status.warning.opacity(0.4), lineWidth: 1) } .contextMenu { Button { diff --git a/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+Actions.swift b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+Actions.swift deleted file mode 100644 index b284f4542..000000000 --- a/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+Actions.swift +++ /dev/null @@ -1,861 +0,0 @@ -import Foundation -import PostgresKit - -extension PostgresAdvancedObjectsViewModel { - - // MARK: - Drop Actions - - func dropFDW(_ name: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping FDW \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropForeignDataWrapper(name: name, ifExists: true, cascade: true) - handle?.succeed() - panelState?.appendMessage("Dropped FDW '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop FDW: \(error.localizedDescription)", severity: .error) - } - } - - func dropForeignServer(_ name: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping server \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropForeignServer(name: name, ifExists: true, cascade: true) - handle?.succeed() - panelState?.appendMessage("Dropped foreign server '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop server: \(error.localizedDescription)", severity: .error) - } - } - - func dropEventTrigger(_ name: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping event trigger \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.triggers.dropEventTrigger(name: name, ifExists: true, cascade: true) - handle?.succeed() - panelState?.appendMessage("Dropped event trigger '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop event trigger: \(error.localizedDescription)", severity: .error) - } - } - - func dropDomain(_ name: String, schema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping domain \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.dropDomain(name: name, ifExists: true, cascade: true, schema: schema) - handle?.succeed() - panelState?.appendMessage("Dropped domain '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop domain: \(error.localizedDescription)", severity: .error) - } - } - - func dropCompositeType(_ name: String, schema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping type \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.dropCompositeType(name: name, ifExists: true, cascade: true, schema: schema) - handle?.succeed() - panelState?.appendMessage("Dropped composite type '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop type: \(error.localizedDescription)", severity: .error) - } - } - - func dropRangeType(_ name: String, schema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping range type \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.dropRangeType(name: name, ifExists: true, cascade: true, schema: schema) - handle?.succeed() - panelState?.appendMessage("Dropped range type '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop range type: \(error.localizedDescription)", severity: .error) - } - } - - func dropCollation(_ name: String, schema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping collation \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropCollation(name: name, ifExists: true, cascade: true, schema: schema) - handle?.succeed() - panelState?.appendMessage("Dropped collation '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop collation: \(error.localizedDescription)", severity: .error) - } - } - - func dropFTSConfig(_ name: String, schema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping FTS config \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropTextSearchConfiguration(name: name, ifExists: true, cascade: true, schema: schema) - handle?.succeed() - panelState?.appendMessage("Dropped text search config '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop FTS config: \(error.localizedDescription)", severity: .error) - } - } - - func dropRule(_ name: String, table: String, schema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping rule \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropRule(name: name, table: table, ifExists: true, cascade: true, schema: schema) - handle?.succeed() - panelState?.appendMessage("Dropped rule '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop rule: \(error.localizedDescription)", severity: .error) - } - } - - func dropTablespace(_ name: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping tablespace \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropTablespace(name: name, ifExists: true) - handle?.succeed() - panelState?.appendMessage("Dropped tablespace '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop tablespace: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Create Actions - - func createForeignServer(name: String, type: String?, version: String?, fdwName: String, options: [String: String]?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating foreign server \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createForeignServer(name: name, type: type, version: version, fdwName: fdwName, options: options) - handle?.succeed() - panelState?.appendMessage("Created foreign server '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create server: \(error.localizedDescription)", severity: .error) - } - } - - func createEventTrigger(name: String, event: String, function: String, tags: [String]?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating event trigger \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.triggers.createEventTrigger(name: name, event: event, function: function, tags: tags) - handle?.succeed() - panelState?.appendMessage("Created event trigger '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create event trigger: \(error.localizedDescription)", severity: .error) - } - } - - func createDomain(name: String, schema: String?, dataType: String, defaultValue: String?, notNull: Bool, checkExpression: String?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating domain \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.createDomain(name: name, dataType: dataType, defaultValue: defaultValue, notNull: notNull, checkExpression: checkExpression, schema: schema) - handle?.succeed() - panelState?.appendMessage("Created domain '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create domain: \(error.localizedDescription)", severity: .error) - } - } - - func createCompositeType(name: String, schema: String?, attributes: [(name: String, dataType: String)]) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating composite type \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.createCompositeType(name: name, attributes: attributes, schema: schema) - handle?.succeed() - panelState?.appendMessage("Created composite type '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create type: \(error.localizedDescription)", severity: .error) - } - } - - func createRangeType(name: String, schema: String?, subtype: String, opClass: String?, collation: String?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating range type \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.createRangeType(name: name, subtype: subtype, subtypeOpClass: opClass, collation: collation, schema: schema) - handle?.succeed() - panelState?.appendMessage("Created range type '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create range type: \(error.localizedDescription)", severity: .error) - } - } - - func createCollation(name: String, schema: String?, locale: String?, provider: String?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating collation \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createCollation(name: name, locale: locale, provider: provider, schema: schema) - handle?.succeed() - panelState?.appendMessage("Created collation '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create collation: \(error.localizedDescription)", severity: .error) - } - } - - func createFTSConfig(name: String, schema: String?, parser: String?, copySource: String?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating FTS config \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createTextSearchConfiguration(name: name, parser: parser, copy: copySource, schema: schema) - handle?.succeed() - panelState?.appendMessage("Created text search config '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create FTS config: \(error.localizedDescription)", severity: .error) - } - } - - func createRule(name: String, table: String, schema: String?, event: String, doInstead: Bool, condition: String?, commands: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating rule \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createRule(name: name, table: table, event: event, doInstead: doInstead, condition: condition, commands: commands, schema: schema) - handle?.succeed() - panelState?.appendMessage("Created rule '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create rule: \(error.localizedDescription)", severity: .error) - } - } - - func createTablespace(name: String, location: String, owner: String?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating tablespace \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createTablespace(name: name, location: location, owner: owner) - handle?.succeed() - panelState?.appendMessage("Created tablespace '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create tablespace: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Aggregates - - func dropAggregate(_ id: String) async { - guard let pg = session as? PostgresSession else { return } - guard let agg = aggregates.first(where: { $0.id == id }) else { return } - let handle = activityEngine?.begin("Dropping aggregate \(agg.name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropAggregate(name: agg.name, inputType: agg.inputType, ifExists: true, cascade: true, schema: agg.schema) - handle?.succeed() - panelState?.appendMessage("Dropped aggregate '\(agg.name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop aggregate: \(error.localizedDescription)", severity: .error) - } - } - - func createAggregate(name: String, inputType: String, sfunc: String, stype: String, initcond: String?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating aggregate \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createAggregate(name: name, inputType: inputType, sfunc: sfunc, stype: stype, initcond: initcond, schema: schemaFilter) - handle?.succeed() - panelState?.appendMessage("Created aggregate '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create aggregate: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Operators - - func dropOperator(_ id: String) async { - guard let pg = session as? PostgresSession else { return } - guard let op = operators.first(where: { $0.id == id }) else { return } - let handle = activityEngine?.begin("Dropping operator \(op.name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropOperator(name: op.name, leftType: op.leftType, rightType: op.rightType, ifExists: true, cascade: true, schema: op.schema) - handle?.succeed() - panelState?.appendMessage("Dropped operator '\(op.name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop operator: \(error.localizedDescription)", severity: .error) - } - } - - func createOperator(name: String, leftType: String?, rightType: String?, procedure: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating operator \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createOperator(name: name, leftType: leftType, rightType: rightType, procedure: procedure, schema: schemaFilter) - handle?.succeed() - panelState?.appendMessage("Created operator '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create operator: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Languages - - func dropLanguage(_ name: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Dropping language \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropLanguage(name: name, ifExists: true, cascade: true) - handle?.succeed() - panelState?.appendMessage("Dropped language '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop language: \(error.localizedDescription)", severity: .error) - } - } - - func createLanguage(name: String, trusted: Bool, handler: String?, validator: String?) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating language \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createLanguage(name: name, trusted: trusted, handler: handler, validator: validator) - handle?.succeed() - panelState?.appendMessage("Created language '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create language: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Casts - - func createCast(sourceType: String, targetType: String, function: String?, asAssignment: Bool, asImplicit: Bool) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Creating cast (\(sourceType) AS \(targetType))", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.createCast(sourceType: sourceType, targetType: targetType, function: function, asAssignment: asAssignment, asImplicit: asImplicit) - handle?.succeed() - panelState?.appendMessage("Created cast '\(sourceType) -> \(targetType)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to create cast: \(error.localizedDescription)", severity: .error) - } - } - - func dropCast(_ id: String) async { - guard let pg = session as? PostgresSession else { return } - guard let cast = casts.first(where: { $0.id == id }) else { return } - let handle = activityEngine?.begin("Dropping cast \(cast.sourceType) -> \(cast.targetType)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.dropCast(sourceType: cast.sourceType, targetType: cast.targetType, ifExists: true) - handle?.succeed() - panelState?.appendMessage("Dropped cast '\(cast.sourceType) -> \(cast.targetType)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to drop cast: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Domain Alter Actions - - func renameDomain(_ name: String, schema: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming domain \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.alterDomainRename(name: name, newName: newName, schema: schema) - handle?.succeed() - panelState?.appendMessage("Renamed domain '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename domain: \(error.localizedDescription)", severity: .error) - } - } - - func changeDomainOwner(_ name: String, schema: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of domain \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.alterDomainOwner(name: name, newOwner: newOwner, schema: schema) - handle?.succeed() - panelState?.appendMessage("Changed owner of domain '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change domain owner: \(error.localizedDescription)", severity: .error) - } - } - - func setDomainSchema(_ name: String, schema: String, newSchema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Moving domain \(name) to schema \(newSchema)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.alterDomainSetSchema(name: name, newSchema: newSchema, schema: schema) - handle?.succeed() - panelState?.appendMessage("Moved domain '\(name)' to schema '\(newSchema)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change domain schema: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Composite Type / Range Type Alter Actions - - func renameType(_ name: String, schema: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming type \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.alterTypeRename(name: name, newName: newName, schema: schema) - handle?.succeed() - panelState?.appendMessage("Renamed type '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename type: \(error.localizedDescription)", severity: .error) - } - } - - func changeTypeOwner(_ name: String, schema: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of type \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.alterTypeOwner(name: name, newOwner: newOwner, schema: schema) - handle?.succeed() - panelState?.appendMessage("Changed owner of type '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change type owner: \(error.localizedDescription)", severity: .error) - } - } - - func setTypeSchema(_ name: String, schema: String, newSchema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Moving type \(name) to schema \(newSchema)", connectionSessionID: connectionSessionID) - do { - try await pg.client.types.alterTypeSetSchema(name: name, newSchema: newSchema, schema: schema) - handle?.succeed() - panelState?.appendMessage("Moved type '\(name)' to schema '\(newSchema)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change type schema: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Collation Alter Actions - - func renameCollation(_ name: String, schema: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming collation \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterCollationRename(name: name, newName: newName, schema: schema) - handle?.succeed() - panelState?.appendMessage("Renamed collation '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename collation: \(error.localizedDescription)", severity: .error) - } - } - - func changeCollationOwner(_ name: String, schema: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of collation \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterCollationOwner(name: name, newOwner: newOwner, schema: schema) - handle?.succeed() - panelState?.appendMessage("Changed owner of collation '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change collation owner: \(error.localizedDescription)", severity: .error) - } - } - - func setCollationSchema(_ name: String, schema: String, newSchema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Moving collation \(name) to schema \(newSchema)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterCollationSetSchema(name: name, newSchema: newSchema, schema: schema) - handle?.succeed() - panelState?.appendMessage("Moved collation '\(name)' to schema '\(newSchema)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change collation schema: \(error.localizedDescription)", severity: .error) - } - } - - func refreshCollationVersion(_ name: String, schema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Refreshing collation version \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterCollationRefreshVersion(name: name, schema: schema) - handle?.succeed() - panelState?.appendMessage("Refreshed collation version '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to refresh collation version: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - FTS Config Alter Actions - - func renameFTSConfig(_ name: String, schema: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming FTS config \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterTextSearchConfigurationRename(name: name, newName: newName, schema: schema) - handle?.succeed() - panelState?.appendMessage("Renamed FTS config '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename FTS config: \(error.localizedDescription)", severity: .error) - } - } - - func changeFTSConfigOwner(_ name: String, schema: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of FTS config \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterTextSearchConfigurationOwner(name: name, newOwner: newOwner, schema: schema) - handle?.succeed() - panelState?.appendMessage("Changed owner of FTS config '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change FTS config owner: \(error.localizedDescription)", severity: .error) - } - } - - func setFTSConfigSchema(_ name: String, schema: String, newSchema: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Moving FTS config \(name) to schema \(newSchema)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterTextSearchConfigurationSetSchema(name: name, newSchema: newSchema, schema: schema) - handle?.succeed() - panelState?.appendMessage("Moved FTS config '\(name)' to schema '\(newSchema)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change FTS config schema: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Tablespace Alter Actions - - func renameTablespace(_ name: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming tablespace \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterTablespaceRename(name: name, newName: newName) - handle?.succeed() - panelState?.appendMessage("Renamed tablespace '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename tablespace: \(error.localizedDescription)", severity: .error) - } - } - - func changeTablespaceOwner(_ name: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of tablespace \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterTablespaceOwner(name: name, newOwner: newOwner) - handle?.succeed() - panelState?.appendMessage("Changed owner of tablespace '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change tablespace owner: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Aggregate Alter Actions - - func renameAggregate(_ id: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - guard let agg = aggregates.first(where: { $0.id == id }) else { return } - let handle = activityEngine?.begin("Renaming aggregate \(agg.name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterAggregateRename(name: agg.name, inputType: agg.inputType, newName: newName, schema: agg.schema) - handle?.succeed() - panelState?.appendMessage("Renamed aggregate '\(agg.name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename aggregate: \(error.localizedDescription)", severity: .error) - } - } - - func changeAggregateOwner(_ id: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - guard let agg = aggregates.first(where: { $0.id == id }) else { return } - let handle = activityEngine?.begin("Changing owner of aggregate \(agg.name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterAggregateOwner(name: agg.name, inputType: agg.inputType, newOwner: newOwner, schema: agg.schema) - handle?.succeed() - panelState?.appendMessage("Changed owner of aggregate '\(agg.name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change aggregate owner: \(error.localizedDescription)", severity: .error) - } - } - - func setAggregateSchema(_ id: String, newSchema: String) async { - guard let pg = session as? PostgresSession else { return } - guard let agg = aggregates.first(where: { $0.id == id }) else { return } - let handle = activityEngine?.begin("Moving aggregate \(agg.name) to schema \(newSchema)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterAggregateSetSchema(name: agg.name, inputType: agg.inputType, newSchema: newSchema, schema: agg.schema) - handle?.succeed() - panelState?.appendMessage("Moved aggregate '\(agg.name)' to schema '\(newSchema)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change aggregate schema: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Operator Alter Actions - - func changeOperatorOwner(_ id: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - guard let op = operators.first(where: { $0.id == id }) else { return } - let handle = activityEngine?.begin("Changing owner of operator \(op.name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterOperatorOwner(name: op.name, leftType: op.leftType, rightType: op.rightType, newOwner: newOwner, schema: op.schema) - handle?.succeed() - panelState?.appendMessage("Changed owner of operator '\(op.name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change operator owner: \(error.localizedDescription)", severity: .error) - } - } - - func setOperatorSchema(_ id: String, newSchema: String) async { - guard let pg = session as? PostgresSession else { return } - guard let op = operators.first(where: { $0.id == id }) else { return } - let handle = activityEngine?.begin("Moving operator \(op.name) to schema \(newSchema)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterOperatorSetSchema(name: op.name, leftType: op.leftType, rightType: op.rightType, newSchema: newSchema, schema: op.schema) - handle?.succeed() - panelState?.appendMessage("Moved operator '\(op.name)' to schema '\(newSchema)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change operator schema: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Language Alter Actions - - func renameLanguage(_ name: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming language \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterLanguageRename(name: name, newName: newName) - handle?.succeed() - panelState?.appendMessage("Renamed language '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename language: \(error.localizedDescription)", severity: .error) - } - } - - func changeLanguageOwner(_ name: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of language \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterLanguageOwner(name: name, newOwner: newOwner) - handle?.succeed() - panelState?.appendMessage("Changed owner of language '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change language owner: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Event Trigger Alter Actions - - func renameEventTrigger(_ name: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming event trigger \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.triggers.alterEventTriggerRename(name: name, newName: newName) - handle?.succeed() - panelState?.appendMessage("Renamed event trigger '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename event trigger: \(error.localizedDescription)", severity: .error) - } - } - - func changeEventTriggerOwner(_ name: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of event trigger \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.triggers.alterEventTriggerOwner(name: name, newOwner: newOwner) - handle?.succeed() - panelState?.appendMessage("Changed owner of event trigger '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change event trigger owner: \(error.localizedDescription)", severity: .error) - } - } - - func enableEventTrigger(_ name: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Enabling event trigger \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.triggers.alterEventTriggerEnable(name: name, enable: true) - handle?.succeed() - panelState?.appendMessage("Enabled event trigger '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to enable event trigger: \(error.localizedDescription)", severity: .error) - } - } - - func disableEventTrigger(_ name: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Disabling event trigger \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.triggers.alterEventTriggerEnable(name: name, enable: false) - handle?.succeed() - panelState?.appendMessage("Disabled event trigger '\(name)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to disable event trigger: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - FDW Alter Actions - - func renameFDW(_ name: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming FDW \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterForeignDataWrapperRename(name: name, newName: newName) - handle?.succeed() - panelState?.appendMessage("Renamed FDW '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename FDW: \(error.localizedDescription)", severity: .error) - } - } - - func changeFDWOwner(_ name: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of FDW \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterForeignDataWrapperOwner(name: name, newOwner: newOwner) - handle?.succeed() - panelState?.appendMessage("Changed owner of FDW '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change FDW owner: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Foreign Server Alter Actions - - func renameForeignServer(_ name: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming server \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterForeignServerRename(name: name, newName: newName) - handle?.succeed() - panelState?.appendMessage("Renamed foreign server '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename server: \(error.localizedDescription)", severity: .error) - } - } - - func changeForeignServerOwner(_ name: String, newOwner: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Changing owner of server \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterForeignServerOwner(name: name, newOwner: newOwner) - handle?.succeed() - panelState?.appendMessage("Changed owner of server '\(name)' to '\(newOwner)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to change server owner: \(error.localizedDescription)", severity: .error) - } - } - - // MARK: - Rule Alter Actions - - func renameRule(_ name: String, tableName: String, schema: String, newName: String) async { - guard let pg = session as? PostgresSession else { return } - let handle = activityEngine?.begin("Renaming rule \(name)", connectionSessionID: connectionSessionID) - do { - try await pg.client.admin.alterRuleRename(ruleName: name, tableName: tableName, newName: newName, schema: schema) - handle?.succeed() - panelState?.appendMessage("Renamed rule '\(name)' to '\(newName)'") - await loadCurrentSection() - } catch { - handle?.fail(error.localizedDescription) - panelState?.appendMessage("Failed to rename rule: \(error.localizedDescription)", severity: .error) - } - } -} diff --git a/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+AlterActions.swift b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+AlterActions.swift new file mode 100644 index 000000000..4a739032c --- /dev/null +++ b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+AlterActions.swift @@ -0,0 +1,227 @@ +import Foundation +import PostgresKit + +// MARK: - Alter Actions (Domains, Types, Collations, FTS Configs, Tablespaces) + +extension PostgresAdvancedObjectsViewModel { + + // MARK: - Domain Alter Actions + + func renameDomain(_ name: String, schema: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming domain \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.alterDomainRename(name: name, newName: newName, schema: schema) + handle?.succeed() + panelState?.appendMessage("Renamed domain '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename domain: \(error.localizedDescription)", severity: .error) + } + } + + func changeDomainOwner(_ name: String, schema: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of domain \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.alterDomainOwner(name: name, newOwner: newOwner, schema: schema) + handle?.succeed() + panelState?.appendMessage("Changed owner of domain '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change domain owner: \(error.localizedDescription)", severity: .error) + } + } + + func setDomainSchema(_ name: String, schema: String, newSchema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Moving domain \(name) to schema \(newSchema)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.alterDomainSetSchema(name: name, newSchema: newSchema, schema: schema) + handle?.succeed() + panelState?.appendMessage("Moved domain '\(name)' to schema '\(newSchema)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change domain schema: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - Composite Type / Range Type Alter Actions + + func renameType(_ name: String, schema: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming type \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.alterTypeRename(name: name, newName: newName, schema: schema) + handle?.succeed() + panelState?.appendMessage("Renamed type '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename type: \(error.localizedDescription)", severity: .error) + } + } + + func changeTypeOwner(_ name: String, schema: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of type \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.alterTypeOwner(name: name, newOwner: newOwner, schema: schema) + handle?.succeed() + panelState?.appendMessage("Changed owner of type '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change type owner: \(error.localizedDescription)", severity: .error) + } + } + + func setTypeSchema(_ name: String, schema: String, newSchema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Moving type \(name) to schema \(newSchema)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.alterTypeSetSchema(name: name, newSchema: newSchema, schema: schema) + handle?.succeed() + panelState?.appendMessage("Moved type '\(name)' to schema '\(newSchema)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change type schema: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - Collation Alter Actions + + func renameCollation(_ name: String, schema: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming collation \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterCollationRename(name: name, newName: newName, schema: schema) + handle?.succeed() + panelState?.appendMessage("Renamed collation '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename collation: \(error.localizedDescription)", severity: .error) + } + } + + func changeCollationOwner(_ name: String, schema: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of collation \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterCollationOwner(name: name, newOwner: newOwner, schema: schema) + handle?.succeed() + panelState?.appendMessage("Changed owner of collation '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change collation owner: \(error.localizedDescription)", severity: .error) + } + } + + func setCollationSchema(_ name: String, schema: String, newSchema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Moving collation \(name) to schema \(newSchema)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterCollationSetSchema(name: name, newSchema: newSchema, schema: schema) + handle?.succeed() + panelState?.appendMessage("Moved collation '\(name)' to schema '\(newSchema)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change collation schema: \(error.localizedDescription)", severity: .error) + } + } + + func refreshCollationVersion(_ name: String, schema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Refreshing collation version \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterCollationRefreshVersion(name: name, schema: schema) + handle?.succeed() + panelState?.appendMessage("Refreshed collation version '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to refresh collation version: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - FTS Config Alter Actions + + func renameFTSConfig(_ name: String, schema: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming FTS config \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterTextSearchConfigurationRename(name: name, newName: newName, schema: schema) + handle?.succeed() + panelState?.appendMessage("Renamed FTS config '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename FTS config: \(error.localizedDescription)", severity: .error) + } + } + + func changeFTSConfigOwner(_ name: String, schema: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of FTS config \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterTextSearchConfigurationOwner(name: name, newOwner: newOwner, schema: schema) + handle?.succeed() + panelState?.appendMessage("Changed owner of FTS config '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change FTS config owner: \(error.localizedDescription)", severity: .error) + } + } + + func setFTSConfigSchema(_ name: String, schema: String, newSchema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Moving FTS config \(name) to schema \(newSchema)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterTextSearchConfigurationSetSchema(name: name, newSchema: newSchema, schema: schema) + handle?.succeed() + panelState?.appendMessage("Moved FTS config '\(name)' to schema '\(newSchema)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change FTS config schema: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - Tablespace Alter Actions + + func renameTablespace(_ name: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming tablespace \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterTablespaceRename(name: name, newName: newName) + handle?.succeed() + panelState?.appendMessage("Renamed tablespace '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename tablespace: \(error.localizedDescription)", severity: .error) + } + } + + func changeTablespaceOwner(_ name: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of tablespace \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterTablespaceOwner(name: name, newOwner: newOwner) + handle?.succeed() + panelState?.appendMessage("Changed owner of tablespace '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change tablespace owner: \(error.localizedDescription)", severity: .error) + } + } +} diff --git a/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+AlterObjectActions.swift b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+AlterObjectActions.swift new file mode 100644 index 000000000..c894469de --- /dev/null +++ b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+AlterObjectActions.swift @@ -0,0 +1,250 @@ +import Foundation +import PostgresKit + +// MARK: - Alter Actions (Aggregates, Operators, Languages, Event Triggers, FDWs, Foreign Servers, Rules) + +extension PostgresAdvancedObjectsViewModel { + + // MARK: - Aggregate Alter Actions + + func renameAggregate(_ id: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + guard let agg = aggregates.first(where: { $0.id == id }) else { return } + let handle = activityEngine?.begin("Renaming aggregate \(agg.name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterAggregateRename(name: agg.name, inputType: agg.inputType, newName: newName, schema: agg.schema) + handle?.succeed() + panelState?.appendMessage("Renamed aggregate '\(agg.name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename aggregate: \(error.localizedDescription)", severity: .error) + } + } + + func changeAggregateOwner(_ id: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + guard let agg = aggregates.first(where: { $0.id == id }) else { return } + let handle = activityEngine?.begin("Changing owner of aggregate \(agg.name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterAggregateOwner(name: agg.name, inputType: agg.inputType, newOwner: newOwner, schema: agg.schema) + handle?.succeed() + panelState?.appendMessage("Changed owner of aggregate '\(agg.name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change aggregate owner: \(error.localizedDescription)", severity: .error) + } + } + + func setAggregateSchema(_ id: String, newSchema: String) async { + guard let pg = session as? PostgresSession else { return } + guard let agg = aggregates.first(where: { $0.id == id }) else { return } + let handle = activityEngine?.begin("Moving aggregate \(agg.name) to schema \(newSchema)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterAggregateSetSchema(name: agg.name, inputType: agg.inputType, newSchema: newSchema, schema: agg.schema) + handle?.succeed() + panelState?.appendMessage("Moved aggregate '\(agg.name)' to schema '\(newSchema)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change aggregate schema: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - Operator Alter Actions + + func changeOperatorOwner(_ id: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + guard let op = operators.first(where: { $0.id == id }) else { return } + let handle = activityEngine?.begin("Changing owner of operator \(op.name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterOperatorOwner(name: op.name, leftType: op.leftType, rightType: op.rightType, newOwner: newOwner, schema: op.schema) + handle?.succeed() + panelState?.appendMessage("Changed owner of operator '\(op.name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change operator owner: \(error.localizedDescription)", severity: .error) + } + } + + func setOperatorSchema(_ id: String, newSchema: String) async { + guard let pg = session as? PostgresSession else { return } + guard let op = operators.first(where: { $0.id == id }) else { return } + let handle = activityEngine?.begin("Moving operator \(op.name) to schema \(newSchema)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterOperatorSetSchema(name: op.name, leftType: op.leftType, rightType: op.rightType, newSchema: newSchema, schema: op.schema) + handle?.succeed() + panelState?.appendMessage("Moved operator '\(op.name)' to schema '\(newSchema)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change operator schema: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - Language Alter Actions + + func renameLanguage(_ name: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming language \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterLanguageRename(name: name, newName: newName) + handle?.succeed() + panelState?.appendMessage("Renamed language '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename language: \(error.localizedDescription)", severity: .error) + } + } + + func changeLanguageOwner(_ name: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of language \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterLanguageOwner(name: name, newOwner: newOwner) + handle?.succeed() + panelState?.appendMessage("Changed owner of language '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change language owner: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - Event Trigger Alter Actions + + func renameEventTrigger(_ name: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming event trigger \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.triggers.alterEventTriggerRename(name: name, newName: newName) + handle?.succeed() + panelState?.appendMessage("Renamed event trigger '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename event trigger: \(error.localizedDescription)", severity: .error) + } + } + + func changeEventTriggerOwner(_ name: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of event trigger \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.triggers.alterEventTriggerOwner(name: name, newOwner: newOwner) + handle?.succeed() + panelState?.appendMessage("Changed owner of event trigger '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change event trigger owner: \(error.localizedDescription)", severity: .error) + } + } + + func enableEventTrigger(_ name: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Enabling event trigger \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.triggers.alterEventTriggerEnable(name: name, enable: true) + handle?.succeed() + panelState?.appendMessage("Enabled event trigger '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to enable event trigger: \(error.localizedDescription)", severity: .error) + } + } + + func disableEventTrigger(_ name: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Disabling event trigger \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.triggers.alterEventTriggerEnable(name: name, enable: false) + handle?.succeed() + panelState?.appendMessage("Disabled event trigger '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to disable event trigger: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - FDW Alter Actions + + func renameFDW(_ name: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming FDW \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterForeignDataWrapperRename(name: name, newName: newName) + handle?.succeed() + panelState?.appendMessage("Renamed FDW '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename FDW: \(error.localizedDescription)", severity: .error) + } + } + + func changeFDWOwner(_ name: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of FDW \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterForeignDataWrapperOwner(name: name, newOwner: newOwner) + handle?.succeed() + panelState?.appendMessage("Changed owner of FDW '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change FDW owner: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - Foreign Server Alter Actions + + func renameForeignServer(_ name: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming server \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterForeignServerRename(name: name, newName: newName) + handle?.succeed() + panelState?.appendMessage("Renamed foreign server '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename server: \(error.localizedDescription)", severity: .error) + } + } + + func changeForeignServerOwner(_ name: String, newOwner: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Changing owner of server \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterForeignServerOwner(name: name, newOwner: newOwner) + handle?.succeed() + panelState?.appendMessage("Changed owner of server '\(name)' to '\(newOwner)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to change server owner: \(error.localizedDescription)", severity: .error) + } + } + + // MARK: - Rule Alter Actions + + func renameRule(_ name: String, tableName: String, schema: String, newName: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Renaming rule \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.alterRuleRename(ruleName: name, tableName: tableName, newName: newName, schema: schema) + handle?.succeed() + panelState?.appendMessage("Renamed rule '\(name)' to '\(newName)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to rename rule: \(error.localizedDescription)", severity: .error) + } + } +} diff --git a/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+CreateActions.swift b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+CreateActions.swift new file mode 100644 index 000000000..7330a1823 --- /dev/null +++ b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+CreateActions.swift @@ -0,0 +1,189 @@ +import Foundation +import PostgresKit + +// MARK: - Create Actions + +extension PostgresAdvancedObjectsViewModel { + + func createForeignServer(name: String, type: String?, version: String?, fdwName: String, options: [String: String]?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating foreign server \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createForeignServer(name: name, type: type, version: version, fdwName: fdwName, options: options) + handle?.succeed() + panelState?.appendMessage("Created foreign server '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create server: \(error.localizedDescription)", severity: .error) + } + } + + func createEventTrigger(name: String, event: String, function: String, tags: [String]?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating event trigger \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.triggers.createEventTrigger(name: name, event: event, function: function, tags: tags) + handle?.succeed() + panelState?.appendMessage("Created event trigger '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create event trigger: \(error.localizedDescription)", severity: .error) + } + } + + func createDomain(name: String, schema: String?, dataType: String, defaultValue: String?, notNull: Bool, checkExpression: String?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating domain \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.createDomain(name: name, dataType: dataType, defaultValue: defaultValue, notNull: notNull, checkExpression: checkExpression, schema: schema) + handle?.succeed() + panelState?.appendMessage("Created domain '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create domain: \(error.localizedDescription)", severity: .error) + } + } + + func createCompositeType(name: String, schema: String?, attributes: [(name: String, dataType: String)]) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating composite type \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.createCompositeType(name: name, attributes: attributes, schema: schema) + handle?.succeed() + panelState?.appendMessage("Created composite type '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create type: \(error.localizedDescription)", severity: .error) + } + } + + func createRangeType(name: String, schema: String?, subtype: String, opClass: String?, collation: String?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating range type \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.createRangeType(name: name, subtype: subtype, subtypeOpClass: opClass, collation: collation, schema: schema) + handle?.succeed() + panelState?.appendMessage("Created range type '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create range type: \(error.localizedDescription)", severity: .error) + } + } + + func createCollation(name: String, schema: String?, locale: String?, provider: String?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating collation \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createCollation(name: name, locale: locale, provider: provider, schema: schema) + handle?.succeed() + panelState?.appendMessage("Created collation '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create collation: \(error.localizedDescription)", severity: .error) + } + } + + func createFTSConfig(name: String, schema: String?, parser: String?, copySource: String?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating FTS config \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createTextSearchConfiguration(name: name, parser: parser, copy: copySource, schema: schema) + handle?.succeed() + panelState?.appendMessage("Created text search config '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create FTS config: \(error.localizedDescription)", severity: .error) + } + } + + func createRule(name: String, table: String, schema: String?, event: String, doInstead: Bool, condition: String?, commands: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating rule \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createRule(name: name, table: table, event: event, doInstead: doInstead, condition: condition, commands: commands, schema: schema) + handle?.succeed() + panelState?.appendMessage("Created rule '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create rule: \(error.localizedDescription)", severity: .error) + } + } + + func createTablespace(name: String, location: String, owner: String?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating tablespace \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createTablespace(name: name, location: location, owner: owner) + handle?.succeed() + panelState?.appendMessage("Created tablespace '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create tablespace: \(error.localizedDescription)", severity: .error) + } + } + + func createAggregate(name: String, inputType: String, sfunc: String, stype: String, initcond: String?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating aggregate \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createAggregate(name: name, inputType: inputType, sfunc: sfunc, stype: stype, initcond: initcond, schema: schemaFilter) + handle?.succeed() + panelState?.appendMessage("Created aggregate '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create aggregate: \(error.localizedDescription)", severity: .error) + } + } + + func createOperator(name: String, leftType: String?, rightType: String?, procedure: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating operator \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createOperator(name: name, leftType: leftType, rightType: rightType, procedure: procedure, schema: schemaFilter) + handle?.succeed() + panelState?.appendMessage("Created operator '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create operator: \(error.localizedDescription)", severity: .error) + } + } + + func createLanguage(name: String, trusted: Bool, handler: String?, validator: String?) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating language \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createLanguage(name: name, trusted: trusted, handler: handler, validator: validator) + handle?.succeed() + panelState?.appendMessage("Created language '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create language: \(error.localizedDescription)", severity: .error) + } + } + + func createCast(sourceType: String, targetType: String, function: String?, asAssignment: Bool, asImplicit: Bool) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Creating cast (\(sourceType) AS \(targetType))", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.createCast(sourceType: sourceType, targetType: targetType, function: function, asAssignment: asAssignment, asImplicit: asImplicit) + handle?.succeed() + panelState?.appendMessage("Created cast '\(sourceType) -> \(targetType)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to create cast: \(error.localizedDescription)", severity: .error) + } + } +} diff --git a/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+DropActions.swift b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+DropActions.swift new file mode 100644 index 000000000..c24816cba --- /dev/null +++ b/Echo/Sources/Features/Security/Domain/PostgresAdvancedObjectsViewModel+DropActions.swift @@ -0,0 +1,206 @@ +import Foundation +import PostgresKit + +// MARK: - Drop Actions + +extension PostgresAdvancedObjectsViewModel { + + func dropFDW(_ name: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping FDW \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropForeignDataWrapper(name: name, ifExists: true, cascade: true) + handle?.succeed() + panelState?.appendMessage("Dropped FDW '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop FDW: \(error.localizedDescription)", severity: .error) + } + } + + func dropForeignServer(_ name: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping server \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropForeignServer(name: name, ifExists: true, cascade: true) + handle?.succeed() + panelState?.appendMessage("Dropped foreign server '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop server: \(error.localizedDescription)", severity: .error) + } + } + + func dropEventTrigger(_ name: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping event trigger \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.triggers.dropEventTrigger(name: name, ifExists: true, cascade: true) + handle?.succeed() + panelState?.appendMessage("Dropped event trigger '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop event trigger: \(error.localizedDescription)", severity: .error) + } + } + + func dropDomain(_ name: String, schema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping domain \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.dropDomain(name: name, ifExists: true, cascade: true, schema: schema) + handle?.succeed() + panelState?.appendMessage("Dropped domain '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop domain: \(error.localizedDescription)", severity: .error) + } + } + + func dropCompositeType(_ name: String, schema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping type \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.dropCompositeType(name: name, ifExists: true, cascade: true, schema: schema) + handle?.succeed() + panelState?.appendMessage("Dropped composite type '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop type: \(error.localizedDescription)", severity: .error) + } + } + + func dropRangeType(_ name: String, schema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping range type \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.types.dropRangeType(name: name, ifExists: true, cascade: true, schema: schema) + handle?.succeed() + panelState?.appendMessage("Dropped range type '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop range type: \(error.localizedDescription)", severity: .error) + } + } + + func dropCollation(_ name: String, schema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping collation \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropCollation(name: name, ifExists: true, cascade: true, schema: schema) + handle?.succeed() + panelState?.appendMessage("Dropped collation '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop collation: \(error.localizedDescription)", severity: .error) + } + } + + func dropFTSConfig(_ name: String, schema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping FTS config \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropTextSearchConfiguration(name: name, ifExists: true, cascade: true, schema: schema) + handle?.succeed() + panelState?.appendMessage("Dropped text search config '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop FTS config: \(error.localizedDescription)", severity: .error) + } + } + + func dropRule(_ name: String, table: String, schema: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping rule \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropRule(name: name, table: table, ifExists: true, cascade: true, schema: schema) + handle?.succeed() + panelState?.appendMessage("Dropped rule '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop rule: \(error.localizedDescription)", severity: .error) + } + } + + func dropTablespace(_ name: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping tablespace \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropTablespace(name: name, ifExists: true) + handle?.succeed() + panelState?.appendMessage("Dropped tablespace '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop tablespace: \(error.localizedDescription)", severity: .error) + } + } + + func dropAggregate(_ id: String) async { + guard let pg = session as? PostgresSession else { return } + guard let agg = aggregates.first(where: { $0.id == id }) else { return } + let handle = activityEngine?.begin("Dropping aggregate \(agg.name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropAggregate(name: agg.name, inputType: agg.inputType, ifExists: true, cascade: true, schema: agg.schema) + handle?.succeed() + panelState?.appendMessage("Dropped aggregate '\(agg.name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop aggregate: \(error.localizedDescription)", severity: .error) + } + } + + func dropOperator(_ id: String) async { + guard let pg = session as? PostgresSession else { return } + guard let op = operators.first(where: { $0.id == id }) else { return } + let handle = activityEngine?.begin("Dropping operator \(op.name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropOperator(name: op.name, leftType: op.leftType, rightType: op.rightType, ifExists: true, cascade: true, schema: op.schema) + handle?.succeed() + panelState?.appendMessage("Dropped operator '\(op.name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop operator: \(error.localizedDescription)", severity: .error) + } + } + + func dropLanguage(_ name: String) async { + guard let pg = session as? PostgresSession else { return } + let handle = activityEngine?.begin("Dropping language \(name)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropLanguage(name: name, ifExists: true, cascade: true) + handle?.succeed() + panelState?.appendMessage("Dropped language '\(name)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop language: \(error.localizedDescription)", severity: .error) + } + } + + func dropCast(_ id: String) async { + guard let pg = session as? PostgresSession else { return } + guard let cast = casts.first(where: { $0.id == id }) else { return } + let handle = activityEngine?.begin("Dropping cast \(cast.sourceType) -> \(cast.targetType)", connectionSessionID: connectionSessionID) + do { + try await pg.client.admin.dropCast(sourceType: cast.sourceType, targetType: cast.targetType, ifExists: true) + handle?.succeed() + panelState?.appendMessage("Dropped cast '\(cast.sourceType) -> \(cast.targetType)'") + await loadCurrentSection() + } catch { + handle?.fail(error.localizedDescription) + panelState?.appendMessage("Failed to drop cast: \(error.localizedDescription)", severity: .error) + } + } +} diff --git a/Echo/Sources/Shared/DesignSystem/Components/DatabaseTypeIcon.swift b/Echo/Sources/Shared/DesignSystem/Components/DatabaseTypeIcon.swift new file mode 100644 index 000000000..f11e81bb7 --- /dev/null +++ b/Echo/Sources/Shared/DesignSystem/Components/DatabaseTypeIcon.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct DatabaseTypeIcon: View { + let databaseType: DatabaseType + var tint: Color? = nil + var isColorful: Bool = true + var presentation: RasterSymbolPresentation = .standard + var glyphScale: CGFloat = 1 + + @ViewBuilder + var body: some View { +#if os(macOS) + if let nativeImage = nativeImage { + Image(nsImage: nativeImage) + .renderingMode(databaseType.usesTemplateIcon ? .template : .original) + .foregroundStyle(foregroundTint) + .grayscale(isColorful || databaseType.usesTemplateIcon ? 0 : 1) + .accessibilityHidden(true) + } else { + fallbackBody + } +#else + fallbackBody +#endif + } + + @ViewBuilder + private var fallbackBody: some View { + SymbolLikeAssetImage( + assetName: databaseType.iconName, + isTemplate: databaseType.usesTemplateIcon, + tint: tint, + isColorful: isColorful, + presentation: presentation, + glyphScale: glyphScale + ) + } + + private var foregroundTint: Color { + if databaseType.usesTemplateIcon { + return isColorful ? (tint ?? Color.primary) : ColorTokens.Sidebar.symbol + } + return .primary + } + +#if os(macOS) + private var nativeImage: NSImage? { + switch presentation { + case .menu: + databaseType.menuIconImage() + case .formControl: + databaseType.formControlIconImage() + case .standard, .landingRecent, .sidebar: + nil + } + } +#endif +} diff --git a/Echo/Sources/Shared/DesignSystem/Components/MSSQLDataTypePicker.swift b/Echo/Sources/Shared/DesignSystem/Components/MSSQLDataTypePicker.swift index 0b3980cd9..4367fce64 100644 --- a/Echo/Sources/Shared/DesignSystem/Components/MSSQLDataTypePicker.swift +++ b/Echo/Sources/Shared/DesignSystem/Components/MSSQLDataTypePicker.swift @@ -126,6 +126,11 @@ struct MSSQLDataTypePicker: View { baseType = "" selection = "" } else { + let currentSelection = Self.selectionState(for: selection) + if currentSelection.baseType.caseInsensitiveCompare(newValue) == .orderedSame { + sizeParam = currentSelection.sizeParam + return + } sizeParam = Self.parameterInfo[newValue.lowercased()]?.defaultValue ?? "" syncToSelection() } @@ -137,17 +142,10 @@ struct MSSQLDataTypePicker: View { private func syncFromSelection() { isSyncing = true defer { isSyncing = false } - let parsed = parseType(selection) - if Self.allFlat.contains(where: { $0.lowercased() == parsed.base.lowercased() }) { - baseType = Self.allFlat.first { $0.lowercased() == parsed.base.lowercased() } ?? parsed.base - sizeParam = parsed.param.isEmpty ? (Self.parameterInfo[baseType.lowercased()]?.defaultValue ?? "") : parsed.param - } else if selection.isEmpty { - baseType = "nvarchar" - sizeParam = "255" - syncToSelection() - } else { - isCustom = true - } + let resolvedState = Self.selectionState(for: selection) + baseType = resolvedState.baseType + sizeParam = resolvedState.sizeParam + isCustom = resolvedState.isCustom } private func syncToSelection() { @@ -162,7 +160,23 @@ struct MSSQLDataTypePicker: View { selection = newValue } - private func parseType(_ type: String) -> (base: String, param: String) { + internal static func selectionState(for selection: String) -> (baseType: String, sizeParam: String, isCustom: Bool) { + let trimmedSelection = selection.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSelection.isEmpty else { + return ("", "", false) + } + + let parsed = parseType(trimmedSelection) + guard let matchedType = allFlat.first(where: { $0.lowercased() == parsed.base.lowercased() }) else { + return ("", "", true) + } + + // Preserve metadata-provided bare types such as `nvarchar` so opening the + // editor doesn't rewrite them to an arbitrary default like `nvarchar(255)`. + return (matchedType, parsed.param, false) + } + + private static func parseType(_ type: String) -> (base: String, param: String) { guard let openParen = type.firstIndex(of: "("), let closeParen = type.lastIndex(of: ")") else { return (type, "") diff --git a/Echo/Sources/Shared/DesignSystem/Components/SidebarConnectionHeader.swift b/Echo/Sources/Shared/DesignSystem/Components/SidebarConnectionHeader.swift index 055a95c01..1653d4118 100644 --- a/Echo/Sources/Shared/DesignSystem/Components/SidebarConnectionHeader.swift +++ b/Echo/Sources/Shared/DesignSystem/Components/SidebarConnectionHeader.swift @@ -16,6 +16,12 @@ struct SidebarConnectionHeader: View { let connectionState: ConnectionState let onAction: () -> Void var trailingAccessory: TrailingAccessory = .chevron + var iconScale: CGFloat = 1 + var iconFrameScale: CGFloat = 1 + var iconGlyphScale: CGFloat = 1 + var leadingPaddingAdjustment: CGFloat = 0 + var statusPresentation: StatusPresentation = .overlayIcon + var labelFont: Font? = nil @Environment(\.sidebarDensity) private var density @@ -26,6 +32,12 @@ struct SidebarConnectionHeader: View { case none } + enum StatusPresentation { + case overlayIcon + case inlineDot + case none + } + private var statusInfo: (color: Color, label: String?) { switch connectionState { case .connected: @@ -74,6 +86,9 @@ struct SidebarConnectionHeader: View { } private var densityLabelFont: Font { + if let labelFont { + return labelFont + } switch density { case .small: return SidebarRowConstants.labelFont // 11pt case .medium: return Font.system(size: 12, weight: .regular) @@ -91,10 +106,6 @@ struct SidebarConnectionHeader: View { // MARK: - Icon - private var iconColor: Color { - isColorful ? connectionColor : ColorTokens.Sidebar.symbol - } - // MARK: - Highlight private var highlightFill: some View { @@ -114,21 +125,7 @@ struct SidebarConnectionHeader: View { } .frame(width: SidebarRowConstants.chevronWidth) - // Server icon with status dot - ZStack(alignment: .bottomTrailing) { - Image(systemName: databaseType.symbolName) - .font(densityIconFont) - .imageScale(.medium) - .symbolRenderingMode(.monochrome) - .foregroundStyle(iconColor) - .frame(width: densityIconFrameWidth, height: densityIconFrameHeight) - - Circle() - .fill(statusInfo.color) - .frame(width: densityStatusDotSize, height: densityStatusDotSize) - .overlay(Circle().stroke(Color.white.opacity(0.4), lineWidth: 0.75)) - .offset(x: 1.5, y: 1.5) - } + serverIconView // Connection name — single line, same font as SidebarRow Text(connectionName) @@ -136,6 +133,10 @@ struct SidebarConnectionHeader: View { .foregroundStyle(ColorTokens.Text.primary) .lineLimit(1) + if statusPresentation == .inlineDot { + inlineStatusIndicator + } + if isSecure { Image(systemName: "lock.fill") .font(.system(size: density == .large ? 9 : 8)) @@ -152,7 +153,7 @@ struct SidebarConnectionHeader: View { trailingAccessoryView } - .padding(.leading, SidebarRowConstants.rowLeadingPadding) + .padding(.leading, SidebarRowConstants.rowLeadingPadding + leadingPaddingAdjustment) .padding(.trailing, SidebarRowConstants.rowTrailingPadding) .padding(.vertical, densityVerticalPadding) .background(highlightFill) @@ -165,6 +166,58 @@ struct SidebarConnectionHeader: View { .focusable(false) } + @ViewBuilder + private var serverIconView: some View { + switch statusPresentation { + case .overlayIcon: + ZStack(alignment: .bottomTrailing) { + iconImage + statusDot + .offset(x: 1.5, y: 1.5) + } + case .inlineDot, .none: + iconImage + } + } + + private var iconImage: some View { + DatabaseTypeIcon( + databaseType: databaseType, + tint: connectionColor, + isColorful: isColorful, + presentation: .sidebar, + glyphScale: iconGlyphScale + ) + .scaleEffect(iconScale) + .frame( + width: densityIconFrameWidth * iconFrameScale, + height: densityIconFrameHeight * iconFrameScale + ) + } + + private var statusDot: some View { + Circle() + .fill(statusInfo.color) + .frame(width: densityStatusDotSize, height: densityStatusDotSize) + .overlay(Circle().stroke(Color.white.opacity(0.4), lineWidth: 0.75)) + } + + @ViewBuilder + private var inlineStatusIndicator: some View { + switch connectionState { + case .connected, .disconnected, .error: + statusDot + .shadow(color: statusInfo.color.opacity(0.18), radius: 1.5, y: 0.5) + .padding(.leading, SpacingTokens.xxxs) + .padding(.trailing, SpacingTokens.xxxs) + case .connecting, .testing: + ProgressView() + .controlSize(.mini) + .padding(.leading, SpacingTokens.xxxs) + .padding(.trailing, SpacingTokens.xxxs) + } + } + // MARK: - Trailing Accessory @ViewBuilder diff --git a/Echo/Sources/Shared/DesignSystem/Components/SymbolLikeAssetImage.swift b/Echo/Sources/Shared/DesignSystem/Components/SymbolLikeAssetImage.swift new file mode 100644 index 000000000..0fb5a3685 --- /dev/null +++ b/Echo/Sources/Shared/DesignSystem/Components/SymbolLikeAssetImage.swift @@ -0,0 +1,82 @@ +import SwiftUI + +enum RasterSymbolPresentation: Sendable { + case standard + case menu + case formControl + case landingRecent + case sidebar + + var canvasWidth: CGFloat { + switch self { + case .standard, .menu, .formControl: + return LayoutTokens.Icon.standardCanvas + case .landingRecent: + return LayoutTokens.Icon.landingRecentCanvas + case .sidebar: + return LayoutTokens.Icon.sidebarCanvasWidth + } + } + + var canvasHeight: CGFloat { + switch self { + case .standard: + return LayoutTokens.Icon.standardCanvas + case .menu: + return LayoutTokens.Icon.menuCanvas + case .formControl: + return LayoutTokens.Icon.formControlCanvas + case .landingRecent: + return LayoutTokens.Icon.landingRecentCanvas + case .sidebar: + return LayoutTokens.Icon.sidebarCanvasHeight + } + } + + var glyphSize: CGFloat { + switch self { + case .standard: + return LayoutTokens.Icon.standardGlyph + case .menu: + return LayoutTokens.Icon.menuGlyph + case .formControl: + return LayoutTokens.Icon.formControlGlyph + case .landingRecent: + return LayoutTokens.Icon.landingRecentGlyph + case .sidebar: + return LayoutTokens.Icon.sidebarGlyph + } + } +} + +struct SymbolLikeAssetImage: View { + let assetName: String + let isTemplate: Bool + var tint: Color? = nil + var isColorful: Bool = true + var presentation: RasterSymbolPresentation = .standard + var glyphScale: CGFloat = 1 + + @ViewBuilder + var body: some View { + ZStack { + if isTemplate { + Image(assetName) + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundStyle(isColorful ? (tint ?? Color.primary) : ColorTokens.Sidebar.symbol) + .frame(width: presentation.glyphSize * glyphScale, height: presentation.glyphSize * glyphScale) + } else { + Image(assetName) + .renderingMode(.original) + .resizable() + .scaledToFit() + .grayscale(isColorful ? 0 : 1) + .frame(width: presentation.glyphSize * glyphScale, height: presentation.glyphSize * glyphScale) + } + } + .frame(width: presentation.canvasWidth, height: presentation.canvasHeight) + .accessibilityHidden(true) + } +} diff --git a/Echo/Sources/Shared/DesignSystem/LayoutToken.swift b/Echo/Sources/Shared/DesignSystem/LayoutToken.swift index 08fe3a76a..86eacf93b 100644 --- a/Echo/Sources/Shared/DesignSystem/LayoutToken.swift +++ b/Echo/Sources/Shared/DesignSystem/LayoutToken.swift @@ -1,6 +1,35 @@ import SwiftUI public enum LayoutTokens { + public enum Icon { + /// Default square canvas for icon assets embedded inline with 13pt text. + public static let standardCanvas: CGFloat = SpacingTokens.md + /// Visual glyph size inside the standard canvas. + public static let standardGlyph: CGFloat = SpacingTokens.sm + + /// Landing-page recent connection icon canvas with no background tile. + public static let landingRecentCanvas: CGFloat = SpacingTokens.md2 + /// Landing-page recent connection glyph size tuned for card rows. + public static let landingRecentGlyph: CGFloat = SpacingTokens.md2 + + /// Tight 16pt menu canvas matching AppKit/SwiftUI menu row expectations. + public static let menuCanvas: CGFloat = SpacingTokens.md + /// Menu glyph size aligned with the Manage Connections / form-control reference. + public static let menuGlyph: CGFloat = SpacingTokens.md + + /// Form control icon canvas for pop-up buttons and picker labels. + public static let formControlCanvas: CGFloat = SpacingTokens.md + /// Form control glyph size tuned to match Manage Connections type icons. + public static let formControlGlyph: CGFloat = SpacingTokens.md + + /// Sidebar server icon canvas width. + public static let sidebarCanvasWidth: CGFloat = SpacingTokens.md1 + /// Sidebar server icon canvas height. + public static let sidebarCanvasHeight: CGFloat = SpacingTokens.md + /// Sidebar glyph size inside the Tahoe-style sidebar row. + public static let sidebarGlyph: CGFloat = SpacingTokens.sm + } + public enum Form { /// 32pt — Standard minimum height for a settings or property row (compact Tahoe style) public static let rowMinHeight: CGFloat = 32 diff --git a/Echo/Sources/Shared/Notifications/NotificationCategory.swift b/Echo/Sources/Shared/Notifications/NotificationCategory.swift index 8e6c54098..8da137d5b 100644 --- a/Echo/Sources/Shared/Notifications/NotificationCategory.swift +++ b/Echo/Sources/Shared/Notifications/NotificationCategory.swift @@ -58,6 +58,30 @@ enum NotificationCategory: String, CaseIterable, Identifiable, Codable, Sendable var id: String { rawValue } + // MARK: - Critical Default + + /// Whether this category is enabled by default on first launch. + /// Only error and failure notifications are considered critical. + var isCriticalDefault: Bool { + switch self { + case .connectionFailed, + .extensionFailed, + .maintenanceFailed, + .securityToggleFailed, + .indexRebuildFailed, + .databaseCreationFailed, + .databaseSwitchFailed, + .databasePropertiesError, + .jobError, + .generalError: + return true + default: + return false + } + } + + // MARK: - Group + var group: NotificationGroup { switch self { case .connectionConnected, .connectionDisconnected, .connectionFailed: @@ -79,6 +103,8 @@ enum NotificationCategory: String, CaseIterable, Identifiable, Codable, Sendable } } + // MARK: - Display + var displayName: String { switch self { case .connectionConnected: return "Connected" @@ -117,6 +143,46 @@ enum NotificationCategory: String, CaseIterable, Identifiable, Codable, Sendable } } + var displayDescription: String { + switch self { + case .connectionConnected: return "When a database connection is established" + case .connectionDisconnected: return "When a connection is closed or drops" + case .connectionFailed: return "When a connection attempt fails" + case .objectDropped: return "When a database object is deleted" + case .objectRenamed: return "When a database object is renamed" + case .objectCreated: return "When a new database object is created" + case .objectTruncated: return "When a table is truncated" + case .extensionInstalled: return "When a PostgreSQL extension is installed" + case .extensionFailed: return "When an extension operation fails" + case .maintenanceCompleted: return "When a maintenance task finishes" + case .maintenanceFailed: return "When a maintenance task fails" + case .securityDropped: return "When a security object is removed" + case .securityToggleFailed: return "When a security setting change fails" + case .tableStructureUpdated: return "When a table structure change is applied" + case .indexCreated: return "When a new index is created" + case .indexDropped: return "When an index is removed" + case .indexRebuilt: return "When an index rebuild completes" + case .indexRebuildFailed: return "When an index rebuild fails" + case .databaseCreated: return "When a new database is created" + case .databaseCreationFailed: return "When database creation fails" + case .databaseSwitched: return "When the active database changes" + case .databaseSwitchFailed: return "When a database switch fails" + case .databasePropertiesError: return "When database property changes fail" + case .databasePropertiesSaved: return "When database properties are saved" + case .jobStarted: return "When a SQL Agent job starts running" + case .jobStopped: return "When a SQL Agent job is stopped" + case .jobError: return "When a SQL Agent job encounters an error" + case .jobScheduleCreated: return "When a new job schedule is created" + case .jobNotificationSaved: return "When job notification settings are saved" + case .jobPropertiesSaved: return "When job properties are saved" + case .generalSuccess: return "When an operation succeeds" + case .generalError: return "When an unexpected error occurs" + case .generalInfo: return "General informational alerts" + } + } + + // MARK: - Visuals + var defaultIcon: String { switch self { case .connectionConnected: return "checkmark.circle.fill" @@ -195,6 +261,17 @@ enum NotificationGroup: String, CaseIterable, Identifiable, Sendable { } } + var displayDescription: String { + switch self { + case .connection: return "Connection status and errors" + case .objectBrowser: return "Object lifecycle, extensions, and maintenance" + case .tableStructure: return "Schema changes and index operations" + case .database: return "Database creation, switching, and properties" + case .jobs: return "SQL Agent job activity" + case .general: return "App-wide success, error, and info alerts" + } + } + var systemImage: String { switch self { case .connection: return "bolt.horizontal.circle" @@ -209,4 +286,14 @@ enum NotificationGroup: String, CaseIterable, Identifiable, Sendable { var categories: [NotificationCategory] { NotificationCategory.allCases.filter { $0.group == self } } + + /// Whether all categories in this group are critical defaults. + var isAllCritical: Bool { + categories.allSatisfy(\.isCriticalDefault) + } + + /// Whether any categories in this group are critical defaults. + var hasCriticalCategories: Bool { + categories.contains(where: \.isCriticalDefault) + } } diff --git a/Echo/Sources/Shared/Notifications/NotificationPreferences.swift b/Echo/Sources/Shared/Notifications/NotificationPreferences.swift index bba31be92..8e7a46831 100644 --- a/Echo/Sources/Shared/Notifications/NotificationPreferences.swift +++ b/Echo/Sources/Shared/Notifications/NotificationPreferences.swift @@ -13,6 +13,14 @@ enum NotificationDelivery: String, Codable, Hashable, CaseIterable, Sendable { case .both: return "Both" } } + + var displayDescription: String { + switch self { + case .inApp: return "Show banners inside Echo" + case .native: return "Show macOS Notification Center banners" + case .both: return "Show both in-app and macOS banners" + } + } } /// User-configurable notification settings, persisted in ``GlobalSettings``. @@ -20,8 +28,15 @@ struct NotificationPreferences: Codable, Hashable, Sendable { var delivery: NotificationDelivery = .inApp var disabledCategories: Set = [] + /// Whether a specific category is enabled. + /// On first launch (empty `disabledCategories` and no stored defaults), + /// only critical categories (errors and failures) are enabled. func isEnabled(_ category: NotificationCategory) -> Bool { - !disabledCategories.contains(category.rawValue) + if hasExplicitPreferences { + return !disabledCategories.contains(category.rawValue) + } + // Fresh install: only critical categories are on by default + return category.isCriticalDefault } mutating func setEnabled(_ enabled: Bool, for category: NotificationCategory) { @@ -31,4 +46,56 @@ struct NotificationPreferences: Codable, Hashable, Sendable { disabledCategories.insert(category.rawValue) } } + + /// Whether the user has explicitly toggled any category. + /// When false, the system uses the critical-defaults policy. + private var hasExplicitPreferences: Bool { + // If the disabled set is non-empty, the user has made choices. + // We also check for a sentinel key written on first explicit toggle. + return disabledCategories.contains(hasExplicitPreferencesKey) + || disabledCategories.contains(allEnabledSentinelKey) + } + + /// Mark that the user has explicitly chosen their preferences. + mutating func markExplicitPreferences() { + // Ensure the preferences are recognized as explicit going forward + if !hasExplicitPreferences { + disabledCategories.insert(hasExplicitPreferencesKey) + } + } + + /// Enable all notification categories. + mutating func enableAll() { + disabledCategories = [hasExplicitPreferencesKey] + } + + /// Disable all notification categories. + mutating func disableAll() { + var all = Set(NotificationCategory.allCases.map(\.rawValue)) + all.insert(hasExplicitPreferencesKey) + disabledCategories = all + } + + /// Whether all categories are enabled (given explicit preferences). + var isAllEnabled: Bool { + guard hasExplicitPreferences else { return false } + return disabledCategories.subtracting([hasExplicitPreferencesKey, allEnabledSentinelKey]).isEmpty + } + + /// Whether a whole group has any enabled notifications. + func isGroupEnabled(_ group: NotificationGroup) -> Bool { + group.categories.contains { isEnabled($0) } + } + + /// Enable or disable an entire group at once. + mutating func setGroupEnabled(_ enabled: Bool, for group: NotificationGroup) { + markExplicitPreferences() + for category in group.categories { + setEnabled(enabled, for: category) + } + } + + // Sentinel keys for tracking explicit user preferences + private var hasExplicitPreferencesKey: String { "__explicit" } + private var allEnabledSentinelKey: String { "__allEnabled" } } diff --git a/Echo/Sources/Shared/PlatformBridge/NSImage+DatabaseIcons.swift b/Echo/Sources/Shared/PlatformBridge/NSImage+DatabaseIcons.swift new file mode 100644 index 000000000..07b24b74f --- /dev/null +++ b/Echo/Sources/Shared/PlatformBridge/NSImage+DatabaseIcons.swift @@ -0,0 +1,48 @@ +import AppKit + +extension DatabaseType { + func rasterSymbolImage( + canvasSize: CGFloat, + glyphSize: CGFloat + ) -> NSImage? { + guard let image = NSImage(named: iconName) else { return nil } + + let targetSize = NSSize(width: canvasSize, height: canvasSize) + let canvas = NSImage(size: targetSize, flipped: false) { rect in + let sourceSize = image.size + guard sourceSize.width > 0, sourceSize.height > 0 else { return false } + + let scale = min(glyphSize / sourceSize.width, glyphSize / sourceSize.height) + let drawSize = NSSize( + width: sourceSize.width * scale, + height: sourceSize.height * scale + ) + let drawRect = NSRect( + x: rect.midX - drawSize.width / 2, + y: rect.midY - drawSize.height / 2, + width: drawSize.width, + height: drawSize.height + ) + + image.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1) + return true + } + + canvas.isTemplate = usesTemplateIcon + return canvas + } + + func menuIconImage( + canvasSize: CGFloat = LayoutTokens.Icon.menuCanvas, + glyphSize: CGFloat = LayoutTokens.Icon.menuGlyph + ) -> NSImage? { + rasterSymbolImage(canvasSize: canvasSize, glyphSize: glyphSize) + } + + func formControlIconImage() -> NSImage? { + rasterSymbolImage( + canvasSize: LayoutTokens.Icon.formControlCanvas, + glyphSize: LayoutTokens.Icon.formControlGlyph + ) + } +} diff --git a/Echo/Sources/UI/Modals/FolderEditorSheet+Components.swift b/Echo/Sources/UI/Modals/FolderEditorSheet+Components.swift new file mode 100644 index 000000000..4e29e74ec --- /dev/null +++ b/Echo/Sources/UI/Modals/FolderEditorSheet+Components.swift @@ -0,0 +1,60 @@ +import SwiftUI + +extension FolderEditorSheet { + + // MARK: - Icon Palette + + var iconPaletteView: some View { + HStack(spacing: SpacingTokens.xxs2) { + ForEach(availableIcons, id: \.self) { iconName in + iconSwatch(name: iconName, isSelected: selectedIcon == iconName) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.15)) { + selectedIcon = iconName + } + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + func iconSwatch(name: String, isSelected: Bool) -> some View { + Image(systemName: name) + .font(TypographyTokens.prominent) + .frame(width: 26, height: 26) + .foregroundStyle(isSelected ? Color.white : ColorTokens.Text.secondary) + .background(isSelected ? ColorTokens.accent : Color.clear, in: RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + } + + // MARK: - Color Palette + + var colorPaletteView: some View { + HStack(spacing: SpacingTokens.xs) { + ForEach(FolderIdentityPalette.defaults, id: \.self) { hex in + let swatch = Color(hex: hex) ?? ColorTokens.accent + colorSwatch(color: swatch, isSelected: selectedColorHex == hex) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.15)) { selectedColorHex = hex } + } + } + + ColorPicker("", selection: folderColorBinding, supportsOpacity: false) + .labelsHidden() + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + func colorSwatch(color: Color, isSelected: Bool) -> some View { + Circle().fill(color).frame(width: SpacingTokens.md2, height: SpacingTokens.md2) + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(TypographyTokens.label.weight(.bold)) + .foregroundStyle(.white) + } + } + .overlay(Circle().strokeBorder(ColorTokens.Text.primary.opacity(0.15), lineWidth: 0.5)) + .contentShape(Circle()) + } +} diff --git a/Echo/Sources/UI/Modals/FolderEditorSheet+Credentials.swift b/Echo/Sources/UI/Modals/FolderEditorSheet+Credentials.swift new file mode 100644 index 000000000..6384358fb --- /dev/null +++ b/Echo/Sources/UI/Modals/FolderEditorSheet+Credentials.swift @@ -0,0 +1,104 @@ +import SwiftUI + +extension FolderEditorSheet { + + // MARK: - Credentials + + @ViewBuilder + var credentialsFormSection: some View { + Section("Credentials") { + PropertyRow(title: "Mode") { + Picker("", selection: $credentialMode) { + Text("None").tag(FolderCredentialMode.none) + Text("Manual").tag(FolderCredentialMode.manual) + Text("Identity").tag(FolderCredentialMode.identity) + if canUseInheritance { Text("Inherit").tag(FolderCredentialMode.inherit) } + } + .labelsHidden() + .pickerStyle(.segmented) + } + + switch credentialMode { + case .manual: + PropertyRow(title: "Username") { + TextField("", text: $manualUsername, prompt: Text("shared_user")) + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + } + + PropertyRow(title: "Password") { + SecureField("", text: Binding( + get: { manualPassword }, + set: { manualPassword = $0; manualPasswordDirty = true } + ), prompt: Text("••••••••")) + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + } + + if editingFolderUsesManual && !manualPasswordDirty { + Text("Existing password will be kept unless changed.") + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Text.secondary) + .listRowSeparator(.hidden) + } + case .identity: + identitySelectionContent + case .inherit: + if let identity = inheritedIdentity { + Text("Inherits identity \"\(identity.name)\" from parent folder.") + .foregroundStyle(ColorTokens.Text.secondary) + .font(TypographyTokens.formDescription) + .listRowSeparator(.hidden) + } else { + Text("Parent folder does not provide credentials.") + .foregroundStyle(ColorTokens.Status.error) + .font(TypographyTokens.formDescription) + .listRowSeparator(.hidden) + } + case .none: + EmptyView() + } + } + } + + var identitySelectionContent: some View { + Group { + if availableIdentities.isEmpty { + PropertyRow(title: "Identity") { + VStack(alignment: .trailing, spacing: SpacingTokens.xs) { + Text("No identities available.") + .foregroundStyle(ColorTokens.Text.secondary) + .font(TypographyTokens.formDescription) + Button("Create Identity") { + identityEditorState = .create(parent: nil, token: UUID()) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } else { + PropertyRow(title: "Identity") { + HStack(spacing: SpacingTokens.xs) { + Picker("", selection: $selectedIdentityID) { + Text("Select").tag(UUID?.none) + ForEach(availableIdentities, id: \.id) { + Text($0.name).tag(UUID?.some($0.id)) + } + } + .labelsHidden() + .pickerStyle(.menu) + + Button { + identityEditorState = .create(parent: nil, token: UUID()) + } label: { + Image(systemName: "plus") + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel("Add identity") + } + } + } + } + } +} diff --git a/Echo/Sources/UI/Modals/FolderEditorSheet+Logic.swift b/Echo/Sources/UI/Modals/FolderEditorSheet+Logic.swift new file mode 100644 index 000000000..9df34d912 --- /dev/null +++ b/Echo/Sources/UI/Modals/FolderEditorSheet+Logic.swift @@ -0,0 +1,83 @@ +import SwiftUI + +extension FolderEditorSheet { + + // MARK: - Logic + + func handleCredentialModeChange(_ newMode: FolderCredentialMode) { + if newMode == .manual { + manualUsername = editingFolderUsesManual ? (editingFolder?.manualUsername ?? "") : "" + manualPassword = "" + manualPasswordDirty = false + } else if newMode == .identity && selectedIdentityID == nil { + selectedIdentityID = availableIdentities.first?.id + } else { + manualUsername = "" + manualPassword = "" + manualPasswordDirty = false + } + } + + func prepareInitialValues() { + if case .edit(let folder) = state { + name = folder.name + folderDescription = folder.folderDescription ?? "" + selectedColorHex = folder.colorHex + selectedIcon = folder.icon + selectedKind = folder.kind + selectedParentID = folder.parentFolderID + credentialMode = folder.credentialMode + selectedIdentityID = folder.identityID + manualUsername = folder.manualUsername ?? "" + manualPassword = "" + manualPasswordDirty = false + } else if case .create(let kind, let parent, _) = state { + name = "" + folderDescription = "" + selectedColorHex = FolderIdentityPalette.defaults.first ?? "5A9CDE" + selectedIcon = SavedFolder.defaultIcon + selectedKind = kind + selectedParentID = parent?.id + credentialMode = .none + selectedIdentityID = nil + if let parent { + selectedColorHex = parent.colorHex + if parent.credentialMode == .inherit { credentialMode = .inherit } + } + manualUsername = "" + manualPassword = "" + manualPasswordDirty = false + } + } + + func saveFolder() async { + var folder: SavedFolder + switch state { + case .create: + folder = SavedFolder(name: name) + folder.id = UUID() + folder.projectID = projectStore.selectedProject?.id + case .edit(let existing): + folder = existing + folder.name = name + } + + let trimmedDescription = folderDescription.trimmingCharacters(in: .whitespacesAndNewlines) + folder.folderDescription = trimmedDescription.isEmpty ? nil : trimmedDescription + folder.icon = selectedIcon + folder.colorHex = selectedColorHex + folder.kind = selectedKind + folder.parentFolderID = selectedParentID + folder.credentialMode = isIdentityFolder ? .none : credentialMode + folder.identityID = credentialMode == .identity && !isIdentityFolder ? selectedIdentityID : nil + folder.manualUsername = credentialMode == .manual && !isIdentityFolder ? manualUsername.trimmingCharacters(in: .whitespacesAndNewlines) : nil + + let pw = (credentialMode == .manual && !isIdentityFolder && manualPasswordDirty) ? manualPassword.trimmingCharacters(in: .whitespacesAndNewlines) : nil + if let pw { try? environmentState.identityRepository.setPassword(pw, for: &folder) } + + let isNew = !isEditing + try? await connectionStore.updateFolder(folder) + if isNew && folder.kind == .connections { connectionStore.selectedFolderID = folder.id } + dismiss() + } +} diff --git a/Echo/Sources/UI/Modals/FolderEditorSheet+Sections.swift b/Echo/Sources/UI/Modals/FolderEditorSheet+Sections.swift new file mode 100644 index 000000000..f67a13b2d --- /dev/null +++ b/Echo/Sources/UI/Modals/FolderEditorSheet+Sections.swift @@ -0,0 +1,74 @@ +import SwiftUI + +extension FolderEditorSheet { + + // MARK: - Form + + var formContent: some View { + Form { + Section { + PropertyRow(title: "Name") { + TextField("", text: $name, prompt: Text("Folder name")) + .textFieldStyle(.plain) + .multilineTextAlignment(.trailing) + } + + if hasDuplicateName { + Text("A folder with this name already exists here.") + .font(TypographyTokens.formDescription) + .foregroundStyle(ColorTokens.Status.error) + .listRowSeparator(.hidden) + } + + PropertyRow(title: "Description") { + TextField("", text: $folderDescription, prompt: Text("Optional"), axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...3) + .multilineTextAlignment(.trailing) + } + + PropertyRow(title: "Icon") { iconPaletteView } + PropertyRow(title: "Color") { colorPaletteView } + } header: { + Text(isEditing ? "Edit Folder" : "New Folder") + } + + Section("Location") { + PropertyRow(title: "Type") { + Picker("", selection: $selectedKind) { + Text("Connections").tag(FolderKind.connections) + Text("Identities").tag(FolderKind.identities) + } + .labelsHidden() + .pickerStyle(.menu) + } + + PropertyRow(title: "Parent") { + Picker("", selection: $selectedParentID) { + Text("None").tag(UUID?.none) + ForEach(hierarchicalParentFolders, id: \.folder.id) { item in + Text(item.path).tag(UUID?.some(item.folder.id)) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + } + + if !isIdentityFolder { + credentialsFormSection + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .scrollDisabled(true) + .onChange(of: credentialMode) { _, newMode in handleCredentialModeChange(newMode) } + .onChange(of: selectedKind) { _, _ in + selectedParentID = nil + selectedIcon = SavedFolder.defaultIcon + if selectedKind == .identities { + credentialMode = .none + } + } + } +} diff --git a/Echo/Sources/UI/Modals/FolderEditorSheet.swift b/Echo/Sources/UI/Modals/FolderEditorSheet.swift index fbdba761a..e6b111823 100644 --- a/Echo/Sources/UI/Modals/FolderEditorSheet.swift +++ b/Echo/Sources/UI/Modals/FolderEditorSheet.swift @@ -1,52 +1,52 @@ import SwiftUI struct FolderEditorSheet: View { - @Environment(ProjectStore.self) private var projectStore - @Environment(ConnectionStore.self) private var connectionStore - @Environment(EnvironmentState.self) private var environmentState - @Environment(\.dismiss) private var dismiss + @Environment(ProjectStore.self) var projectStore + @Environment(ConnectionStore.self) var connectionStore + @Environment(EnvironmentState.self) var environmentState + @Environment(\.dismiss) var dismiss let state: FolderEditorState - @State private var name: String = "" - @State private var folderDescription: String = "" - @State private var selectedColorHex: String = FolderIdentityPalette.defaults.first ?? "5A9CDE" - @State private var selectedIcon: String = SavedFolder.defaultIcon - @State private var selectedKind: FolderKind = .connections - @State private var selectedParentID: UUID? - @State private var credentialMode: FolderCredentialMode = .none - @State private var selectedIdentityID: UUID? - @State private var manualUsername: String = "" - @State private var manualPassword: String = "" - @State private var manualPasswordDirty = false - @State private var identityEditorState: IdentityEditorState? - - private var editingFolder: SavedFolder? { + @State var name: String = "" + @State var folderDescription: String = "" + @State var selectedColorHex: String = FolderIdentityPalette.defaults.first ?? "5A9CDE" + @State var selectedIcon: String = SavedFolder.defaultIcon + @State var selectedKind: FolderKind = .connections + @State var selectedParentID: UUID? + @State var credentialMode: FolderCredentialMode = .none + @State var selectedIdentityID: UUID? + @State var manualUsername: String = "" + @State var manualPassword: String = "" + @State var manualPasswordDirty = false + @State var identityEditorState: IdentityEditorState? + + var editingFolder: SavedFolder? { if case .edit(let folder) = state { return folder } return nil } - private var isEditing: Bool { editingFolder != nil } + var isEditing: Bool { editingFolder != nil } - private var isIdentityFolder: Bool { selectedKind == .identities } + var isIdentityFolder: Bool { selectedKind == .identities } - private var selectedParentFolder: SavedFolder? { + var selectedParentFolder: SavedFolder? { guard let id = selectedParentID else { return nil } return connectionStore.folders.first(where: { $0.id == id }) } - private var inheritedIdentity: SavedIdentity? { + var inheritedIdentity: SavedIdentity? { guard let parent = selectedParentFolder else { return nil } return environmentState.identityRepository.resolveInheritedIdentity(folderID: parent.id) } - private var editingFolderUsesManual: Bool { editingFolder?.credentialMode == .manual } + var editingFolderUsesManual: Bool { editingFolder?.credentialMode == .manual } - private var availableIdentities: [SavedIdentity] { + var availableIdentities: [SavedIdentity] { connectionStore.identities.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } - private var availableParentFolders: [SavedFolder] { + var availableParentFolders: [SavedFolder] { let projectID = projectStore.selectedProject?.id let editingID = editingFolder?.id return connectionStore.folders @@ -54,7 +54,7 @@ struct FolderEditorSheet: View { .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } - private func folderPath(for folder: SavedFolder) -> String { + func folderPath(for folder: SavedFolder) -> String { var components: [String] = [folder.name] var current = folder while let parentID = current.parentFolderID, @@ -65,26 +65,26 @@ struct FolderEditorSheet: View { return components.joined(separator: " / ") } - private var hierarchicalParentFolders: [(folder: SavedFolder, path: String)] { + var hierarchicalParentFolders: [(folder: SavedFolder, path: String)] { availableParentFolders.map { folder in (folder: folder, path: folderPath(for: folder)) } .sorted { $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending } } - private var folderColorBinding: Binding { + var folderColorBinding: Binding { Binding( get: { Color(hex: selectedColorHex) ?? ColorTokens.accent }, set: { color in selectedColorHex = color.toHex() ?? selectedColorHex } ) } - private var canUseInheritance: Bool { + var canUseInheritance: Bool { guard let parent = selectedParentFolder else { return false } return parent.credentialMode != .none } - private var hasDuplicateName: Bool { + var hasDuplicateName: Bool { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty else { return false } let projectID = projectStore.selectedProject?.id @@ -100,7 +100,7 @@ struct FolderEditorSheet: View { } } - private var isValid: Bool { + var isValid: Bool { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedName.isEmpty { return false } if hasDuplicateName { return false } @@ -114,7 +114,7 @@ struct FolderEditorSheet: View { } } - private var availableIcons: [String] { + var availableIcons: [String] { isIdentityFolder ? FolderIdentityPalette.identityIcons : FolderIdentityPalette.connectionIcons } @@ -149,309 +149,4 @@ struct FolderEditorSheet: View { } .onAppear(perform: prepareInitialValues) } - - // MARK: - Form - - private var formContent: some View { - Form { - Section { - PropertyRow(title: "Name") { - TextField("", text: $name, prompt: Text("Folder name")) - .textFieldStyle(.plain) - .multilineTextAlignment(.trailing) - } - - if hasDuplicateName { - Text("A folder with this name already exists here.") - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Status.error) - .listRowSeparator(.hidden) - } - - PropertyRow(title: "Description") { - TextField("", text: $folderDescription, prompt: Text("Optional"), axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(1...3) - .multilineTextAlignment(.trailing) - } - - PropertyRow(title: "Icon") { iconPaletteView } - PropertyRow(title: "Color") { colorPaletteView } - } header: { - Text(isEditing ? "Edit Folder" : "New Folder") - } - - Section("Location") { - PropertyRow(title: "Type") { - Picker("", selection: $selectedKind) { - Text("Connections").tag(FolderKind.connections) - Text("Identities").tag(FolderKind.identities) - } - .labelsHidden() - .pickerStyle(.menu) - } - - PropertyRow(title: "Parent") { - Picker("", selection: $selectedParentID) { - Text("None").tag(UUID?.none) - ForEach(hierarchicalParentFolders, id: \.folder.id) { item in - Text(item.path).tag(UUID?.some(item.folder.id)) - } - } - .labelsHidden() - .pickerStyle(.menu) - } - } - - if !isIdentityFolder { - credentialsFormSection - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - .scrollDisabled(true) - .onChange(of: credentialMode) { _, newMode in handleCredentialModeChange(newMode) } - .onChange(of: selectedKind) { _, _ in - selectedParentID = nil - selectedIcon = SavedFolder.defaultIcon - if selectedKind == .identities { - credentialMode = .none - } - } - } - - // MARK: - Icon Palette - - private var iconPaletteView: some View { - HStack(spacing: SpacingTokens.xxs2) { - ForEach(availableIcons, id: \.self) { iconName in - iconSwatch(name: iconName, isSelected: selectedIcon == iconName) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.15)) { - selectedIcon = iconName - } - } - } - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - - private func iconSwatch(name: String, isSelected: Bool) -> some View { - Image(systemName: name) - .font(TypographyTokens.prominent) - .frame(width: 26, height: 26) - .foregroundStyle(isSelected ? Color.white : ColorTokens.Text.secondary) - .background(isSelected ? ColorTokens.accent : Color.clear, in: RoundedRectangle(cornerRadius: 6)) - .contentShape(Rectangle()) - } - - // MARK: - Color Palette - - private var colorPaletteView: some View { - HStack(spacing: SpacingTokens.xs) { - ForEach(FolderIdentityPalette.defaults, id: \.self) { hex in - let swatch = Color(hex: hex) ?? ColorTokens.accent - colorSwatch(color: swatch, isSelected: selectedColorHex == hex) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.15)) { selectedColorHex = hex } - } - } - - ColorPicker("", selection: folderColorBinding, supportsOpacity: false) - .labelsHidden() - } - .frame(maxWidth: .infinity, alignment: .trailing) - } - - private func colorSwatch(color: Color, isSelected: Bool) -> some View { - Circle().fill(color).frame(width: SpacingTokens.md2, height: SpacingTokens.md2) - .overlay { - if isSelected { - Image(systemName: "checkmark") - .font(TypographyTokens.label.weight(.bold)) - .foregroundStyle(Color.white) - } - } - .overlay(Circle().strokeBorder(ColorTokens.Text.primary.opacity(0.15), lineWidth: 0.5)) - .contentShape(Circle()) - } - - // MARK: - Credentials - - @ViewBuilder - private var credentialsFormSection: some View { - Section("Credentials") { - PropertyRow(title: "Mode") { - Picker("", selection: $credentialMode) { - Text("None").tag(FolderCredentialMode.none) - Text("Manual").tag(FolderCredentialMode.manual) - Text("Identity").tag(FolderCredentialMode.identity) - if canUseInheritance { Text("Inherit").tag(FolderCredentialMode.inherit) } - } - .labelsHidden() - .pickerStyle(.segmented) - } - - switch credentialMode { - case .manual: - PropertyRow(title: "Username") { - TextField("", text: $manualUsername, prompt: Text("shared_user")) - .textFieldStyle(.plain) - .multilineTextAlignment(.trailing) - } - - PropertyRow(title: "Password") { - SecureField("", text: Binding( - get: { manualPassword }, - set: { manualPassword = $0; manualPasswordDirty = true } - ), prompt: Text("••••••••")) - .textFieldStyle(.plain) - .multilineTextAlignment(.trailing) - } - - if editingFolderUsesManual && !manualPasswordDirty { - Text("Existing password will be kept unless changed.") - .font(TypographyTokens.formDescription) - .foregroundStyle(ColorTokens.Text.secondary) - .listRowSeparator(.hidden) - } - case .identity: - identitySelectionContent - case .inherit: - if let identity = inheritedIdentity { - Text("Inherits identity \"\(identity.name)\" from parent folder.") - .foregroundStyle(ColorTokens.Text.secondary) - .font(TypographyTokens.formDescription) - .listRowSeparator(.hidden) - } else { - Text("Parent folder does not provide credentials.") - .foregroundStyle(ColorTokens.Status.error) - .font(TypographyTokens.formDescription) - .listRowSeparator(.hidden) - } - case .none: - EmptyView() - } - } - } - - private var identitySelectionContent: some View { - Group { - if availableIdentities.isEmpty { - PropertyRow(title: "Identity") { - VStack(alignment: .trailing, spacing: SpacingTokens.xs) { - Text("No identities available.") - .foregroundStyle(ColorTokens.Text.secondary) - .font(TypographyTokens.formDescription) - Button("Create Identity") { - identityEditorState = .create(parent: nil, token: UUID()) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } else { - PropertyRow(title: "Identity") { - HStack(spacing: SpacingTokens.xs) { - Picker("", selection: $selectedIdentityID) { - Text("Select").tag(UUID?.none) - ForEach(availableIdentities, id: \.id) { - Text($0.name).tag(UUID?.some($0.id)) - } - } - .labelsHidden() - .pickerStyle(.menu) - - Button { - identityEditorState = .create(parent: nil, token: UUID()) - } label: { - Image(systemName: "plus") - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityLabel("Add identity") - } - } - } - } - } - - // MARK: - Logic - - private func handleCredentialModeChange(_ newMode: FolderCredentialMode) { - if newMode == .manual { - manualUsername = editingFolderUsesManual ? (editingFolder?.manualUsername ?? "") : "" - manualPassword = "" - manualPasswordDirty = false - } else if newMode == .identity && selectedIdentityID == nil { - selectedIdentityID = availableIdentities.first?.id - } else { - manualUsername = "" - manualPassword = "" - manualPasswordDirty = false - } - } - - private func prepareInitialValues() { - if case .edit(let folder) = state { - name = folder.name - folderDescription = folder.folderDescription ?? "" - selectedColorHex = folder.colorHex - selectedIcon = folder.icon - selectedKind = folder.kind - selectedParentID = folder.parentFolderID - credentialMode = folder.credentialMode - selectedIdentityID = folder.identityID - manualUsername = folder.manualUsername ?? "" - manualPassword = "" - manualPasswordDirty = false - } else if case .create(let kind, let parent, _) = state { - name = "" - folderDescription = "" - selectedColorHex = FolderIdentityPalette.defaults.first ?? "5A9CDE" - selectedIcon = SavedFolder.defaultIcon - selectedKind = kind - selectedParentID = parent?.id - credentialMode = .none - selectedIdentityID = nil - if let parent { - selectedColorHex = parent.colorHex - if parent.credentialMode == .inherit { credentialMode = .inherit } - } - manualUsername = "" - manualPassword = "" - manualPasswordDirty = false - } - } - - private func saveFolder() async { - var folder: SavedFolder - switch state { - case .create: - folder = SavedFolder(name: name) - folder.id = UUID() - folder.projectID = projectStore.selectedProject?.id - case .edit(let existing): - folder = existing - folder.name = name - } - - let trimmedDescription = folderDescription.trimmingCharacters(in: .whitespacesAndNewlines) - folder.folderDescription = trimmedDescription.isEmpty ? nil : trimmedDescription - folder.icon = selectedIcon - folder.colorHex = selectedColorHex - folder.kind = selectedKind - folder.parentFolderID = selectedParentID - folder.credentialMode = isIdentityFolder ? .none : credentialMode - folder.identityID = credentialMode == .identity && !isIdentityFolder ? selectedIdentityID : nil - folder.manualUsername = credentialMode == .manual && !isIdentityFolder ? manualUsername.trimmingCharacters(in: .whitespacesAndNewlines) : nil - - let pw = (credentialMode == .manual && !isIdentityFolder && manualPasswordDirty) ? manualPassword.trimmingCharacters(in: .whitespacesAndNewlines) : nil - if let pw { try? environmentState.identityRepository.setPassword(pw, for: &folder) } - - let isNew = !isEditing - try? await connectionStore.updateFolder(folder) - if isNew && folder.kind == .connections { connectionStore.selectedFolderID = folder.id } - dismiss() - } } diff --git a/EchoTests/Components/MSSQLDataTypePickerTests.swift b/EchoTests/Components/MSSQLDataTypePickerTests.swift new file mode 100644 index 000000000..5c9a2c1d6 --- /dev/null +++ b/EchoTests/Components/MSSQLDataTypePickerTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import Echo + +@Suite("MSSQLDataTypePicker") +struct MSSQLDataTypePickerTests { + + @Test @MainActor func preservesBareUnicodeTypeWithoutInjectingDefaultLength() { + let state = MSSQLDataTypePicker.selectionState(for: "nvarchar") + + #expect(state.baseType == "nvarchar") + #expect(state.sizeParam.isEmpty) + #expect(state.isCustom == false) + } + + @Test @MainActor func preservesExplicitLengthForParameterizedType() { + let state = MSSQLDataTypePicker.selectionState(for: "nvarchar(4000)") + + #expect(state.baseType == "nvarchar") + #expect(state.sizeParam == "4000") + #expect(state.isCustom == false) + } +} diff --git a/EchoTests/Models/GlobalSettingsExtendedTests.swift b/EchoTests/Models/GlobalSettingsExtendedTests.swift index 1e765d26f..a29109e21 100644 --- a/EchoTests/Models/GlobalSettingsExtendedTests.swift +++ b/EchoTests/Models/GlobalSettingsExtendedTests.swift @@ -36,28 +36,6 @@ struct GlobalSettingsExtendedTests { } } - // MARK: - NativePsqlRuntimePreference - - @Test func nativePsqlRuntimePreferenceAllCases() { - let cases = NativePsqlRuntimePreference.allCases - #expect(cases.count == 2) - #expect(cases.contains(.bundled)) - #expect(cases.contains(.system)) - } - - @Test func nativePsqlRuntimePreferenceDisplayNames() { - #expect(NativePsqlRuntimePreference.bundled.displayName == "Bundled Binary") - #expect(NativePsqlRuntimePreference.system.displayName == "System Binary") - } - - @Test func nativePsqlRuntimePreferenceCodableRoundTrip() throws { - for pref in NativePsqlRuntimePreference.allCases { - let data = try JSONEncoder().encode(pref) - let decoded = try JSONDecoder().decode(NativePsqlRuntimePreference.self, from: data) - #expect(decoded == pref) - } - } - // MARK: - SidebarAutoExpandSection: displayName @Test func sidebarAutoExpandSectionDisplayNames() { @@ -213,6 +191,57 @@ struct GlobalSettingsExtendedTests { #expect(overrides.textHex == nil) } + @Test func globalSettingsObjectBrowserCacheDefault() { + let settings = GlobalSettings() + #expect(settings.objectBrowserCacheMaxBytes == 512 * 1_024 * 1_024) + } + + @Test func globalSettingsObjectBrowserCacheCodableRoundTrip() throws { + var settings = GlobalSettings() + settings.objectBrowserCacheMaxBytes = 256 * 1_024 * 1_024 + + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + + #expect(decoded.objectBrowserCacheMaxBytes == 256 * 1_024 * 1_024) + } + + // MARK: - NotificationPreferences + + @Test func notificationGroupRemainsEnabledWhenAnyCategoryIsEnabled() { + var preferences = NotificationPreferences() + preferences.enableAll() + preferences.setEnabled(false, for: .connectionConnected) + + #expect(preferences.isGroupEnabled(.connection)) + #expect(!preferences.isEnabled(.connectionConnected)) + #expect(preferences.isEnabled(.connectionDisconnected)) + #expect(preferences.isEnabled(.connectionFailed)) + } + + @Test func notificationGroupToggleDisablesAllCategoriesInGroup() { + var preferences = NotificationPreferences() + preferences.enableAll() + preferences.setGroupEnabled(false, for: .connection) + + #expect(!preferences.isGroupEnabled(.connection)) + #expect(!preferences.isEnabled(.connectionConnected)) + #expect(!preferences.isEnabled(.connectionDisconnected)) + #expect(!preferences.isEnabled(.connectionFailed)) + #expect(preferences.isEnabled(.generalSuccess)) + } + + @Test func notificationExplicitPreferencesPreserveSingleCategoryChanges() { + var preferences = NotificationPreferences() + preferences.markExplicitPreferences() + preferences.setEnabled(true, for: .generalInfo) + preferences.setEnabled(false, for: .generalSuccess) + + #expect(preferences.isGroupEnabled(.general)) + #expect(preferences.isEnabled(.generalInfo)) + #expect(!preferences.isEnabled(.generalSuccess)) + } + // MARK: - GlobalSettings: Codable round-trip @Test func globalSettingsCodableRoundTripPreservesAllFields() throws { @@ -240,11 +269,6 @@ struct GlobalSettingsExtendedTests { settings.sidebarAutoExpandSections = [.databases, .tables] settings.sidebarAutoExpandPostgresql = [.databases, .materializedViews] settings.sidebarAutoExpandSQLServer = [.databases, .procedures] - settings.nativePsqlEnabled = true - settings.nativePsqlRuntimePreference = .system - settings.nativePsqlAllowSystemBinaryFallback = true - settings.nativePsqlAllowShellEscape = false - settings.nativePsqlAllowFileCommands = false settings.sidebarIconColorMode = .monochrome settings.managedPostgresConsoleEnabled = false @@ -274,11 +298,6 @@ struct GlobalSettingsExtendedTests { #expect(decoded.sidebarAutoExpandSections == [.databases, .tables]) #expect(decoded.sidebarAutoExpandPostgresql == [.databases, .materializedViews]) #expect(decoded.sidebarAutoExpandSQLServer == [.databases, .procedures]) - #expect(decoded.nativePsqlEnabled == true) - #expect(decoded.nativePsqlRuntimePreference == .system) - #expect(decoded.nativePsqlAllowSystemBinaryFallback == true) - #expect(decoded.nativePsqlAllowShellEscape == false) - #expect(decoded.nativePsqlAllowFileCommands == false) #expect(decoded.sidebarIconColorMode == .monochrome) #expect(decoded.managedPostgresConsoleEnabled == false) } diff --git a/EchoTests/Models/TableStructureEditorModelsTests.swift b/EchoTests/Models/TableStructureEditorModelsTests.swift index e99e04f76..794e7eaef 100644 --- a/EchoTests/Models/TableStructureEditorModelsTests.swift +++ b/EchoTests/Models/TableStructureEditorModelsTests.swift @@ -208,6 +208,12 @@ struct TableStructureEditorModelsTests { #expect(idx.isDirty == false) } + @Test func indexModelUnchangedIsNotDirtyForMSSQLDefaultType() { + let snapshot = IndexModel.Snapshot(name: "idx_test", columns: [], isUnique: false, filterCondition: nil, indexType: "nonclustered") + let idx = IndexModel(original: snapshot, name: "idx_test", columns: [], isUnique: false, filterCondition: "", indexType: "nonclustered") + #expect(idx.isDirty == false) + } + @Test func indexModelNameChangeIsDirty() { let snapshot = IndexModel.Snapshot(name: "idx_old", columns: [], isUnique: false, filterCondition: nil) let idx = IndexModel(original: snapshot, name: "idx_new", columns: [], isUnique: false, filterCondition: "") diff --git a/EchoTests/Services/AppleSignInCoordinatorTests.swift b/EchoTests/Services/AppleSignInCoordinatorTests.swift new file mode 100644 index 000000000..fe08d89ed --- /dev/null +++ b/EchoTests/Services/AppleSignInCoordinatorTests.swift @@ -0,0 +1,31 @@ +import AuthenticationServices +import Foundation +import Testing +@testable import Echo + +@Suite("AppleSignInCoordinator") +struct AppleSignInCoordinatorTests { + + @Test func mapAuthErrorReturnsCancelledForCanceledAuthorization() { + let error = ASAuthorizationError(.canceled) + + let mapped = AppleSignInCoordinator.mapAuthError(error) + + #expect(mapped == .cancelled) + } + + @Test func mapAuthErrorReturnsUnknownForOtherErrors() { + let error = NSError(domain: "AppleSignInCoordinatorTests", code: 42, userInfo: [ + NSLocalizedDescriptionKey: "Unexpected failure" + ]) + + let mapped = AppleSignInCoordinator.mapAuthError(error) + + switch mapped { + case .unknown(let message): + #expect(message == "Unexpected failure") + default: + Issue.record("Expected unknown auth error, got \(mapped)") + } + } +} diff --git a/EchoTests/Services/ObjectBrowserCacheStoreTests.swift b/EchoTests/Services/ObjectBrowserCacheStoreTests.swift new file mode 100644 index 000000000..5ae758bab --- /dev/null +++ b/EchoTests/Services/ObjectBrowserCacheStoreTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +@testable import Echo + +@Suite("Object Browser Cache Store") +struct ObjectBrowserCacheStoreTests { + @Test func ignoresEntryWhenConnectionFingerprintChanges() async throws { + let store = ObjectBrowserCacheStore(configuration: .init(rootDirectory: try makeTempDirectory())) + let structure = TestFixtures.databaseStructure(databaseCount: 1, schemasPerDatabase: 1, tablesPerSchema: 1) + let original = SavedConnection( + id: UUID(), + connectionName: "Demo", + host: "db.local", + port: 5432, + database: "analytics", + username: "echo", + databaseType: .postgresql + ) + + try await store.stashStructure(structure, for: original, limitBytes: 512 * 1_024 * 1_024) + + var changed = original + changed.port = 5433 + + let entry = await store.entry(for: changed) + #expect(entry == nil) + } + + @Test func migratesLegacyInlineCacheWhenStoreEntryIsMissing() async throws { + let store = ObjectBrowserCacheStore(configuration: .init(rootDirectory: try makeTempDirectory())) + let structure = TestFixtures.databaseStructure(databaseCount: 1, schemasPerDatabase: 1, tablesPerSchema: 2) + let connection = SavedConnection( + id: UUID(), + connectionName: "Legacy", + host: "legacy.local", + port: 5432, + database: "legacydb", + username: "echo", + databaseType: .postgresql, + cachedStructure: structure, + cachedStructureUpdatedAt: Date(timeIntervalSince1970: 1_000) + ) + + await store.migrateLegacyCacheIfNeeded(from: connection, limitBytes: 512 * 1_024 * 1_024) + + let entry = await store.entry(for: connection) + #expect(entry?.structure == structure) + #expect(entry?.updatedAt == connection.cachedStructureUpdatedAt) + } + + @Test func prunesOldestEntriesFirstWhenOverLimit() async throws { + let directory = try makeTempDirectory() + let store = ObjectBrowserCacheStore(configuration: .init(rootDirectory: directory)) + let oldConnection = SavedConnection( + id: UUID(), + connectionName: "Old", + host: "old.local", + port: 5432, + database: "old", + username: "echo" + ) + let newConnection = SavedConnection( + id: UUID(), + connectionName: "New", + host: "new.local", + port: 5432, + database: "new", + username: "echo" + ) + + let oldEntry = ObjectBrowserCacheEntry( + key: ObjectBrowserCacheKey(connectionID: oldConnection.id), + connectionFingerprint: oldConnection.objectBrowserCacheFingerprint, + updatedAt: Date(timeIntervalSince1970: 1_000), + structure: TestFixtures.databaseStructure(databaseCount: 3, schemasPerDatabase: 2, tablesPerSchema: 10) + ) + let newEntry = ObjectBrowserCacheEntry( + key: ObjectBrowserCacheKey(connectionID: newConnection.id), + connectionFingerprint: newConnection.objectBrowserCacheFingerprint, + updatedAt: Date(timeIntervalSince1970: 2_000), + structure: TestFixtures.databaseStructure(databaseCount: 3, schemasPerDatabase: 2, tablesPerSchema: 10) + ) + + let encoder = JSONEncoder() + let oldData = try encoder.encode(oldEntry) + let newData = try encoder.encode(newEntry) + try oldData.write(to: directory.appendingPathComponent("\(oldConnection.id.uuidString).json")) + try newData.write(to: directory.appendingPathComponent("\(newConnection.id.uuidString).json")) + + await store.pruneToLimit(oldData.count + 1) + + #expect(await store.entry(for: oldConnection) == nil) + #expect(await store.entry(for: newConnection) != nil) + } + + private func makeTempDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ObjectBrowserCacheStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} diff --git a/EchoTests/Services/QueryStatementClassifierTests.swift b/EchoTests/Services/QueryStatementClassifierTests.swift new file mode 100644 index 000000000..680445891 --- /dev/null +++ b/EchoTests/Services/QueryStatementClassifierTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import Echo + +struct QueryStatementClassifierTests { + @Test + func alterTableIsMessageOnly() { + #expect( + QueryStatementClassifier.isLikelyMessageOnlyStatement( + "ALTER TABLE dbo.people ADD nickname text NULL;", + databaseType: .microsoftSQL + ) + ) + } + + @Test + func selectRemainsResultSetStatement() { + #expect( + !QueryStatementClassifier.isLikelyMessageOnlyStatement( + "SELECT * FROM dbo.people;", + databaseType: .microsoftSQL + ) + ) + } + + @Test + func returningOverridesDmlClassification() { + #expect( + !QueryStatementClassifier.isLikelyMessageOnlyStatement( + "INSERT INTO people(name) VALUES ('Ana') RETURNING id;", + databaseType: .postgresql + ) + ) + } +} diff --git a/EchoTests/Services/SyncStartupDecisionTests.swift b/EchoTests/Services/SyncStartupDecisionTests.swift new file mode 100644 index 000000000..341b5e0f4 --- /dev/null +++ b/EchoTests/Services/SyncStartupDecisionTests.swift @@ -0,0 +1,54 @@ +import Testing +@testable import Echo + +@Suite("SyncStartupDecision") +struct SyncStartupDecisionTests { + + @Test func checkpointSuppressesStartupAction() { + let summary = SyncDataSummary( + localConnections: 2, + localIdentities: 1, + localFolders: 0, + localBookmarks: 0, + cloudDocuments: 4 + ) + + #expect(summary.startupAction(hasCheckpoint: true) == .none) + } + + @Test func bothSidesWithNoCheckpointRequiresMergePrompt() { + let summary = SyncDataSummary( + localConnections: 1, + localIdentities: 0, + localFolders: 0, + localBookmarks: 0, + cloudDocuments: 3 + ) + + #expect(summary.startupAction(hasCheckpoint: false) == .promptForMerge) + } + + @Test func cloudOnlyWithNoCheckpointPullsCloud() { + let summary = SyncDataSummary( + localConnections: 0, + localIdentities: 0, + localFolders: 0, + localBookmarks: 0, + cloudDocuments: 2 + ) + + #expect(summary.startupAction(hasCheckpoint: false) == .pullCloud) + } + + @Test func localOnlyWithNoCheckpointUploadsLocal() { + let summary = SyncDataSummary( + localConnections: 0, + localIdentities: 1, + localFolders: 1, + localBookmarks: 0, + cloudDocuments: 0 + ) + + #expect(summary.startupAction(hasCheckpoint: false) == .uploadLocal) + } +} diff --git a/EchoTests/Stores/ConnectionSessionTests.swift b/EchoTests/Stores/ConnectionSessionTests.swift index 49c863f9b..f42910bdc 100644 --- a/EchoTests/Stores/ConnectionSessionTests.swift +++ b/EchoTests/Stores/ConnectionSessionTests.swift @@ -187,4 +187,38 @@ final class ConnectionSessionTests: XCTestCase { let tab = cs.addQueryTab(withQuery: "SELECT 1") XCTAssertNotNil(tab) } + + func testHydrateMetadataFreshnessFromCacheStructureMarksCachedAndListOnly() async { + let cs = makeConnectionSession() + cs.databaseStructure = DatabaseStructure( + serverVersion: "16.0", + databases: [ + DatabaseInfo( + name: "loaded", + schemas: [SchemaInfo(name: "public", objects: [TestFixtures.schemaObjectInfo(name: "users")])] + ), + DatabaseInfo(name: "list_only", schemas: []) + ] + ) + + cs.hydrateMetadataFreshnessFromCacheStructure() + + XCTAssertEqual(cs.metadataFreshness(forDatabase: "loaded"), .cached) + XCTAssertEqual(cs.metadataFreshness(forDatabase: "list_only"), .listOnly) + } + + func testClearMetadataCacheStateResetsStructureAndFreshness() async { + let cs = makeConnectionSession() + cs.databaseStructure = TestFixtures.databaseStructure() + cs.hydrateMetadataFreshnessFromCacheStructure() + cs.markMetadataRefreshStarted(forDatabase: "db_0") + _ = cs.beginSchemaLoad(forDatabase: "db_0") + + cs.clearMetadataCacheState() + + XCTAssertNil(cs.databaseStructure) + XCTAssertEqual(cs.metadataFreshness(forDatabase: "db_0"), .listOnly) + XCTAssertTrue(cs.schemaLoadsInFlight.isEmpty) + XCTAssertEqual(cs.structureLoadingState, .idle) + } } diff --git a/README.md b/README.md index e96b38c54..a6b189af5 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,105 @@ -# Echo +
+ Echo App Icon +

Echo

+

The Definitive Database Client for macOS 26 Tahoe

+ + [![Website](https://img.shields.io/badge/website-echodb.dev-blue?style=for-the-badge)](https://echodb.dev) + [![macOS](https://img.shields.io/badge/macOS-26%2B-black?style=for-the-badge&logo=apple)](https://developer.apple.com/macos/) + [![Swift](https://img.shields.io/badge/Swift-6.2-orange?style=for-the-badge&logo=swift)](https://swift.org) + +

+ Features • + Engine • + Databases • + Installation +

+
-
+--- -**Echo** is a fast, lightweight, and truly native macOS database management client. Built from the ground up with SwiftUI and AppKit, it provides a seamless, Mac-first experience without the overhead of Electron or Java-based wrappers. +**Echo** is a high-performance, strictly native database management suite built exclusively for the modern macOS era. Eschewing the resource-heavy paradigms of Electron and Java, Echo leverages **Swift 6.2** and the **macOS 26 Tahoe** design language to deliver a tool that feels like a first-class citizen on your Mac. -Connect to your favorite relational databases, execute complex queries, and manage your data with speed and elegance. +Designed for engineers who demand precision, Echo combines a minimalist aesthetic with deep, dialect-specific functionality for PostgreSQL, Microsoft SQL Server, and more. --- -## ✨ Features +## ✨ Key Features -- **Truly Native:** Built with Swift and optimized for Apple Silicon and macOS 15+. Fast, responsive, and integrates deeply with native macOS features. -- **Multi-Database Support:** - - 🐘 PostgreSQL - - 🐬 MySQL - - 🪶 SQLite - - 🏢 Microsoft SQL Server (MSSQL) -- **Advanced SQL Editor:** Syntax highlighting, smart autocomplete, and a responsive query editing experience. -- **High-Performance Results Table:** Stream and navigate massive datasets instantly with native, hardware-accelerated grid rendering. -- **Intuitive Object Browser:** Seamlessly explore schemas, tables, views, and routines. -- **Security & Role Management:** Built-in tools for managing database logins, roles, parameters, and security labels. -- **Dark Mode Support:** Beautifully adapts to your system theme. +### 🧠 EchoSense™ Intelligence +Stop fighting with syntax. EchoSense is our context-aware SQL autocomplete engine that understands your schema, foreign keys, and dialect-specific quirks. It doesn't just suggest keywords; it predicts your intent. +- **Dialect-Aware:** Precise suggestions for T-SQL, PL/pgSQL, and SQLite. +- **Schema Navigation:** Autocomplete tables, columns, and joins based on live metadata. ---- +### ⚙️ Activity Engine +Long-running operations shouldn't be a black box. Our centralized **Activity Engine** tracks backups, restores, and maintenance tasks directly in the native toolbar. +- **Real-time Progress:** Visual feedback for every background task. +- **Detailed History:** Audit exactly what happened and when. -## 🚀 Installation +### 🛠️ Professional Maintenance Suite +Go beyond simple queries. Echo provides deep integration for database administration: +- **MSSQL Power Tools:** Rebuild indexes, check integrity, and manage agent jobs with native UI. +- **Postgres Management:** Vacuum, reindex, and security label management. +- **Schema Browser:** A lightning-fast, hierarchical view of your entire database structure. -### Via Homebrew (Recommended) -The easiest way to install and keep Echo updated is via Homebrew. Using the `--no-quarantine` flag is recommended to bypass macOS Gatekeeper (as the open-source releases are currently ad-hoc signed): +### 📊 Streaming Query Workspace +Experience zero-lag result sets. Echo streams data directly from the wire to a hardware-accelerated grid. +- **Execution Plans:** Visualize how your queries run to find bottlenecks. +- **Native Rendering:** Handles millions of rows with minimal memory footprint. -```bash -brew install --cask --no-quarantine tashda/tap/echo -``` +--- + +## 🏗️ The Engine: Custom-Built Native Drivers + +Unlike other database clients that rely on generic, multi-platform libraries, Echo is powered by a suite of **first-party, custom-built database drivers**. We maintain the entire stack to ensure absolute performance, memory efficiency, and seamless integration with Swift's modern concurrency model. -### Manual Download -You can download the latest compiled `.zip` from the [Releases](https://github.com/tashda/Echo/releases) page. +- **[postgres-wire](https://github.com/tashda/postgres-wire):** A high-performance, pure Swift implementation of the PostgreSQL wire protocol. No C-dependencies, just raw speed. +- **[sqlserver-nio](https://github.com/tashda/sqlserver-nio):** Built on SwiftNIO, this driver provides an asynchronous, non-blocking bridge to Microsoft SQL Server, supporting advanced T-SQL features. +- **[mysql-wire](https://github.com/tashda/mysql-wire):** Our native MySQL implementation, currently in active development to bring the same performance standards to the MySQL ecosystem. -*Note: If you install manually without Homebrew, you may need to **Right-Click** the app and select **Open** the first time you launch it to bypass the macOS "unverified developer" warning.* +### What this means for you: +- **Zero Overhead:** No translation layers between the UI and the database socket. +- **Data-Race Safety:** Built from the ground up for **Swift 6.2**, ensuring compile-time safety for your data. +- **Native Efficiency:** Minimal memory footprint even when streaming millions of rows. --- -## 🔄 Auto-Updates -Echo uses the [Sparkle](https://sparkle-project.org) framework for secure automatic updates via EdDSA (Ed25519) cryptographic signatures. +## 🗄️ Supported Databases -You can manually check for updates at any time via the menu bar: **Echo > Check for Updates...** or simply wait for the app to notify you when a new version is released. +| Database | Status | Features | +| :--- | :--- | :--- | +| **PostgreSQL** | 🟢 Stable | Streaming, Metadata, Security, Maintenance | +| **Microsoft SQL Server** | 🟢 Stable | T-SQL, Agent Jobs, Maintenance, Indexing | +| **SQLite** | 🟢 Stable | Local browser, Full Schema Support | +| **MySQL** | 🟡 Beta | Query Execution, Table Exploration | --- -## 🛠️ Developer Setup & CI/CD +## 🚀 Installation -Interested in building Echo from source or contributing? Echo uses the Swift Package Manager (SPM) and is built strictly for modern macOS architectures. +Echo is distributed as a standalone, signed macOS application. -1. Clone the repository. -2. Open `Echo.xcodeproj` in **Xcode 16+**. -3. Let SPM resolve the required dependencies. -4. Select the `Echo` scheme and hit `Cmd + R` to build and run. +1. **Download:** Grab the latest release from the [GitHub Releases](https://github.com/tashda/Echo/releases) page or visit [echodb.dev](https://echodb.dev). +2. **Move to Applications:** Drag `Echo.app` into your `/Applications` folder. +3. **Auto-Updates:** Echo includes a built-in update mechanism powered by **Sparkle**. You will be notified automatically when a new version is available. -### Automated Releases -This repository is configured with a fully automated GitHub Actions pipeline (`.github/workflows/build-release.yml`). +--- -- **Trigger:** Any push or pull-request merge to the `main` branch. -- **Process:** The workflow builds the app (Release configuration), packages it into a ZIP, signs the update with Sparkle keys, and publishes a new GitHub Release. -- **Appcast:** The Sparkle update feed (`appcast.xml`) is automatically generated and hosted alongside each release. +## 🛠️ Developer Setup -*(Note for maintainers: To run the pipeline, ensure `SPARKLE_PRIVATE_KEY` is set in the repository's Actions Secrets).* +```bash +# Clone the repository +git clone https://github.com/tashda/Echo.git ---- +# Open in Xcode 26+ +open Echo.xcodeproj -## 📝 License +# Build and Run +# Ensure the 'Echo' scheme is selected (Cmd + R) +``` + +--- -*(License information to be added)* +
+

Built with ❤️ by the Echo Team.

+

echodb.dev

+
diff --git a/proxmox_mcp.log b/proxmox_mcp.log index 4c218637b..82cbadd02 100644 --- a/proxmox_mcp.log +++ b/proxmox_mcp.log @@ -142,3 +142,9 @@ 2026-04-02 20:59:30,485 - mcp.server.lowlevel.server - INFO - Processing request of type ListToolsRequest 2026-04-02 20:59:30,485 - mcp.server.lowlevel.server - INFO - Processing request of type ListPromptsRequest 2026-04-02 20:59:30,486 - mcp.server.lowlevel.server - INFO - Processing request of type ListResourcesRequest +2026-04-04 23:05:48,386 - proxmox-mcp.proxmox - INFO - Connecting to Proxmox host: 192.168.1.150 +2026-04-04 23:05:48,633 - proxmox-mcp.proxmox - INFO - Successfully connected to Proxmox API +2026-04-04 23:05:48,641 - proxmox-mcp - INFO - Starting MCP server... +2026-04-04 23:05:48,649 - mcp.server.lowlevel.server - INFO - Processing request of type ListToolsRequest +2026-04-04 23:05:48,650 - mcp.server.lowlevel.server - INFO - Processing request of type ListPromptsRequest +2026-04-04 23:05:48,650 - mcp.server.lowlevel.server - INFO - Processing request of type ListResourcesRequest