Skip to content

Commit bfbf818

Browse files
author
xuhengyu
committed
perf(sidebar): debounce table search to keep typing responsive
1 parent 07961dc commit bfbf818

5 files changed

Lines changed: 129 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Typing in the sidebar table search stays responsive on databases with thousands of tables; filtering runs after a short pause instead of on every keystroke.
13+
1014
### Fixed
1115

1216
- Oracle connections no longer crash the app when the server sends a backend message the driver cannot decode; the query fails with a clear error and the connection reconnects. (#483)

TablePro/ViewModels/SidebarViewModel.swift

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import TableProPluginKit
1010
@MainActor @Observable
1111
final class SidebarViewModel {
1212
private static var registry: [UUID: SidebarViewModel] = [:]
13+
private static let searchDebounceNanoseconds: UInt64 = 150_000_000
1314

1415
static func shared(
1516
connectionId: UUID,
@@ -78,8 +79,15 @@ final class SidebarViewModel {
7879
// MARK: - Published State
7980

8081
var searchText = "" {
82+
didSet { scheduleFilterQueryUpdate(oldValue: oldValue) }
83+
}
84+
85+
private(set) var filterQuery = "" {
8186
didSet { invalidateFilterCaches() }
8287
}
88+
89+
@ObservationIgnored private var filterDebounceTask: Task<Void, Never>?
90+
8391
var expanded: ExpansionState {
8492
didSet { persistExpansion(oldValue: oldValue) }
8593
}
@@ -329,7 +337,7 @@ final class SidebarViewModel {
329337
}
330338

331339
func filteredTables(from tables: [TableInfo]) -> [TableInfo] {
332-
let query = searchText
340+
let query = filterQuery
333341
let fingerprint = (count: tables.count, generation: schemaGeneration, query: query)
334342
if let cache = cachedFilteredTables,
335343
let inputs = cachedFilterInputs,
@@ -359,7 +367,7 @@ final class SidebarViewModel {
359367
}
360368

361369
func filteredTables(of kind: SidebarObjectKind, from tables: [TableInfo]) -> [TableInfo] {
362-
let query = searchText
370+
let query = filterQuery
363371
let fingerprint = (count: tables.count, generation: schemaGeneration, query: query)
364372
if cachedFilteredByKindFingerprint?.count != fingerprint.count
365373
|| cachedFilteredByKindFingerprint?.generation != fingerprint.generation
@@ -378,7 +386,7 @@ final class SidebarViewModel {
378386
}
379387

380388
func filteredRoutines(of kind: SidebarObjectKind, from routines: [RoutineInfo]) -> [RoutineInfo] {
381-
let query = searchText
389+
let query = filterQuery
382390
let fingerprint = (count: routines.count, generation: schemaGeneration, query: query)
383391
if cachedFilteredRoutinesFingerprint?.count != fingerprint.count
384392
|| cachedFilteredRoutinesFingerprint?.generation != fingerprint.generation
@@ -393,7 +401,7 @@ final class SidebarViewModel {
393401
}
394402

395403
func effectiveExpanded(kind: SidebarObjectKind, hasMatches: Bool) -> Bool {
396-
if !searchText.isEmpty && hasMatches { return true }
404+
if !filterQuery.isEmpty && hasMatches { return true }
397405
return expanded[kind]
398406
}
399407

@@ -436,4 +444,24 @@ final class SidebarViewModel {
436444
cachedFilteredRoutines = [:]
437445
cachedFilteredRoutinesFingerprint = nil
438446
}
447+
448+
private func scheduleFilterQueryUpdate(oldValue: String) {
449+
if searchText.isEmpty || oldValue.isEmpty {
450+
filterDebounceTask?.cancel()
451+
filterDebounceTask = nil
452+
filterQuery = searchText
453+
return
454+
}
455+
filterDebounceTask?.cancel()
456+
filterDebounceTask = Task { @MainActor [weak self] in
457+
try? await Task.sleep(nanoseconds: Self.searchDebounceNanoseconds)
458+
guard !Task.isCancelled else { return }
459+
guard let self else { return }
460+
self.filterQuery = self.searchText
461+
}
462+
}
463+
464+
deinit {
465+
filterDebounceTask?.cancel()
466+
}
439467
}

TablePro/Views/Sidebar/SidebarTreeView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ struct SidebarTreeView: View {
2323
}
2424

2525
private var searchText: String {
26-
viewModel.searchText
26+
viewModel.filterQuery
2727
}
2828

2929
private var visibleSchemas: [String] {

TablePro/Views/Sidebar/SidebarView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ struct SidebarView: View {
275275
loadingState
276276
case .failed(let message):
277277
errorState(message: message)
278-
case .loaded where !viewModel.searchText.isEmpty && !hasAnyMatch:
278+
case .loaded where !viewModel.filterQuery.isEmpty && !hasAnyMatch:
279279
noMatchState
280280
case .loaded(let allTables) where allTables.isEmpty && routines.isEmpty:
281281
emptyState
@@ -361,7 +361,7 @@ struct SidebarView: View {
361361
if viewModel.databaseType == .redis, let keyTreeVM = sidebarState.redisKeyTreeViewModel {
362362
Section(isExpanded: $viewModel.isRedisKeysExpanded) {
363363
RedisKeyTreeView(
364-
nodes: keyTreeVM.displayNodes(searchText: viewModel.searchText),
364+
nodes: keyTreeVM.displayNodes(searchText: viewModel.filterQuery),
365365
isLoading: keyTreeVM.isLoading,
366366
isTruncated: keyTreeVM.isTruncated,
367367
onSelectNamespace: { prefix in

TableProTests/ViewModels/SidebarViewModelTests.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,93 @@ struct SidebarViewModelMultiSectionTests {
520520
UserDefaults.standard.removeObject(forKey: SidebarPersistenceKey.legacyTablesExpanded)
521521
}
522522
}
523+
524+
@Suite("SidebarViewModel search debounce")
525+
struct SidebarViewModelSearchDebounceTests {
526+
527+
@Test("filterQuery updates immediately on first non-empty input")
528+
@MainActor
529+
func filterQueryUpdatesImmediatelyOnFirstInput() {
530+
let vm = makeViewModel()
531+
532+
vm.searchText = "user"
533+
534+
#expect(vm.filterQuery == "user")
535+
}
536+
537+
@Test("filterQuery clears immediately when search becomes empty")
538+
@MainActor
539+
func filterQueryClearsImmediatelyOnEmpty() async {
540+
let vm = makeViewModel()
541+
vm.searchText = "user"
542+
vm.searchText = "users"
543+
await Task.yield()
544+
545+
vm.searchText = ""
546+
547+
#expect(vm.filterQuery == "")
548+
}
549+
550+
@Test("filterQuery stays at previous value during debounce window")
551+
@MainActor
552+
func filterQueryHoldsPreviousValueDuringDebounce() async {
553+
let vm = makeViewModel()
554+
vm.searchText = "user"
555+
#expect(vm.filterQuery == "user")
556+
557+
vm.searchText = "users"
558+
await Task.yield()
559+
560+
#expect(vm.filterQuery == "user")
561+
}
562+
563+
@Test("filterQuery catches up after debounce window elapses")
564+
@MainActor
565+
func filterQueryCatchesUpAfterDebounce() async {
566+
let vm = makeViewModel()
567+
vm.searchText = "user"
568+
569+
vm.searchText = "users"
570+
571+
try? await Task.sleep(nanoseconds: 300_000_000)
572+
await Task.yield()
573+
574+
#expect(vm.filterQuery == "users")
575+
}
576+
577+
@Test("rapid consecutive keystrokes collapse to the final value")
578+
@MainActor
579+
func rapidKeystrokesCollapseToFinalValue() async {
580+
let vm = makeViewModel()
581+
vm.searchText = "u"
582+
583+
vm.searchText = "us"
584+
vm.searchText = "use"
585+
vm.searchText = "user"
586+
await Task.yield()
587+
588+
#expect(vm.filterQuery == "u")
589+
590+
try? await Task.sleep(nanoseconds: 300_000_000)
591+
await Task.yield()
592+
593+
#expect(vm.filterQuery == "user")
594+
}
595+
596+
@Test("filter caches still serve filteredTables using filterQuery, not searchText")
597+
@MainActor
598+
func filteredTablesHonorsFilterQueryNotSearchText() async {
599+
let vm = makeViewModel()
600+
let users = TestFixtures.makeTableInfo(name: "users", type: .table)
601+
let orders = TestFixtures.makeTableInfo(name: "orders", type: .table)
602+
let mixed = [users, orders]
603+
604+
vm.searchText = "user"
605+
vm.searchText = "users"
606+
await Task.yield()
607+
608+
let matches = vm.filteredTables(of: .table, from: mixed)
609+
610+
#expect(matches.map(\.name) == ["users"])
611+
}
612+
}

0 commit comments

Comments
 (0)