Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion TablePro/ViewModels/QuickSwitcherViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "" {
Expand All @@ -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
Expand Down
22 changes: 17 additions & 5 deletions TablePro/Views/QuickSwitcher/QuickSwitcherSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
81 changes: 81 additions & 0 deletions TableProTests/ViewModels/QuickSwitcherViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading