diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f0ce817..e911f0421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Reopening a table now restores the filter you had applied, instead of clearing it. Filters are remembered per connection. (#1347) +- Quick switcher panel height now fits its results instead of leaving a large empty area below short lists. (#1349) - Importing connections from TablePlus brings over saved passwords again. A recent release looked under the wrong keychain name, so connections imported with no passwords and no warning. - Importing an SSH connection from TablePlus no longer fills in a fake private key path such as `~/.ssh/Import a private key...` when no key was selected. Empty TLS certificate paths are skipped too. - Importing from DBeaver no longer shows an unnecessary keychain permission warning. DBeaver stores passwords in its own file, so macOS never prompts. diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 859c7837c..4e84b93c4 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -33,7 +33,7 @@ internal final class QuickSwitcherViewModel { @ObservationIgnored private var activeLoadId = UUID() private(set) var groups: [Group] = [] - private(set) var isLoading = false + private(set) var isLoading = true var selectedItemId: String? var searchText = "" { @@ -47,6 +47,13 @@ internal final class QuickSwitcherViewModel { groups.flatMap(\.items) } + func listHeight(rowHeight: CGFloat, headerHeight: CGFloat, maxVisibleRows: Int) -> CGFloat { + let headerCount = groups.filter { $0.header != nil }.count + let naturalHeight = CGFloat(flatItems.count) * rowHeight + CGFloat(headerCount) * headerHeight + let maxHeight = CGFloat(maxVisibleRows) * rowHeight + return min(naturalHeight, maxHeight) + } + init(connectionId: UUID, services: AppServices, defaults: UserDefaults = .standard) { self.connectionId = connectionId self.services = services diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift index 1775b5faf..7efbfdca5 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift @@ -14,6 +14,11 @@ struct QuickSwitcherSheet: View { let databaseType: DatabaseType let onSelect: (QuickSwitcherItem) -> Void + private let sheetWidth: CGFloat = 460 + private let rowHeight: CGFloat = 30 + private let sectionHeaderHeight: CGFloat = 28 + private let maxVisibleRows = 9 + @State private var viewModel: QuickSwitcherViewModel init( @@ -43,13 +48,18 @@ struct QuickSwitcherSheet: View { emptyState } else { itemList + .frame(height: viewModel.listHeight( + rowHeight: rowHeight, + headerHeight: sectionHeaderHeight, + maxVisibleRows: maxVisibleRows + )) } Divider() footer } - .frame(width: 460, height: 500) + .frame(width: sheetWidth) .navigationTitle(String(localized: "Quick Switcher")) .background(Color(nsColor: .windowBackgroundColor)) .task { @@ -144,9 +154,9 @@ struct QuickSwitcherSheet: View { .lineLimit(1) } } - .padding(.vertical, 3) + .frame(height: rowHeight) .contentShape(Rectangle()) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) .listRowSeparator(.hidden) .id(item.id) .tag(item.id) @@ -160,7 +170,8 @@ struct QuickSwitcherSheet: View { .font(.callout) .foregroundStyle(.secondary) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) } private var emptyState: some View { @@ -181,7 +192,8 @@ struct QuickSwitcherSheet: View { .foregroundStyle(.secondary) } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) } private var footer: some View { diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index ec3cc3d4e..089daf48a 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -165,4 +165,85 @@ struct QuickSwitcherViewModelTests { #expect(vm.flatItems.contains(where: { $0.id == "d1" }) == false) #expect(vm.selectedItemId == vm.flatItems.first?.id) } + + @Test("listHeight is zero when there are no items") + func listHeightZeroWhenEmpty() { + let vm = makeViewModel(items: []) + #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 0) + } + + @Test("listHeight for a single filtered result is one row") + func listHeightSingleFilteredRow() async throws { + let vm = makeViewModel(items: [QuickSwitcherItem(id: "t1", name: "users", kind: .table, subtitle: "")]) + vm.searchText = "users" + try await Task.sleep(nanoseconds: 80_000_000) + #expect(vm.groups.first?.header == nil) + #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 30) + } + + @Test("listHeight at the row cap shows every row") + func listHeightAtCap() async throws { + var items: [QuickSwitcherItem] = [] + for index in 0..<9 { + items.append(QuickSwitcherItem(id: "t\(index)", name: "tbl_\(index)", kind: .table, subtitle: "")) + } + let vm = makeViewModel(items: items) + vm.searchText = "tbl" + try await Task.sleep(nanoseconds: 80_000_000) + #expect(vm.flatItems.count == 9) + #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) + } + + @Test("listHeight caps at maxVisibleRows when results overflow") + func listHeightCapsWhenOverflowing() async throws { + var items: [QuickSwitcherItem] = [] + for index in 0..<20 { + items.append(QuickSwitcherItem(id: "t\(index)", name: "tbl_\(index)", kind: .table, subtitle: "")) + } + let vm = makeViewModel(items: items) + vm.searchText = "tbl" + try await Task.sleep(nanoseconds: 80_000_000) + #expect(vm.flatItems.count == 20) + #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) + } + + @Test("listHeight for the empty-query view counts section headers") + func listHeightCountsSectionHeaders() { + let vm = makeViewModel(items: sampleItems()) + #expect(vm.groups.filter { $0.header != nil }.count == 4) + #expect(vm.flatItems.count == 5) + #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 262) + } + + @Test("listHeight grows by one header when a Recent group appears") + func listHeightIncludesRecentHeader() { + let suite = makeDefaults() + let connectionId = UUID() + let items = sampleItems() + let vm = makeViewModel(items: items, connectionId: connectionId, defaults: suite) + let baseline = vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 100) + vm.recordSelection(items[0]) + + let vm2 = QuickSwitcherViewModel(connectionId: connectionId, services: .live, defaults: suite) + vm2.allItems = items + #expect(vm2.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 100) == baseline + 28) + } + + @Test("listHeight clamps to the cap when sections and rows overflow") + func listHeightClampsWithHeaders() { + var items: [QuickSwitcherItem] = [] + for index in 0..<30 { + items.append(QuickSwitcherItem(id: "t\(index)", name: "table_\(index)", kind: .table, subtitle: "")) + items.append(QuickSwitcherItem(id: "v\(index)", name: "view_\(index)", kind: .view, subtitle: "View")) + } + let vm = makeViewModel(items: items) + #expect(vm.groups.filter { $0.header != nil }.count >= 2) + #expect(vm.listHeight(rowHeight: 30, headerHeight: 28, maxVisibleRows: 9) == 270) + } + + @Test("isLoading is true until the first load finishes") + func isLoadingStartsTrue() { + let vm = QuickSwitcherViewModel(connectionId: UUID(), services: .live, defaults: makeDefaults()) + #expect(vm.isLoading) + } }