Skip to content

Commit b84d132

Browse files
desperadoxhyxuhengyudatlechin
authored
perf(sidebar): debounce table search to keep typing responsive (#1725)
* perf(sidebar): debounce table search to keep typing responsive * test(sidebar): make filterQuery-vs-searchText test discriminating --------- Co-authored-by: xuhengyu <xuhengyu@aspirecn.com> Co-authored-by: Ngo Quoc Dat <datlechin@gmail.com>
1 parent c7ea512 commit b84d132

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+
@Test("filterQuery updates immediately on first non-empty input")
527+
@MainActor
528+
func filterQueryUpdatesImmediatelyOnFirstInput() {
529+
let vm = makeViewModel()
530+
531+
vm.searchText = "user"
532+
533+
#expect(vm.filterQuery == "user")
534+
}
535+
536+
@Test("filterQuery clears immediately when search becomes empty")
537+
@MainActor
538+
func filterQueryClearsImmediatelyOnEmpty() async {
539+
let vm = makeViewModel()
540+
vm.searchText = "user"
541+
vm.searchText = "users"
542+
await Task.yield()
543+
544+
vm.searchText = ""
545+
546+
#expect(vm.filterQuery == "")
547+
}
548+
549+
@Test("filterQuery stays at previous value during debounce window")
550+
@MainActor
551+
func filterQueryHoldsPreviousValueDuringDebounce() async {
552+
let vm = makeViewModel()
553+
vm.searchText = "user"
554+
#expect(vm.filterQuery == "user")
555+
556+
vm.searchText = "users"
557+
await Task.yield()
558+
559+
#expect(vm.filterQuery == "user")
560+
}
561+
562+
@Test("filterQuery catches up after debounce window elapses")
563+
@MainActor
564+
func filterQueryCatchesUpAfterDebounce() async {
565+
let vm = makeViewModel()
566+
vm.searchText = "user"
567+
568+
vm.searchText = "users"
569+
570+
try? await Task.sleep(nanoseconds: 300_000_000)
571+
await Task.yield()
572+
573+
#expect(vm.filterQuery == "users")
574+
}
575+
576+
@Test("rapid consecutive keystrokes collapse to the final value")
577+
@MainActor
578+
func rapidKeystrokesCollapseToFinalValue() async {
579+
let vm = makeViewModel()
580+
vm.searchText = "u"
581+
582+
vm.searchText = "us"
583+
vm.searchText = "use"
584+
vm.searchText = "user"
585+
await Task.yield()
586+
587+
#expect(vm.filterQuery == "u")
588+
589+
try? await Task.sleep(nanoseconds: 300_000_000)
590+
await Task.yield()
591+
592+
#expect(vm.filterQuery == "user")
593+
}
594+
595+
@Test("filter caches still serve filteredTables using filterQuery, not searchText")
596+
@MainActor
597+
func filteredTablesHonorsFilterQueryNotSearchText() async {
598+
let vm = makeViewModel()
599+
let users = TestFixtures.makeTableInfo(name: "users", type: .table)
600+
let userLog = TestFixtures.makeTableInfo(name: "user_log", type: .table)
601+
let orders = TestFixtures.makeTableInfo(name: "orders", type: .table)
602+
let mixed = [users, userLog, 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", "user_log"])
611+
}
612+
}

0 commit comments

Comments
 (0)