From 98f42dcbb608853b3086616629fabe735914b2fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 4 Oct 2026 19:43:04 +0700 Subject: [PATCH] fix(hig): redraw stale dashboard and privilege views, unclip panes Server Dashboard metrics and slow queries never refreshed, Users & Roles privilege clicks did not redraw, Redis Count Exactly kept the estimate, the status bar truncated its readout, the query editor overflowed its pane at the minimum height, and ER column names ran over their types. --- CHANGELOG.md | 6 + .../RedisCommandChannel.swift | 26 ++++ .../RedisDriverPlugin/RedisPluginDriver.swift | 27 ++++ .../RedisDriverPlugin/RedisQueryBuilder.swift | 17 ++- .../Coordinators/PaginationCoordinator.swift | 12 +- TablePro/Core/Database/DatabaseDriver.swift | 5 + .../Core/Plugins/PluginDriverAdapter.swift | 9 ++ .../Services/Query/TableQueryBuilder.swift | 10 +- TablePro/Models/UI/FilterState.swift | 16 +++ TablePro/Resources/Localizable.xcstrings | 3 + TablePro/ViewModels/UsersRolesViewModel.swift | 8 ++ .../Views/ERDiagram/ERDiagramDrawing.swift | 20 ++- .../ERDiagram/ERDiagramNodeRenderer.swift | 60 +++++++- TablePro/Views/Editor/QueryEditorView.swift | 6 +- TablePro/Views/Results/ResultStatusBar.swift | 128 +++++++++++++++--- .../ServerDashboardSplitView.swift | 6 +- .../BrowseSearchExactCountTests.swift | 85 ++++++++++++ .../Plugins/RedisExactCountTests.swift | 102 ++++++++++++++ ...sRolesViewModelChangeForwardingTests.swift | 74 ++++++++++ .../ERDiagram/ERColumnRowTextTests.swift | 72 ++++++++++ .../Editor/QueryEditorViewLayoutTests.swift | 65 +++++++++ .../Main/ResultStatusBarLayoutTests.swift | 64 +++++++++ .../ServerDashboardSplitViewTests.swift | 87 ++++++++++++ 23 files changed, 865 insertions(+), 43 deletions(-) create mode 100644 TableProTests/Core/Coordinators/BrowseSearchExactCountTests.swift create mode 100644 TableProTests/Plugins/RedisExactCountTests.swift create mode 100644 TableProTests/ViewModels/UsersRolesViewModelChangeForwardingTests.swift create mode 100644 TableProTests/Views/ERDiagram/ERColumnRowTextTests.swift create mode 100644 TableProTests/Views/Editor/QueryEditorViewLayoutTests.swift create mode 100644 TableProTests/Views/ServerDashboard/ServerDashboardSplitViewTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3362c8671b..ead251a37c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Last column's divider sitting on the grid's right edge, where it could not be dragged to resize. - Cells keeping their old width or order while a column is resized or dragged, until the mouse is released. - Resize cursor showing a few points from a column divider, where a drag moved the column instead. +- 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 f952b03298..db9068a405 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -184987,6 +184987,9 @@ }, "etcd cannot filter with %@." : { + }, + "The cluster changed while its keys were being counted. Count again." : { + } }, "version" : "1.1" 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 + } +}