diff --git a/CHANGELOG.md b/CHANGELOG.md index bb065eca3a..cad124b707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Counts reading "3 table to export" in the Export dialog, and "1 rows" in the result status bar and query plan. - Safe Mode's Touch ID prompt reading "TablePro is trying to Authenticate to execute database operations." - VoiceOver reading no name for the AI provider pop-up, max output tokens, plugin category filter and CSV NULL text field. +- Server Dashboard's metrics staying on a spinner, and slow queries never refreshing. +- Users & Roles privilege checkboxes, Review & Apply and "Modified" not updating after a click. +- Count Exactly on a Redis database leaving the estimate in place. +- Status bar truncating the row count and Count Exactly instead of dropping to a narrower layout. +- Query editor bar cut off, with the editor's text over it, when the editor pane is at its smallest. +- Long column names drawn over their type in the ER diagram. ## [0.77.1] - 2026-10-03 diff --git a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift index 9b103fb34d..02d091d817 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift @@ -186,6 +186,32 @@ extension RedisCommandChannel { let page = RedisScanReply.parse(reply) return RedisKeyspacePage(cursor: page.cursor, keys: page.keys, isIncomplete: false) } + + /// The keys a scan with this glob and type scope visits, walked to the end and each counted + /// once: SCAN may return a key twice while the keyspace rehashes, so the pages are + /// deduplicated rather than tallied. A cluster that lost or replaced a node mid-walk cannot + /// say it visited every key, so that walk throws rather than report a total as exact. + func countKeys(pattern: String?, type: String?) async throws -> Int { + var seen = Set() + var cursor = RedisClusterCursor.start + + repeat { + try Task.checkCancellation() + let page = try await scanKeyspace( + cursor: cursor, pattern: pattern, type: type, count: 1_000, scope: .outsideBlock + ) + guard !page.isIncomplete else { + throw RedisPluginError( + code: 0, + message: String(localized: "The cluster changed while its keys were being counted. Count again.") + ) + } + cursor = page.cursor + seen.formUnion(page.keys) + } while cursor != RedisClusterCursor.start + + return seen.count + } } enum RedisScanReply { diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index fb93340a9c..f3ad14be05 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -300,6 +300,33 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return try await conn.keyCount(inDatabase: index) } + /// What `Count Exactly` runs on a database tab. Without it the kit's default answered nil, which + /// the app reads as "no count", so the button stayed and the estimate was never replaced. + /// + /// An unfiltered tab is counted by the same reading the estimate came from: `DBSIZE` and + /// `INFO keyspace` both report the exact number of keys a database holds. A filtered tab is + /// counted by scanning with its browse's own `MATCH` glob and `TYPE` scope to the end, without + /// the cap the browse stops at, because nothing short of the whole scan is exact. + func fetchExactRowCount( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String + ) async throws -> Int? { + guard let conn = redisConnection else { + throw RedisPluginError.notConnected + } + guard let index = RedisDatabaseIndex.parse(table) else { return nil } + + let scope = RedisQueryBuilder().browseScope(filters: filters) + guard scope.pattern != nil || scope.typeScope != nil else { + return try await conn.keyCount(inDatabase: index) + } + return try await conn.withDatabase(index) { + try await conn.countKeys(pattern: scope.pattern, type: scope.typeScope) + } + } + func fetchTableDDL(table: String, schema: String?) async throws -> String { guard let conn = redisConnection else { throw RedisPluginError.notConnected diff --git a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift index bd821c1aa8..301bb3d150 100644 --- a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift +++ b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift @@ -38,18 +38,27 @@ struct RedisQueryBuilder { limit: Int = 200, offset: Int = 0 ) -> String { - let pattern = extractBrowsePattern(from: filters, namespace: namespace) - let typeScope = extractTypeScope(from: filters) + let scope = browseScope(filters: filters, namespace: namespace) - guard pattern != nil || typeScope != nil else { + guard scope.pattern != nil || scope.typeScope != nil else { return buildBaseQuery(namespace: namespace, database: database, limit: limit, offset: offset) } return buildKeyBrowseQuery( - pattern: pattern, typeScope: typeScope, database: database, limit: limit, offset: offset + pattern: scope.pattern, typeScope: scope.typeScope, database: database, limit: limit, offset: offset ) } + /// The SCAN `MATCH` glob and `TYPE` scope a filtered browse lists keys by, both nil when the + /// filters narrow nothing. The exact row count reads the same pair, so it counts the keys the + /// grid would list rather than a reading of the filters of its own. + func browseScope( + filters: [(column: String, op: String, value: String)], + namespace: String = "" + ) -> (pattern: String?, typeScope: String?) { + (extractBrowsePattern(from: filters, namespace: namespace), extractTypeScope(from: filters)) + } + /// Streamed, so the whole database is read and `LIMIT` is never consulted. func buildExportQuery(database: Int?) -> String { guard let database else { return "KEYBROWSE" } diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 5863ef0c07..1cca9f873d 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -171,6 +171,11 @@ final class PaginationCoordinator: ObservableObject { let schemaName = tab.tableContext.schemaName let filters = tab.filterState.hasAppliedFilters ? tab.filterState.appliedFilters : [] let logicMode = tab.filterState.filterLogicMode + /// A browse narrowed by the plugin's own search runs that search instead of the table + /// filters, so the count has to read the same search or it counts the whole table. + let browseFilters = parent.filterCoordinator.usesBrowseSearch && tab.filterState.hasActiveBrowseSearch + ? tab.filterState.browseSearch.pluginQueryFilters + : nil let isNonSQL = PluginManager.shared.editorLanguage(for: parent.connection.type) != .sql let queryColumns = parent.queryColumns(for: tab) let countSQL = isNonSQL ? nil : parent.queryBuilder.buildFilteredCountQuery( @@ -195,6 +200,7 @@ final class PaginationCoordinator: ObservableObject { tableName: tableName, filters: filters, logicMode: logicMode, + browseFilters: browseFilters, countSQL: countSQL ) @@ -243,11 +249,15 @@ final class PaginationCoordinator: ObservableObject { tableName: String, filters: [TableFilter], logicMode: FilterLogicMode, + browseFilters: [PluginQueryFilter]?, countSQL: String? ) async -> Result { do { let count = try await DatabaseManager.shared.withMetadataDriver(scope: scope, workload: .bulk) { driver in - try await ExactRowCounter.count( + if let browseFilters { + return try await driver.fetchExactRowCount(table: tableName, browseFilters: browseFilters) + } + return try await ExactRowCounter.count( on: driver, table: tableName, filters: filters, logicMode: logicMode, countSQL: countSQL ) } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index f79656c556..8bbdb4012c 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -216,6 +216,10 @@ protocol DatabaseDriver: AnyObject, Sendable { /// counts to keep browsing responsive must not apply that cap here. func fetchExactRowCount(table: String, filters: [TableFilter], logicMode: FilterLogicMode) async throws -> Int? + /// The exact count of a browse narrowed by a search the plugin defines, such as Redis's key + /// pattern and type, rather than by table filters. Nil when the driver has no such count. + func fetchExactRowCount(table: String, browseFilters: [PluginQueryFilter]) async throws -> Int? + /// Fetch the DDL (CREATE TABLE statement) for a specific table func fetchTableDDL(table: String) async throws -> String @@ -769,6 +773,7 @@ extension DatabaseDriver { func fetchExactRowCount(table: String, filters: [TableFilter], logicMode: FilterLogicMode) async throws -> Int? { try await fetchFilteredRowCount(table: table, filters: filters, logicMode: logicMode) } + func fetchExactRowCount(table: String, browseFilters: [PluginQueryFilter]) async throws -> Int? { nil } func maintenanceOperations() -> [PluginMaintenanceOperation]? { nil } func maintenanceStatements( diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 4e04feae4d..ceb49442cf 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -599,6 +599,15 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor ) } + func fetchExactRowCount(table: String, browseFilters: [PluginQueryFilter]) async throws -> Int? { + try await pluginDriver.fetchExactRowCount( + table: table, + schema: pluginDriver.currentSchema, + queryFilters: browseFilters, + logicMode: "and" + ) + } + func fetchTableDDL(table: String) async throws -> String { try await fetchTableDDL(table: table, schema: nil) } diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index fccec709b3..2ba8c2bcf4 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -160,14 +160,8 @@ struct TableQueryBuilder { let sortCols = SortColumnResolver.resolvedIndices( for: sortState, displayColumns: columns, targetColumns: targetColumns ) - var tuples: [(column: String, op: String, value: String)] = [] - let trimmedPattern = pattern.trimmingCharacters(in: .whitespaces) - if !trimmedPattern.isEmpty { - tuples.append((column: "Key", op: "MATCH", value: trimmedPattern)) - } - if let typeScope, !typeScope.isEmpty { - tuples.append((column: "Type", op: "=", value: typeScope)) - } + let tuples = BrowseSearchState(pattern: pattern, typeScope: typeScope).pluginQueryFilters + .map { (column: $0.column, op: $0.op, value: $0.value) } if let result = pluginDriver.buildFilteredQuery( table: tableName, schema: schemaName, filters: tuples, logicMode: "and", sortColumns: sortCols, diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index 9fdb51afc2..04b58e9f20 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit enum FilterLogicMode: String, Codable { case and = "AND" @@ -31,6 +32,21 @@ struct BrowseSearchState: Codable, Equatable { var isActive: Bool { !pattern.trimmingCharacters(in: .whitespaces).isEmpty || typeScope != nil } + + /// The search as the filters a plugin's browse reads: a raw `MATCH` glob on the key and a + /// `TYPE` scope. The browse query and `Count Exactly` both build from this, so the count is of + /// the keys the grid lists. + var pluginQueryFilters: [PluginQueryFilter] { + var filters: [PluginQueryFilter] = [] + let trimmedPattern = pattern.trimmingCharacters(in: .whitespaces) + if !trimmedPattern.isEmpty { + filters.append(PluginQueryFilter(column: "Key", op: "MATCH", value: trimmedPattern)) + } + if let typeScope, !typeScope.isEmpty { + filters.append(PluginQueryFilter(column: "Type", op: "=", value: typeScope)) + } + return filters + } } struct PersistedFilterState: Codable, Equatable { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e63ba4e96a..ea141d735b 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -184951,6 +184951,9 @@ }, "etcd cannot filter with %@." : { + }, + "The cluster changed while its keys were being counted. Count again." : { + }, "NULL text" : { "localizations" : { diff --git a/TablePro/ViewModels/UsersRolesViewModel.swift b/TablePro/ViewModels/UsersRolesViewModel.swift index 050d83947e..cfb008608b 100644 --- a/TablePro/ViewModels/UsersRolesViewModel.swift +++ b/TablePro/ViewModels/UsersRolesViewModel.swift @@ -100,10 +100,18 @@ final class UsersRolesViewModel: ObservableObject { @Published var scopeSearchTask: Task? + private var changeManagerForwarding: AnyCancellable? + + /// Every view on this tab observes the view model, not `changeManager`, and reads the staged + /// grants, the change count and each principal's stage through it. A change the manager + /// published reached none of them: a ticked privilege stayed unticked, and Review & Apply and + /// "Modified" stayed stale until the view model happened to publish something of its own. init(connectionId: UUID, databaseType: DatabaseType) { self.connectionId = connectionId self.databaseType = databaseType expansionStore = PrivilegeExpansionStore(connectionId: connectionId) + changeManagerForwarding = changeManager.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } } // MARK: - Derived diff --git a/TablePro/Views/ERDiagram/ERDiagramDrawing.swift b/TablePro/Views/ERDiagram/ERDiagramDrawing.swift index b8cc7265e8..8a7f862eaa 100644 --- a/TablePro/Views/ERDiagram/ERDiagramDrawing.swift +++ b/TablePro/Views/ERDiagram/ERDiagramDrawing.swift @@ -28,16 +28,20 @@ enum ERDiagramTextRenderer { /// The anchor point is the text's visual centre on the vertical axis, which is where /// `GraphicsContext.draw(_:at:anchor:)` put it. The context is flipped, so the baseline sits /// below that centre by half the difference between ascent and descent. + /// + /// Text wider than `maxWidth` is cut at its end with an ellipsis, the way a truncating label is, + /// so two texts given widths that add up to their room can never draw over each other. static func draw( _ text: String, font: NSFont, color: NSColor, at point: CGPoint, anchor: ERDiagramTextAnchor, + maxWidth: CGFloat = .greatestFiniteMagnitude, in context: CGContext ) { - guard !text.isEmpty else { return } - let line = self.line(for: text, font: font, color: color) + guard !text.isEmpty, maxWidth > 0 else { return } + let line = fitted(self.line(for: text, font: font, color: color), to: maxWidth, font: font, color: color) let width = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) let originX: CGFloat @@ -54,6 +58,18 @@ enum ERDiagramTextRenderer { context.restoreGState() } + /// The width `draw` lays `text` out at before any truncation. + static func width(of text: String, font: NSFont) -> CGFloat { + guard !text.isEmpty else { return 0 } + return CGFloat(CTLineGetTypographicBounds(line(for: text, font: font, color: .labelColor), nil, nil, nil)) + } + + private static func fitted(_ line: CTLine, to maxWidth: CGFloat, font: NSFont, color: NSColor) -> CTLine { + guard CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) > maxWidth else { return line } + let ellipsis = self.line(for: "\u{2026}", font: font, color: color) + return CTLineCreateTruncatedLine(line, Double(maxWidth), .end, ellipsis) ?? ellipsis + } + private static func line(for text: String, font: NSFont, color: NSColor) -> CTLine { let key = LineKey(text: text, font: font, color: color) if let cached = lineCache[key] { return cached } diff --git a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift index a9f37ea29f..fc2c5e085a 100644 --- a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift +++ b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift @@ -14,8 +14,10 @@ enum ERDiagramNodeRenderer { private static var badgeXOffset: CGFloat { 14 * ERDiagramLayout.typeScale } private static var columnNameXOffset: CGFloat { 24 * ERDiagramLayout.typeScale } private static var typeRightMargin: CGFloat { 8 * ERDiagramLayout.typeScale } + private static var nameTypeGap: CGFloat { 8 * ERDiagramLayout.typeScale } + /// How much of a type stays readable beside a long name: enough for "varchar" or "timestam". + private static let typeFloorCharacters = 8 private static let maxTableNameChars = 24 - private static let maxTypeChars = 18 private static let cornerRadius: CGFloat = 6 private static var headerPointSize: CGFloat { @@ -110,8 +112,8 @@ enum ERDiagramNodeRenderer { in context: CGContext ) { let rowHeight = ERDiagramLayout.columnRowHeight - let nameFont = NSFont.monospacedSystemFont(ofSize: columnNamePointSize * scale, weight: .regular) - let typeFont = NSFont.monospacedSystemFont(ofSize: columnTypePointSize * scale, weight: .regular) + let nameFont = columnNameFont(scale: scale) + let typeFont = columnTypeFont(scale: scale) for (index, column) in node.displayColumns.enumerated() { let rowY = dividerY + CGFloat(index) * rowHeight + rowHeight / 2 @@ -134,26 +136,70 @@ enum ERDiagramNodeRenderer { ) } + let widths = columnTextWidths(name: column.name, type: column.dataType, nodeWidth: rect.width) ERDiagramTextRenderer.draw( column.name, font: nameFont, color: .labelColor, at: CGPoint(x: rect.minX + columnNameXOffset, y: rowY), anchor: .leading, + maxWidth: widths.name, in: context ) - let displayType = (column.dataType as NSString).length > maxTypeChars - ? String(column.dataType.prefix(maxTypeChars)) + "\u{2026}" - : column.dataType ERDiagramTextRenderer.draw( - displayType, + column.dataType, font: typeFont, color: .secondaryLabelColor, at: CGPoint(x: rect.maxX - typeRightMargin, y: rowY), anchor: .trailing, + maxWidth: widths.type, in: context ) } } + + private static func columnNameFont(scale: CGFloat) -> NSFont { + .monospacedSystemFont(ofSize: columnNamePointSize * scale, weight: .regular) + } + + private static func columnTypeFont(scale: CGFloat) -> NSFont { + .monospacedSystemFont(ofSize: columnTypePointSize * scale, weight: .regular) + } + + /// The widths a column row draws its name and type at, measured in the fonts the row uses. + /// + /// The name is drawn from the leading edge and the type to the trailing edge, so nothing kept + /// the two apart: a long name ran under its type ("shipping_addr" over "character varying(…"). + /// They now share the row. The name comes first, because it is what a reader is looking for, + /// but it leaves the type at least its first few characters; the type then takes what is left. + static func columnTextWidths(name: String, type: String, nodeWidth: CGFloat) -> ERColumnTextWidths { + let scale = ERDiagramLayout.typeScale + let typeFont = columnTypeFont(scale: scale) + let typeFloor = ERDiagramTextRenderer.width( + of: String(repeating: "0", count: typeFloorCharacters), font: typeFont + ) + return ERColumnTextWidths( + room: nodeWidth - columnNameXOffset - typeRightMargin, + gap: nameTypeGap, + name: ERDiagramTextRenderer.width(of: name, font: columnNameFont(scale: scale)), + type: ERDiagramTextRenderer.width(of: type, font: typeFont), + typeFloor: typeFloor + ) + } +} + +/// How one column row shares its room between the name and the type. Each width is the most that +/// text may draw at; neither ever exceeds the text's own width, and the two plus the gap between +/// them never exceed the room. +struct ERColumnTextWidths: Equatable { + let name: CGFloat + let type: CGFloat + + init(room: CGFloat, gap: CGFloat, name: CGFloat, type: CGFloat, typeFloor: CGFloat) { + let separation = type > 0 ? gap : 0 + let nameRoom = max(0, room - separation - min(type, typeFloor)) + self.name = min(name, nameRoom) + self.type = min(type, max(0, room - separation - self.name)) + } } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 575cd63010..a52abc1494 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -50,6 +50,11 @@ struct QueryEditorView: View { @State private var vimMode: VimMode = .normal + /// The editor takes whatever height the bar above it leaves, with no minimum of its own. The + /// query split's editor pane bottoms out at `VerticalCollapsibleSplitView.defaultTopMinimumThickness`, + /// and an editor that also insisted on that much under a bar asked for more than the pane can + /// ever be: at the pane's minimum the stack overflowed it, the bar was cut off at the top and the + /// editor's text ran up over it. var body: some View { VStack(alignment: .leading, spacing: 0) { QueryEditorBar( @@ -105,7 +110,6 @@ struct QueryEditorView: View { onAIAction: onAIAction, onSaveAsFavorite: onSaveAsFavorite ) - .frame(minHeight: 100) .clipped() } .background(Color(nsColor: .textBackgroundColor)) diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index 6c79fdbf46..538ed80e9d 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -153,22 +153,35 @@ struct ResultStatusBar: View { // MARK: - Readout /// The zone that absorbs the bar's slack, which is why `row` gives it the flexible frame and the - /// clusters on either side keep their intrinsic widths. + /// clusters on either side keep their intrinsic widths. Its three parts are the slots + /// `ReadoutZoneLayout` sizes: the sentence, the actions beside it, and the report after them. private var readoutCluster: some View { - HStack(spacing: 6) { - if model.controls.showsReadout { - resultReadout + ReadoutZoneLayout(spacing: Self.readoutSpacing, sentenceIdealWidth: StatusBarLayoutMetrics.readoutIdealWidth) { + HStack(spacing: Self.readoutSpacing) { + if model.controls.showsReadout { + readoutSentence + } + } + HStack(spacing: Self.readoutSpacing) { + if model.controls.showsReadout { + readoutActions + } } - executionReport - if model.controls.showsReadout, isRefreshingSchema { - DelayedProgressIndicator(isActive: true) - .accessibilityLabel(String(localized: "Refreshing")) + HStack(spacing: Self.readoutSpacing) { + if model.controls.showsReadout { + readoutMessage + } + executionReport + if model.controls.showsReadout, isRefreshingSchema { + DelayedProgressIndicator(isActive: true) + .accessibilityLabel(String(localized: "Refreshing")) + } } } } @ViewBuilder - private var resultReadout: some View { + private var readoutSentence: some View { if model.controls.showsLoadingMore { ProgressView() .controlSize(.small) @@ -185,13 +198,21 @@ struct ResultStatusBar: View { .controlSize(.small) .accessibilityLabel(String(localized: "Counting rows")) } + } + /// Drawn at the bar's small control size, as every other control on the bar is, and never + /// truncated: the sentence beside them gives way first, and `ReadoutZoneLayout` counts their + /// width into the tier choice so the bar drops a tier before either has to. + @ViewBuilder + private var readoutActions: some View { if model.controls.showsExactCountAction { Button( String(localized: "Count Exactly"), action: paginationCallbacks.onRequestExactCount ) .accessoryBarActionStyle() + .controlSize(.small) + .fixedSize() .help(String(localized: "Replace the estimate with an exact row count.")) .accessibilityIdentifier("result-status-count-exactly") } @@ -199,16 +220,21 @@ struct ResultStatusBar: View { if model.controls.showsFetchAll, let onFetchAll { Button(String(localized: "Fetch All"), action: onFetchAll) .accessoryBarActionStyle() + .controlSize(.small) + .fixedSize() .help(String(localized: "Load the rows the row cap left behind.")) .accessibilityIdentifier("result-status-fetch-all") } + } + @ViewBuilder + private var readoutMessage: some View { if let statusMessage = model.statusMessage { StatusBarSeparator() - /// Yields its width before the sentence beside it does, so a wordy driver message - /// truncates instead of squeezing out the row count. Which tier the bar draws is not - /// its business: the enclosing frame reports a constant ideal width so no message - /// length can change that choice. + /// Yields its width before the execution report beside it does, so a wordy driver + /// message truncates instead of squeezing anything else out. Which tier the bar draws + /// is not its business: `ReadoutZoneLayout` leaves it out of the ideal width, so no + /// message length can change that choice. Text(statusMessage) .font(.caption) .foregroundStyle(.secondary) @@ -239,15 +265,14 @@ struct ResultStatusBar: View { } } + /// No `idealWidth` here: `ReadoutZoneLayout` reports the zone's ideal itself, and a frame that + /// named one would replace it. private func readoutZone(_ content: some View) -> some View { - content.frame( - minWidth: 0, - idealWidth: StatusBarLayoutMetrics.readoutIdealWidth, - maxWidth: .infinity, - alignment: .leading - ) + content.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } + private static let readoutSpacing: CGFloat = 6 + // MARK: - Controls @ViewBuilder @@ -424,6 +449,71 @@ struct ResultStatusBar: View { } } +/// The readout zone: the sentence, the actions beside it, and the report after them, left to right. +/// +/// It exists for the ideal width it reports, which is the width `ViewThatFits` picks a tier by. The +/// sentence and the report count as one constant, `StatusBarLayoutMetrics.readoutIdealWidth`, so +/// neither a long count nor a wordy driver message drops the bar a tier by itself. The actions add +/// their own width on top. Counted inside the constant, they left a table with an estimate on the +/// regular tier with no room for both, and the bar drew "1-22 of ~22 r…" beside "Count Ex…". +/// +/// Placed, the actions keep their width and the report keeps what it cannot give up. The sentence +/// takes the rest up to its own width, and whatever is left after that goes to the report, whose +/// driver message truncates into it. +private struct ReadoutZoneLayout: Layout { + let spacing: CGFloat + let sentenceIdealWidth: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let sizes = slotSizes(width: proposal.width, height: proposal.height, subviews: subviews) + let height = sizes.map(\.height).max() ?? 0 + guard proposal.width != nil else { + let actions = subviews.count == 3 ? sizes[1].width : 0 + return CGSize(width: sentenceIdealWidth + (actions > 0 ? spacing + actions : 0), height: height) + } + return CGSize(width: span(of: sizes.map(\.width)), height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let sizes = slotSizes(width: bounds.width, height: bounds.height, subviews: subviews) + var x = bounds.minX + for (subview, size) in zip(subviews, sizes) where size.width > 0 { + subview.place( + at: CGPoint(x: x, y: bounds.midY), + anchor: .leading, + proposal: ProposedViewSize(width: size.width, height: bounds.height) + ) + x += size.width + spacing + } + } + + private func slotSizes(width: CGFloat?, height: CGFloat?, subviews: Subviews) -> [CGSize] { + guard let width, subviews.count == 3 else { + return subviews.map { $0.sizeThatFits(ProposedViewSize(width: nil, height: height)) } + } + let sentence = subviews[0], actions = subviews[1], report = subviews[2] + let actionsSize = actions.sizeThatFits(ProposedViewSize(width: nil, height: height)) + let sentenceIdeal = sentence.sizeThatFits(ProposedViewSize(width: nil, height: height)).width + let reportIdeal = report.sizeThatFits(ProposedViewSize(width: nil, height: height)).width + let reportFloor = report.sizeThatFits(ProposedViewSize(width: 0, height: height)).width + let gaps = span(of: [sentenceIdeal, actionsSize.width, reportIdeal]) - sentenceIdeal - actionsSize.width - reportIdeal + + let sentenceWidth = min(sentenceIdeal, max(0, width - gaps - actionsSize.width - reportFloor)) + let reportWidth = max(reportFloor, width - gaps - actionsSize.width - sentenceWidth) + return [ + sentence.sizeThatFits(ProposedViewSize(width: sentenceWidth, height: height)), + actionsSize, + report.sizeThatFits(ProposedViewSize(width: reportWidth, height: height)), + ] + } + + /// The width of slots laid side by side, with one gap between each pair that draws anything. + private func span(of widths: [CGFloat]) -> CGFloat { + let drawn = widths.filter { $0 > 0 } + return drawn.reduce(0, +) + spacing * CGFloat(max(0, drawn.count - 1)) + } +} + private extension View { /// `titleAndIcon` and `iconOnly` are different types, so a tier choosing between them cannot do /// it with a ternary. Branching here keeps each control's modifier chain written once, and keeps diff --git a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift index 2130401b05..628f0d5a50 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift @@ -2,7 +2,11 @@ import AppKit import SwiftUI struct ServerDashboardSplitView: NSViewControllerRepresentable { - let viewModel: ServerDashboardViewModel + /// Observed, not just held. As a plain reference the representable compared equal to itself on + /// every refresh, so SwiftUI never called `updateNSViewController` and the metrics and slow + /// query panes kept the empty values they were built with: Server Metrics never left its + /// spinner. The sessions pane only updated because `SessionsTableView` observes the model itself. + @ObservedObject var viewModel: ServerDashboardViewModel func makeCoordinator() -> Coordinator { Coordinator() diff --git a/TableProTests/Core/Coordinators/BrowseSearchExactCountTests.swift b/TableProTests/Core/Coordinators/BrowseSearchExactCountTests.swift new file mode 100644 index 0000000000..58ac60a2c9 --- /dev/null +++ b/TableProTests/Core/Coordinators/BrowseSearchExactCountTests.swift @@ -0,0 +1,85 @@ +// +// BrowseSearchExactCountTests.swift +// TableProTests +// +// A Redis tab narrowed by its key pattern bar or type picker runs that search instead of the +// table filters. `Count Exactly` has to count the same search, or it reports the whole +// database's key count as the exact total of a narrowed grid. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class BrowseCountStubDriver: PluginDatabaseDriver, @unchecked Sendable { + private let lock = NSLock() + private var received: [[PluginQueryFilter]] = [] + + var receivedFilters: [[PluginQueryFilter]] { + lock.withLock { received } + } + + func fetchExactRowCount( + table: String, + schema: String?, + queryFilters: [PluginQueryFilter], + logicMode: String + ) async throws -> Int? { + lock.withLock { received.append(queryFilters) } + return 3 + } + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func connect() async throws {} + func disconnect() {} + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +struct BrowseSearchExactCountTests { + @Test("The count receives the key pattern and type the browse searched by") + func countReceivesTheBrowseSearch() async throws { + let stub = BrowseCountStubDriver() + let adapter = PluginDriverAdapter(connection: TestFixtures.makeConnection(type: .redis), pluginDriver: stub) + let search = BrowseSearchState(pattern: " session:* ", typeScope: "hash") + + let count = try await adapter.fetchExactRowCount(table: "db0", browseFilters: search.pluginQueryFilters) + + #expect(count == 3) + let received = try #require(stub.receivedFilters.first) + #expect(received.map(\.column) == ["Key", "Type"]) + #expect(received.map(\.op) == ["MATCH", "="]) + #expect(received.map(\.value) == ["session:*", "hash"]) + } + + @Test("The Redis builder reads the search's filters as the scope the browse scans") + func redisReadsTheSearchAsItsScope() { + let filters = BrowseSearchState(pattern: "cart:*", typeScope: "list").pluginQueryFilters + let scope = RedisQueryBuilder().browseScope( + filters: filters.map { (column: $0.column, op: $0.op, value: $0.value) } + ) + #expect(scope.pattern == "cart:*") + #expect(scope.typeScope == "list") + } + + @Test("An inactive search narrows nothing") + func inactiveSearchHasNoFilters() { + #expect(BrowseSearchState().pluginQueryFilters.isEmpty) + #expect(BrowseSearchState(pattern: " ").pluginQueryFilters.isEmpty) + } +} diff --git a/TableProTests/Plugins/RedisExactCountTests.swift b/TableProTests/Plugins/RedisExactCountTests.swift new file mode 100644 index 0000000000..4265e4f97a --- /dev/null +++ b/TableProTests/Plugins/RedisExactCountTests.swift @@ -0,0 +1,102 @@ +// +// RedisExactCountTests.swift +// TableProTests +// +// `Count Exactly` on a Redis database tab used to reach the kit's default, which answered nil, so +// the estimate was never replaced. A filtered tab is now counted by the same MATCH glob and TYPE +// scope its browse lists keys by, scanned to the end. +// + +import Foundation +import TableProPluginKit +import Testing + +/// Answers SCAN with the pages it was given, the way a cluster channel does when a node left the +/// topology mid-walk and it had to restart that node's cursor. +private final class PagedScanChannel: RedisCommandChannel, @unchecked Sendable { + private var pages: [RedisKeyspacePage] + + init(_ pages: [RedisKeyspacePage]) { + self.pages = pages + } + + var isConnected: Bool { true } + func connect(reportingStage report: @escaping ConnectionStageReporter) async throws {} + func disconnect() {} + func cancelCurrentQuery() {} + func serverVersion() -> String? { "8.0.0" } + func currentDatabase() -> Int { 0 } + func reportedDatabaseCount() async throws -> Int? { 16 } + func keyCountsByDatabase() async throws -> [Int: Int]? { nil } + func executeCommand(_ args: [Data], scope: RedisCommandScope) async throws -> RedisReply { .null } + func executePipeline(_ commands: [[Data]], scope: RedisCommandScope) async throws -> [RedisReply] { [] } + func selectDatabase(_ index: Int, scope: RedisCommandScope) async throws {} + + func scanKeyspace( + cursor: String, + pattern: String?, + type: String?, + count: Int, + scope: RedisCommandScope + ) async throws -> RedisKeyspacePage { + pages.removeFirst() + } +} + +struct RedisExactCountTests { + @Test("A walk the cluster could not finish is refused rather than reported as exact") + func incompleteWalkThrows() async { + let channel = PagedScanChannel([ + RedisKeyspacePage(cursor: "node-b:0", keys: ["a", "b"], isIncomplete: false), + RedisKeyspacePage(cursor: RedisClusterCursor.start, keys: ["c"], isIncomplete: true) + ]) + + await #expect(throws: RedisPluginError.self) { + _ = try await channel.countKeys(pattern: nil, type: nil) + } + } + + @Test("The count walks the whole keyspace and counts a key SCAN returned twice once") + func countsEveryPageOnce() async throws { + let channel = StubRedisChannel([ + .array([.string("17"), .array([.string("user:1"), .string("user:2")])]), + .array([.string("0"), .array([.string("user:2"), .string("user:3")])]) + ]) + + let count = try await channel.countKeys(pattern: "user:*", type: "hash") + + #expect(count == 3) + #expect(channel.sentCommands == [ + ["SCAN", "0", "MATCH", "user:*", "COUNT", "1000", "TYPE", "hash"], + ["SCAN", "17", "MATCH", "user:*", "COUNT", "1000", "TYPE", "hash"] + ]) + } + + @Test("The count reads the filters the way the browse does") + func countScopeMatchesBrowse() throws { + let builder = RedisQueryBuilder() + let filters: [(column: String, op: String, value: String)] = [ + (column: "Key", op: "MATCH", value: "session:*"), + (column: "Type", op: "=", value: "STRING") + ] + + let scope = builder.browseScope(filters: filters) + let browse = builder.buildFilteredQuery(namespace: "", database: 2, filters: filters) + guard case .keyBrowse(let pattern, let typeScope, _, _, _) = try RedisCommandParser.parse(browse) else { + Issue.record("Expected a keyBrowse operation for \(browse)") + return + } + + #expect(scope.pattern == pattern) + #expect(scope.typeScope == typeScope) + #expect(scope.pattern == "session:*") + #expect(scope.typeScope == "string") + } + + @Test("A filter the browse cannot narrow by leaves the count unfiltered too") + func unnarrowingFilterCountsTheDatabase() { + let scope = RedisQueryBuilder().browseScope(filters: [(column: "TTL", op: ">", value: "60")]) + #expect(scope.pattern == nil) + #expect(scope.typeScope == nil) + } +} diff --git a/TableProTests/ViewModels/UsersRolesViewModelChangeForwardingTests.swift b/TableProTests/ViewModels/UsersRolesViewModelChangeForwardingTests.swift new file mode 100644 index 0000000000..a2a90339e0 --- /dev/null +++ b/TableProTests/ViewModels/UsersRolesViewModelChangeForwardingTests.swift @@ -0,0 +1,74 @@ +// +// UsersRolesViewModelChangeForwardingTests.swift +// TableProTests +// +// The Users & Roles views observe the view model and read staged changes through it, so a change +// the change manager publishes has to reach them as a change of the view model. +// + +import Combine +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct UsersRolesViewModelChangeForwardingTests { + private final class Counter { + var value = 0 + } + + private let alice = PluginPrincipalRef(name: "alice") + private let app = PluginPrivilegeScope.database("app") + + private func makeViewModel() -> UsersRolesViewModel { + let viewModel = UsersRolesViewModel(connectionId: UUID(), databaseType: .postgresql) + viewModel.changeManager.load( + principals: [PluginPrincipalInfo(ref: alice)], + catalog: PluginPrivilegeCatalog( + databasePrivileges: [ + PluginPrivilegeDescriptor(name: "CONNECT", label: "Connect"), + PluginPrivilegeDescriptor(name: "CREATE", label: "Create") + ] + ) + ) + viewModel.changeManager.loadGrants( + [PluginGrantInfo(privilege: "CONNECT", scope: app, isGrantable: false)], + for: alice + ) + viewModel.selection = alice + viewModel.selectedRefs = [alice] + viewModel.selectedScopes = [app] + return viewModel + } + + @Test("Ticking a privilege is a change of the view model the checklist observes") + func tickingAPrivilegeNotifiesTheViewModel() { + let viewModel = makeViewModel() + let notifications = Counter() + let subscription = viewModel.objectWillChange.sink { notifications.value += 1 } + defer { subscription.cancel() } + + viewModel.setGranted(true, privilege: "CREATE") + + #expect(notifications.value > 0, "the tick was staged but nothing observing the view model heard it") + #expect(viewModel.grantState(for: "CREATE") == .checked) + #expect(viewModel.hasChanges) + } + + @Test("Undoing a staged grant notifies the view model too") + func undoNotifiesTheViewModel() { + let viewModel = makeViewModel() + viewModel.setGranted(true, privilege: "CREATE") + let notifications = Counter() + let subscription = viewModel.objectWillChange.sink { notifications.value += 1 } + defer { subscription.cancel() } + + viewModel.undo() + + #expect(notifications.value > 0) + #expect(viewModel.grantState(for: "CREATE") == .unchecked) + #expect(!viewModel.hasChanges) + } +} diff --git a/TableProTests/Views/ERDiagram/ERColumnRowTextTests.swift b/TableProTests/Views/ERDiagram/ERColumnRowTextTests.swift new file mode 100644 index 0000000000..6766d0631e --- /dev/null +++ b/TableProTests/Views/ERDiagram/ERColumnRowTextTests.swift @@ -0,0 +1,72 @@ +// +// ERColumnRowTextTests.swift +// TableProTests +// +// A column row draws its name from the leading edge and its type to the trailing edge. Nothing +// kept the two apart, so a long name ran under its type: "shipping_addr" over +// "character varying(…". +// + +import CoreGraphics +import Testing + +@testable import TablePro + +@MainActor +struct ERColumnRowTextTests { + @Test("A long name and a long type share the row without touching") + func longNameAndTypeDoNotOverlap() { + let nodeWidth = ERDiagramLayout.nodeWidth + let widths = ERDiagramNodeRenderer.columnTextWidths( + name: "shipping_address", + type: "character varying(255)", + nodeWidth: nodeWidth + ) + let unfitted = ERDiagramNodeRenderer.columnTextWidths( + name: "shipping_address", + type: "character varying(255)", + nodeWidth: 10_000 + ) + let room = nodeWidth - 32 * ERDiagramLayout.typeScale + + #expect(widths.name == unfitted.name, "the name was cut although the type could give way") + #expect(widths.type < unfitted.type, "the type was drawn whole into a row with no room for it") + #expect(widths.name + widths.type + 8 * ERDiagramLayout.typeScale <= room + 0.5) + } + + @Test("A short name leaves a long type its whole width") + func shortNameLeavesTheTypeWhole() { + let widths = ERDiagramNodeRenderer.columnTextWidths( + name: "id", + type: "timestamp with time zone", + nodeWidth: ERDiagramLayout.nodeWidth + ) + let unfitted = ERDiagramNodeRenderer.columnTextWidths( + name: "id", + type: "timestamp with time zone", + nodeWidth: 10_000 + ) + #expect(widths == unfitted) + } + + @Test("A name too long for the row keeps the type's first characters") + func overlongNameLeavesTheTypeFloor() { + let widths = ERColumnTextWidths(room: 180, gap: 8, name: 400, type: 120, typeFloor: 44) + #expect(widths.type == 44) + #expect(widths.name == 128) + } + + @Test("A type narrower than the floor is reserved only its own width") + func shortTypeReservesItsOwnWidth() { + let widths = ERColumnTextWidths(room: 180, gap: 8, name: 400, type: 20, typeFloor: 44) + #expect(widths.type == 20) + #expect(widths.name == 152) + } + + @Test("A row with no type gives the name all of its room") + func untypedRowGivesTheNameEverything() { + let widths = ERColumnTextWidths(room: 180, gap: 8, name: 400, type: 0, typeFloor: 44) + #expect(widths.name == 180) + #expect(widths.type == 0) + } +} diff --git a/TableProTests/Views/Editor/QueryEditorViewLayoutTests.swift b/TableProTests/Views/Editor/QueryEditorViewLayoutTests.swift new file mode 100644 index 0000000000..40d321b101 --- /dev/null +++ b/TableProTests/Views/Editor/QueryEditorViewLayoutTests.swift @@ -0,0 +1,65 @@ +// +// QueryEditorViewLayoutTests.swift +// TableProTests +// +// The query split stops its editor pane at a fixed minimum. The editor, its bar and the divider +// between them have to fit inside it, or the stack overflows the pane: the bar is cut off at the +// top and the editor's text runs up over it. +// + +import AppKit +import SwiftUI +import Testing + +@testable import TablePro + +@MainActor +struct QueryEditorViewLayoutTests { + private func makeEditor() -> QueryEditorView { + QueryEditorView( + queryText: .constant("SELECT 1"), + cursorPositions: .constant([]), + parameters: .constant([]), + isParameterPanelVisible: .constant(false), + scope: QueryScopeBarModel( + containers: [], + selectedName: "shop", + entityName: String(localized: "Database"), + isReadOnly: false, + schemaName: nil + ), + commands: QueryCommandAvailability( + isConnected: true, + hasQueryText: true, + isExecuting: false, + isStoppable: true, + hasResults: false, + explainVariants: [], + shortcutHint: { label, _ in label } + ), + onRun: {}, + onRunAllStatements: {}, + onRunWithoutLimit: {}, + onStop: {}, + onExplain: { _ in }, + onFormat: {}, + onSaveAsFavoriteCommand: {}, + onClearQuery: {}, + onClearResults: {}, + onContainerChanged: { _ in } + ) + } + + @Test("The editor and its bar fit the editor pane's minimum height") + func editorFitsThePaneMinimum() { + let paneMinimum = VerticalCollapsibleSplitView.defaultTopMinimumThickness + let controller = NSHostingController(rootView: makeEditor()) + + let squeezed = controller.sizeThatFits(in: CGSize(width: 900, height: 1)) + + #expect( + squeezed.height <= paneMinimum, + "the editor stack needs \(squeezed.height)pt inside a pane that can be \(paneMinimum)pt" + ) + } +} diff --git a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index 333ff808c7..03f02ceff1 100644 --- a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -476,6 +476,70 @@ struct ResultStatusBarLayoutTests { expectFills(bar, at: width) } + private static var estimatedPagination: PaginationState { + var pagination = PaginationState(totalRowCount: 22, pageSize: 1_000) + pagination.isApproximateRowCount = true + return pagination + } + + @Test("A table with an estimate is never wider than its host", arguments: statusBarHostWidths) + func estimatedBarNeverExceedsItsHost(width: CGFloat) { + let bar = makeBar( + rowCount: 22, hasColumns: true, tabType: .table, viewMode: .data, pagination: Self.estimatedPagination + ) + #expect(bar.model.controls.showsExactCountAction) + expectFills(bar, at: width) + } + + /// `ViewThatFits` picks a tier from each candidate's ideal width. With Count Exactly counted + /// inside the readout's constant, a table with an estimate stayed on the regular tier at widths + /// with no room for the sentence and the button together, and the bar drew "1-22 of ~22 r…" + /// beside "Count Ex…". + @Test("Count Exactly adds its own width to the width the bar asks for") + func exactCountActionWidensTheIdealWidth() { + let estimated = makeBar( + rowCount: 22, hasColumns: true, tabType: .table, viewMode: .data, pagination: Self.estimatedPagination + ) + let exact = makeBar( + rowCount: 22, + hasColumns: true, + tabType: .table, + viewMode: .data, + pagination: PaginationState(totalRowCount: 22, pageSize: 1_000) + ) + #expect(!exact.model.controls.showsExactCountAction) + + let extra = idealWidth(of: estimated) - idealWidth(of: exact) + #expect(extra > 40, "the bar asked for \(extra)pt more with Count Exactly on it") + } + + /// A wordy driver message still adds nothing: it truncates instead of dropping the tier. + @Test("A driver message adds nothing to the width the bar asks for") + func statusMessageLeavesTheIdealWidthAlone() { + let plain = makeBar( + rowCount: 1_000, + hasColumns: true, + tabType: .table, + viewMode: .data, + pagination: PaginationState(totalRowCount: 5_000, pageSize: 1_000) + ) + let wordy = makeBar( + rowCount: 1_000, + hasColumns: true, + tabType: .table, + viewMode: .data, + pagination: PaginationState(totalRowCount: 5_000, pageSize: 1_000), + statusMessage: Self.wordyDriverMessage + ) + #expect(idealWidth(of: wordy) == idealWidth(of: plain)) + } + + private func idealWidth(of bar: ResultStatusBar) -> CGFloat { + let host = NSHostingView(rootView: bar) + host.layoutSubtreeIfNeeded() + return host.intrinsicContentSize.width + } + /// The narrowest tier has to fit the narrowest pane the window can produce, or `ViewThatFits` /// falls through to a row that overflows anyway. `resolveDetailMinimumThickness` sets that /// floor, so the two are checked against each other rather than against a literal that could diff --git a/TableProTests/Views/ServerDashboard/ServerDashboardSplitViewTests.swift b/TableProTests/Views/ServerDashboard/ServerDashboardSplitViewTests.swift new file mode 100644 index 0000000000..62856bed80 --- /dev/null +++ b/TableProTests/Views/ServerDashboard/ServerDashboardSplitViewTests.swift @@ -0,0 +1,87 @@ +// +// ServerDashboardSplitViewTests.swift +// TableProTests +// +// The metrics and slow query panes are hosting controllers inside an `NSSplitViewController`, so +// they only see a refresh when SwiftUI calls `updateNSViewController`. Holding the view model as a +// plain reference made the representable compare equal on every refresh and skip that call, which +// left Server Metrics on its spinner for as long as the tab stayed open. +// + +import AppKit +import SwiftUI +import Testing + +@testable import TablePro + +@MainActor +struct ServerDashboardSplitViewTests { + @Test("A refresh reaches the metrics and slow query panes") + func refreshReachesHostedPanes() async throws { + let viewModel = ServerDashboardViewModel(connectionId: UUID(), databaseType: .mysql, services: .live) + let window = host(ServerDashboardView(viewModel: viewModel)) + defer { + viewModel.stopAutoRefresh() + window.orderOut(nil) + window.contentView = nil + } + + let splitController = try #require(splitViewController(in: window.contentView)) + #expect(metrics(in: splitController)?.isEmpty == true) + + viewModel.metrics = [ + DashboardMetric(id: "uptime", label: "Uptime", value: "3", unit: "days", icon: "clock") + ] + viewModel.slowQueries = [ + DashboardSlowQuery(duration: "4s", query: "SELECT SLEEP(4)", user: "app", database: "shop") + ] + + #expect(await settle(window) { metrics(in: splitController)?.count == 1 }) + #expect(slowQueries(in: splitController)?.count == 1) + } + + private func host(_ view: some View) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: view) + window.contentView?.layoutSubtreeIfNeeded() + return window + } + + private func settle(_ window: NSWindow, until condition: () -> Bool) async -> Bool { + for _ in 0 ..< 100 { + window.contentView?.layoutSubtreeIfNeeded() + if condition() { return true } + try? await Task.sleep(for: .milliseconds(20)) + } + return condition() + } + + private func splitViewController(in view: NSView?) -> NSSplitViewController? { + guard let view else { return nil } + if let splitView = view as? NSSplitView, let controller = splitView.delegate as? NSSplitViewController { + return controller + } + for subview in view.subviews { + if let found = splitViewController(in: subview) { return found } + } + return nil + } + + private func metrics(in controller: NSSplitViewController) -> [DashboardMetric]? { + controller.splitViewItems + .compactMap { $0.viewController as? NSHostingController } + .first?.rootView.metrics + } + + private func slowQueries(in controller: NSSplitViewController) -> [DashboardSlowQuery]? { + controller.splitViewItems + .compactMap { $0.viewController as? NSHostingController } + .first?.rootView.queries + } +}