From 97ea17b1b8a66d35b7bf5df96f8f1066822c2350 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 19:31:33 +0700 Subject: [PATCH 1/4] fix(sidebar): rebuild database tree on NSOutlineView to stop the expand/collapse crash --- CHANGELOG.md | 1 + .../Views/Sidebar/DatabaseTreeCellView.swift | 49 ++ .../Views/Sidebar/DatabaseTreeFilter.swift | 44 ++ TablePro/Views/Sidebar/DatabaseTreeNode.swift | 55 ++ .../DatabaseTreeOutlineCoordinator.swift | 522 ++++++++++++++++++ .../Sidebar/DatabaseTreeOutlineView.swift | 67 +++ .../Views/Sidebar/DatabaseTreeRowView.swift | 166 ++++++ TablePro/Views/Sidebar/DatabaseTreeView.swift | 475 +--------------- .../DatabaseTreeMetadataServiceTests.swift | 1 + .../Sidebar/DatabaseTreeFilterTests.swift | 66 +++ .../Views/Sidebar/DatabaseTreeNodeTests.swift | 54 ++ 11 files changed, 1047 insertions(+), 453 deletions(-) create mode 100644 TablePro/Views/Sidebar/DatabaseTreeCellView.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeFilter.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeNode.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeRowView.swift create mode 100644 TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift create mode 100644 TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ed3f6ea..cb7326828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Toolbar button tooltips now show each action's real keyboard shortcut and follow your custom bindings, instead of a fixed value. The Switch Connection tooltip showed the wrong shortcut. (#1694) - The row detail panel no longer stays blank when a table is opened in a second tab. The panel now shows the selected row right away instead of only after the inspector is toggled. - The sidebar filter now stays put when you open another tab. Before, opening a second table tab cleared the filter text and reset the table list to show everything. +- Fixed a crash when browsing the database tree on servers with many schemas, such as PostgreSQL. The sidebar tree is now a native outline view that loads each database and schema on demand. ## [0.51.1] - 2026-06-16 diff --git a/TablePro/Views/Sidebar/DatabaseTreeCellView.swift b/TablePro/Views/Sidebar/DatabaseTreeCellView.swift new file mode 100644 index 000000000..d1501f3d0 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeCellView.swift @@ -0,0 +1,49 @@ +// +// DatabaseTreeCellView.swift +// TablePro +// + +import AppKit +import SwiftUI + +final class DatabaseTreeCellView: NSTableCellView { + private var hosting: NSHostingView? + private var node: DatabaseTreeNode? + private var rowContext: DatabaseTreeRowContext? + private var actions: DatabaseTreeRowActions? + + override var backgroundStyle: NSView.BackgroundStyle { + didSet { rebuild() } + } + + func configure(node: DatabaseTreeNode, context: DatabaseTreeRowContext, actions: DatabaseTreeRowActions) { + self.node = node + self.rowContext = context + self.actions = actions + rebuild() + } + + private func rebuild() { + guard let node, let rowContext, let actions else { return } + let rootView = DatabaseTreeRowView( + node: node, + isEmphasized: backgroundStyle == .emphasized, + context: rowContext, + actions: actions + ) + if let hosting { + hosting.rootView = rootView + return + } + let view = NSHostingView(rootView: rootView) + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: leadingAnchor), + view.trailingAnchor.constraint(equalTo: trailingAnchor), + view.topAnchor.constraint(equalTo: topAnchor, constant: 2), + view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2) + ]) + hosting = view + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift new file mode 100644 index 000000000..f32331ae1 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -0,0 +1,44 @@ +// +// DatabaseTreeFilter.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +enum DatabaseTreeFilter { + static func matches(_ query: String, _ candidate: String) -> Bool { + FuzzyMatcher.matches(query: query, candidate: candidate) + } + + static func filteredTables(_ tables: [TableInfo], searchText: String) -> [TableInfo] { + let matched = searchText.isEmpty ? tables : tables.filter { matches(searchText, $0.name) } + return deduplicated(matched, by: \.id) + } + + static func filteredRoutines(_ routines: [RoutineInfo], searchText: String) -> [RoutineInfo] { + let matched = searchText.isEmpty ? routines : routines.filter { matches(searchText, $0.name) } + return deduplicated(matched, by: \.id) + } + + static func visibleSchemas( + _ schemas: [String], + systemSchemas: Set, + searchText: String, + contentMatches: (String) -> Bool + ) -> [String] { + let nonSystem = schemas.filter { !systemSchemas.contains($0) } + let matched = searchText.isEmpty + ? nonSystem + : nonSystem.filter { matches(searchText, $0) || contentMatches($0) } + return deduplicated(matched, by: { $0 }) + } + + private static func deduplicated( + _ items: [Element], + by key: (Element) -> Key + ) -> [Element] { + var seen = Set() + return items.filter { seen.insert(key($0)).inserted } + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeNode.swift b/TablePro/Views/Sidebar/DatabaseTreeNode.swift new file mode 100644 index 000000000..bd56e7d24 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -0,0 +1,55 @@ +// +// DatabaseTreeNode.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +final class DatabaseTreeNode { + enum Status: Equatable { + case loading + case empty + case error(String) + } + + enum Kind { + case database(DatabaseMetadata) + case schema(database: String, schema: String) + case table(DatabaseTreeTableRef) + case routine(DatabaseTreeRoutineRef) + case status(Status) + } + + let id: String + var kind: Kind + + init(id: String, kind: Kind) { + self.id = id + self.kind = kind + } + + var isExpandable: Bool { + switch kind { + case .database, .schema: return true + case .table, .routine, .status: return false + } + } + + var tableRef: DatabaseTreeTableRef? { + if case .table(let ref) = kind { return ref } + return nil + } + + static func databaseId(_ database: String) -> String { "db\u{1}\(database)" } + static func schemaId(database: String, schema: String) -> String { "schema\u{1}\(database)\u{1}\(schema)" } + static func tableId(_ ref: DatabaseTreeTableRef) -> String { "table\u{1}\(ref.id)" } + static func routineId(_ ref: DatabaseTreeRoutineRef) -> String { "routine\u{1}\(ref.id)" } + static func statusId(parentId: String, status: Status) -> String { + switch status { + case .loading: return "\(parentId)\u{1}status.loading" + case .empty: return "\(parentId)\u{1}status.empty" + case .error: return "\(parentId)\u{1}status.error" + } + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift new file mode 100644 index 000000000..619e5d138 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -0,0 +1,522 @@ +// +// DatabaseTreeOutlineCoordinator.swift +// TablePro +// + +import AppKit +import Observation +import TableProPluginKit + +@MainActor +final class DatabaseTreeOutlineCoordinator: NSObject { + private weak var outlineView: NSOutlineView? + private let service = DatabaseTreeMetadataService.shared + private static let cellIdentifier = NSUserInterfaceItemIdentifier("DatabaseTreeCell") + + private var connectionId = UUID() + private var databaseType: DatabaseType = .mysql + private weak var mainCoordinator: MainContentCoordinator? + private var windowState: WindowSidebarState? + private var sidebarState: SharedSidebarState? + private weak var viewModel: SidebarViewModel? + private var searchText = "" + private var activeDatabase: String? + private var activeSchema: String? + private var pendingTruncates: Set = [] + private var pendingDeletes: Set = [] + + private var nodeCache: [String: DatabaseTreeNode] = [:] + private var childrenCache: [String: [DatabaseTreeNode]] = [:] + private var lastSelection: Set = [] + private var isApplyingExpansion = false + private var isSyncingSelection = false + private var hasRenderedOnce = false + private var reconcileScheduled = false + private var observationGeneration = 0 + + private var supportsSchemaLevel: Bool { + PluginManager.shared.databaseGroupingStrategy(for: databaseType) == .bySchema + } + + private var systemSchemas: Set { + Set(PluginManager.shared.systemSchemaNames(for: databaseType)) + } + + // MARK: - Attach / input + + func attach(outlineView: NSOutlineView) { + self.outlineView = outlineView + } + + func update(from view: DatabaseTreeOutlineView) { + connectionId = view.connectionId + databaseType = view.databaseType + mainCoordinator = view.coordinator + windowState = view.windowState + sidebarState = view.sidebarState + viewModel = view.viewModel + + let activeChanged = activeDatabase != view.activeDatabase || activeSchema != view.activeSchema + let changed = searchText != view.searchText + || activeChanged + || pendingTruncates != view.pendingTruncates + || pendingDeletes != view.pendingDeletes + + searchText = view.searchText + activeDatabase = view.activeDatabase + activeSchema = view.activeSchema + pendingTruncates = view.pendingTruncates + pendingDeletes = view.pendingDeletes + + if !hasRenderedOnce || activeChanged { + persistActiveExpansion() + } + + if !hasRenderedOnce { + hasRenderedOnce = true + refresh() + beginObserving() + } else if changed { + refresh() + beginObserving() + } + } + + private func persistActiveExpansion() { + guard let active = activeDatabase, let windowState else { return } + if !windowState.expandedTreeDatabases.contains(active) { + windowState.expandedTreeDatabases.insert(active) + } + if let schema = activeSchema { + let key = DatabaseSchemaKey(database: active, schema: schema) + if !windowState.expandedTreeDatabaseSchemas.contains(key) { + windowState.expandedTreeDatabaseSchemas.insert(key) + } + } + } + + // MARK: - Observation + + private func beginObserving() { + observationGeneration += 1 + let generation = observationGeneration + withObservationTracking { [weak self] in + self?.snapshotDependencies() + } onChange: { [weak self] in + Task { @MainActor in + guard let self, generation == self.observationGeneration else { return } + self.scheduleReconcile() + } + } + } + + private func scheduleReconcile() { + guard !reconcileScheduled else { return } + reconcileScheduled = true + Task { @MainActor in + self.reconcileScheduled = false + self.refresh() + self.beginObserving() + } + } + + private func snapshotDependencies() { + _ = service.databaseListState(for: connectionId) + for node in nodeCache.values { + switch node.kind { + case .database(let metadata): + _ = service.schemaListState(connectionId: connectionId, database: metadata.name) + _ = service.tablesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) + _ = service.routinesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) + case .schema(let database, let schema): + _ = service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) + _ = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) + case .table, .routine, .status: + break + } + } + } + + private func refresh() { + guard let outlineView else { return } + childrenCache.removeAll() + outlineView.reloadData() + applyDesiredExpansion() + syncSelectionToModel() + } + + // MARK: - Node building + + private func node(id: String, kind: DatabaseTreeNode.Kind) -> DatabaseTreeNode { + if let existing = nodeCache[id] { + existing.kind = kind + return existing + } + let created = DatabaseTreeNode(id: id, kind: kind) + nodeCache[id] = created + return created + } + + private func resolvedChildren(of item: Any?) -> [DatabaseTreeNode] { + let key = (item as? DatabaseTreeNode)?.id ?? "" + if let cached = childrenCache[key] { return cached } + let built = buildChildren(of: item as? DatabaseTreeNode) + childrenCache[key] = built + return built + } + + private func buildChildren(of node: DatabaseTreeNode?) -> [DatabaseTreeNode] { + guard let node else { return rootNodes() } + switch node.kind { + case .database(let metadata): + return supportsSchemaLevel + ? schemaNodes(database: metadata.name) + : objectNodes(database: metadata.name, schema: nil) + case .schema(let database, let schema): + return objectNodes(database: database, schema: schema) + case .table, .routine, .status: + return [] + } + } + + private func rootNodes() -> [DatabaseTreeNode] { + let visible = DatabaseTreeVisibility.visible( + databases: service.databases(for: connectionId), + selected: sidebarState?.databaseFilterSelected ?? [] + ) + let matched = searchText.isEmpty ? visible : visible.filter { databaseMatchesSearch($0) } + var seen = Set() + return matched + .filter { seen.insert($0.id).inserted } + .map { node(id: DatabaseTreeNode.databaseId($0.name), kind: .database($0)) } + } + + private func schemaNodes(database: String) -> [DatabaseTreeNode] { + let parentId = DatabaseTreeNode.databaseId(database) + switch service.schemaListState(connectionId: connectionId, database: database) { + case .idle, .loading: + return [statusNode(parentId: parentId, status: .loading)] + case .failed(let message): + return [statusNode(parentId: parentId, status: .error(message))] + case .loaded(let schemas): + let visible = DatabaseTreeFilter.visibleSchemas( + schemas, + systemSchemas: systemSchemas, + searchText: searchText, + contentMatches: { schemaContentMatchesSearch(database: database, schema: $0) } + ) + if visible.isEmpty { return [statusNode(parentId: parentId, status: .empty)] } + return visible.map { + node(id: DatabaseTreeNode.schemaId(database: database, schema: $0), kind: .schema(database: database, schema: $0)) + } + } + } + + private func objectNodes(database: String, schema: String?) -> [DatabaseTreeNode] { + let parentId = schema.map { DatabaseTreeNode.schemaId(database: database, schema: $0) } + ?? DatabaseTreeNode.databaseId(database) + switch service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) { + case .idle, .loading: + return [statusNode(parentId: parentId, status: .loading)] + case .failed(let message): + return [statusNode(parentId: parentId, status: .error(message))] + case .loaded: + return loadedObjectNodes(database: database, schema: schema, parentId: parentId) + } + } + + private func loadedObjectNodes(database: String, schema: String?, parentId: String) -> [DatabaseTreeNode] { + let tables = DatabaseTreeFilter.filteredTables( + service.tables(connectionId: connectionId, database: database, schema: schema), searchText: searchText + ) + let routines = DatabaseTreeFilter.filteredRoutines( + service.routines(connectionId: connectionId, database: database, schema: schema), searchText: searchText + ) + let routinesState = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) + + guard !tables.isEmpty || !routines.isEmpty else { + switch routinesState { + case .failed(let message): return [statusNode(parentId: parentId, status: .error(message))] + case .loaded: return [statusNode(parentId: parentId, status: .empty)] + case .idle, .loading: return [statusNode(parentId: parentId, status: .loading)] + } + } + + var nodes: [DatabaseTreeNode] = tables.map { table in + let ref = DatabaseTreeTableRef(database: database, schema: schema, table: table) + return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + } + nodes += routines.map { routine in + let ref = DatabaseTreeRoutineRef(database: database, schema: schema, routine: routine) + return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) + } + if case .failed(let message) = routinesState { + nodes.append(statusNode(parentId: parentId, status: .error(message))) + } + return nodes + } + + private func statusNode(parentId: String, status: DatabaseTreeNode.Status) -> DatabaseTreeNode { + node(id: DatabaseTreeNode.statusId(parentId: parentId, status: status), kind: .status(status)) + } + + // MARK: - Search + + private func databaseMatchesSearch(_ metadata: DatabaseMetadata) -> Bool { + if DatabaseTreeFilter.matches(searchText, metadata.name) { return true } + if case .loaded(let schemas) = service.schemaListState(connectionId: connectionId, database: metadata.name) { + if schemas.contains(where: { DatabaseTreeFilter.matches(searchText, $0) }) { return true } + for schema in schemas where schemaContentMatchesSearch(database: metadata.name, schema: schema) { + return true + } + } + return schemaContentMatchesSearch(database: metadata.name, schema: nil) + } + + private func schemaContentMatchesSearch(database: String, schema: String?) -> Bool { + if let schema, DatabaseTreeFilter.matches(searchText, schema) { return true } + let tables = service.tables(connectionId: connectionId, database: database, schema: schema) + if tables.contains(where: { DatabaseTreeFilter.matches(searchText, $0.name) }) { return true } + let routines = service.routines(connectionId: connectionId, database: database, schema: schema) + return routines.contains { DatabaseTreeFilter.matches(searchText, $0.name) } + } + + // MARK: - Expansion + + private func applyDesiredExpansion() { + guard let outlineView else { return } + isApplyingExpansion = true + defer { isApplyingExpansion = false } + let searching = !searchText.isEmpty + for databaseNode in resolvedChildren(of: nil) { + guard case .database(let metadata) = databaseNode.kind else { continue } + let want = searching + ? databaseMatchesSearch(metadata) + : windowState?.expandedTreeDatabases.contains(metadata.name) ?? false + setExpanded(databaseNode, want) + guard supportsSchemaLevel, outlineView.isItemExpanded(databaseNode) else { continue } + for schemaNode in resolvedChildren(of: databaseNode) { + guard case .schema(let database, let schema) = schemaNode.kind else { continue } + let wantSchema = searching + ? DatabaseTreeFilter.matches(searchText, schema) || schemaContentMatchesSearch(database: database, schema: schema) + : windowState?.expandedTreeDatabaseSchemas.contains(DatabaseSchemaKey(database: database, schema: schema)) ?? false + setExpanded(schemaNode, wantSchema) + } + } + } + + private func setExpanded(_ node: DatabaseTreeNode, _ expanded: Bool) { + guard let outlineView else { return } + if expanded, !outlineView.isItemExpanded(node) { + outlineView.expandItem(node) + } else if !expanded, outlineView.isItemExpanded(node) { + outlineView.collapseItem(node) + } + } + + private func recordExpansion(_ node: DatabaseTreeNode, expanded: Bool) { + switch node.kind { + case .database(let metadata): + if expanded { + windowState?.expandedTreeDatabases.insert(metadata.name) + } else { + windowState?.expandedTreeDatabases.remove(metadata.name) + } + case .schema(let database, let schema): + let key = DatabaseSchemaKey(database: database, schema: schema) + if expanded { + windowState?.expandedTreeDatabaseSchemas.insert(key) + } else { + windowState?.expandedTreeDatabaseSchemas.remove(key) + } + case .table, .routine, .status: + break + } + } + + private func triggerLoad(for node: DatabaseTreeNode) { + switch node.kind { + case .database(let metadata): + if supportsSchemaLevel { + if isIdle(service.schemaListState(connectionId: connectionId, database: metadata.name)) { + Task { await service.loadSchemas(connectionId: connectionId, database: metadata.name) } + } + } else { + loadObjects(database: metadata.name, schema: nil) + } + case .schema(let database, let schema): + loadObjects(database: database, schema: schema) + case .table, .routine, .status: + break + } + } + + private func loadObjects(database: String, schema: String?) { + if isIdle(service.tablesLoadState(connectionId: connectionId, database: database, schema: schema)) { + Task { await service.loadTables(connectionId: connectionId, database: database, schema: schema) } + } + if isIdle(service.routinesLoadState(connectionId: connectionId, database: database, schema: schema)) { + Task { await service.loadRoutines(connectionId: connectionId, database: database, schema: schema) } + } + } + + private func isIdle(_ state: MetadataLoadState) -> Bool { + if case .idle = state { return true } + return false + } + + // MARK: - Selection / open + + private func selectedRefs() -> [DatabaseTreeTableRef] { + guard let outlineView else { return [] } + return outlineView.selectedRowIndexes.compactMap { + (outlineView.item(atRow: $0) as? DatabaseTreeNode)?.tableRef + } + } + + private func syncSelectionToModel() { + guard let outlineView else { return } + let rows = lastSelection.compactMap { ref -> Int? in + guard let node = nodeCache[DatabaseTreeNode.tableId(ref)] else { return nil } + let row = outlineView.row(forItem: node) + return row >= 0 ? row : nil + } + isSyncingSelection = true + outlineView.selectRowIndexes(IndexSet(rows), byExtendingSelection: false) + isSyncingSelection = false + } + + private func open(_ ref: DatabaseTreeTableRef, activateGridFocus: Bool) { + Task { @MainActor in + await activate(ref) + mainCoordinator?.openTableTab(ref.table, activateGridFocus: activateGridFocus) + } + } + + private func activate(_ ref: DatabaseTreeTableRef) async { + if ref.database != activeDatabase { + await mainCoordinator?.switchDatabase(to: ref.database) + } + if let schema = ref.schema, + schema != mainCoordinator?.toolbarState.currentSchema, + PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + await mainCoordinator?.switchSchema(to: schema) + } + } + + private func setActiveDatabase(_ database: String) { + guard database != activeDatabase else { return } + Task { await mainCoordinator?.switchDatabase(to: database) } + } + + private func setActiveSchema(database: String, schema: String) { + Task { @MainActor in + if database != activeDatabase { + await mainCoordinator?.switchDatabase(to: database) + } + if schema != mainCoordinator?.toolbarState.currentSchema { + await mainCoordinator?.switchSchema(to: schema) + } + } + } + + private func refreshDatabase(_ database: String) { + if supportsSchemaLevel { + Task { await service.refreshSchemas(connectionId: connectionId, database: database) } + } else { + Task { await service.refreshObjects(connectionId: connectionId, database: database, schema: nil) } + } + } + + private func refreshObjects(database: String, schema: String?) { + Task { await service.refreshObjects(connectionId: connectionId, database: database, schema: schema) } + } + + private func rowContext() -> DatabaseTreeRowContext { + DatabaseTreeRowContext( + activeDatabase: activeDatabase, + activeSchema: activeSchema, + systemSchemas: systemSchemas, + pendingTruncates: pendingTruncates, + pendingDeletes: pendingDeletes + ) + } + + private func rowActions() -> DatabaseTreeRowActions { + DatabaseTreeRowActions( + coordinator: mainCoordinator, + isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false, + selectedTables: { [weak self] in Set((self?.selectedRefs() ?? []).map(\.table)) }, + activate: { [weak self] ref in await self?.activate(ref) }, + setActiveDatabase: { [weak self] in self?.setActiveDatabase($0) }, + setActiveSchema: { [weak self] database, schema in self?.setActiveSchema(database: database, schema: schema) }, + refreshDatabase: { [weak self] in self?.refreshDatabase($0) }, + refreshObjects: { [weak self] database, schema in self?.refreshObjects(database: database, schema: schema) }, + showRoutineDDL: { [weak self] routine in self?.mainCoordinator?.showRoutineDDL(routine) }, + batchToggleTruncate: { [weak self] in self?.viewModel?.batchToggleTruncate(tableNames: $0) }, + batchToggleDelete: { [weak self] in self?.viewModel?.batchToggleDelete(tableNames: $0) } + ) + } + + @objc + func handleDoubleClick() { + guard let outlineView, outlineView.clickedRow >= 0, + let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode, + let ref = node.tableRef else { return } + open(ref, activateGridFocus: true) + } +} + +extension DatabaseTreeOutlineCoordinator: NSOutlineViewDataSource { + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + resolvedChildren(of: item).count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + resolvedChildren(of: item)[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + (item as? DatabaseTreeNode)?.isExpandable ?? false + } +} + +extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + guard let node = item as? DatabaseTreeNode else { return nil } + let cell = outlineView.makeView(withIdentifier: Self.cellIdentifier, owner: self) as? DatabaseTreeCellView + ?? makeCell() + cell.configure(node: node, context: rowContext(), actions: rowActions()) + return cell + } + + func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { + (item as? DatabaseTreeNode)?.tableRef != nil + } + + func outlineViewItemWillExpand(_ notification: Notification) { + guard let node = notification.userInfo?["NSObject"] as? DatabaseTreeNode else { return } + triggerLoad(for: node) + if !isApplyingExpansion { recordExpansion(node, expanded: true) } + } + + func outlineViewItemWillCollapse(_ notification: Notification) { + guard let node = notification.userInfo?["NSObject"] as? DatabaseTreeNode else { return } + if !isApplyingExpansion { recordExpansion(node, expanded: false) } + } + + func outlineViewSelectionDidChange(_ notification: Notification) { + guard !isSyncingSelection else { return } + let refs = Set(selectedRefs()) + if let added = SelectionDelta.singleAddition(old: lastSelection, new: refs) { + open(added, activateGridFocus: false) + } + lastSelection = refs + } + + private func makeCell() -> DatabaseTreeCellView { + let cell = DatabaseTreeCellView() + cell.identifier = Self.cellIdentifier + return cell + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift new file mode 100644 index 000000000..17fc7811b --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift @@ -0,0 +1,67 @@ +// +// DatabaseTreeOutlineView.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +struct DatabaseTreeOutlineView: NSViewRepresentable { + let connectionId: UUID + let databaseType: DatabaseType + let coordinator: MainContentCoordinator? + let windowState: WindowSidebarState + let sidebarState: SharedSidebarState + let viewModel: SidebarViewModel + let pendingTruncates: Set + let pendingDeletes: Set + let searchText: String + let activeDatabase: String? + let activeSchema: String? + + func makeCoordinator() -> DatabaseTreeOutlineCoordinator { + DatabaseTreeOutlineCoordinator() + } + + func makeNSView(context: Context) -> NSScrollView { + let outlineView = NSOutlineView() + outlineView.headerView = nil + outlineView.style = .sourceList + outlineView.rowSizeStyle = .default + outlineView.rowHeight = 24 + outlineView.indentationPerLevel = 14 + outlineView.allowsMultipleSelection = true + outlineView.allowsEmptySelection = true + outlineView.floatsGroupRows = false + outlineView.autosaveExpandedItems = false + outlineView.backgroundColor = .clear + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("DatabaseTreeColumn")) + column.resizingMask = .autoresizingMask + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + + outlineView.dataSource = context.coordinator + outlineView.delegate = context.coordinator + outlineView.target = context.coordinator + outlineView.doubleAction = #selector(DatabaseTreeOutlineCoordinator.handleDoubleClick) + + context.coordinator.attach(outlineView: outlineView) + context.coordinator.update(from: self) + + let scrollView = NSScrollView() + scrollView.documentView = outlineView + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + context.coordinator.update(from: self) + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift new file mode 100644 index 000000000..89f22ca03 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -0,0 +1,166 @@ +// +// DatabaseTreeRowView.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +struct DatabaseTreeRowActions { + let coordinator: MainContentCoordinator? + let isReadOnly: Bool + let selectedTables: () -> Set + let activate: (DatabaseTreeTableRef) async -> Void + let setActiveDatabase: (String) -> Void + let setActiveSchema: (_ database: String, _ schema: String) -> Void + let refreshDatabase: (String) -> Void + let refreshObjects: (_ database: String, _ schema: String?) -> Void + let showRoutineDDL: (RoutineInfo) -> Void + let batchToggleTruncate: ([String]) -> Void + let batchToggleDelete: ([String]) -> Void +} + +struct DatabaseTreeRowContext { + let activeDatabase: String? + let activeSchema: String? + let systemSchemas: Set + let pendingTruncates: Set + let pendingDeletes: Set +} + +struct DatabaseTreeRowView: View { + let node: DatabaseTreeNode + let isEmphasized: Bool + let context: DatabaseTreeRowContext + let actions: DatabaseTreeRowActions + + var body: some View { + content + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + } + + @ViewBuilder + private var content: some View { + switch node.kind { + case .database(let metadata): + databaseRow(metadata) + case .schema(let database, let schema): + schemaRow(database: database, schema: schema) + case .table(let ref): + tableRow(ref) + case .routine(let ref): + routineRow(ref) + case .status(let status): + statusRow(status) + } + } + + // MARK: - Database + + private func databaseRow(_ metadata: DatabaseMetadata) -> some View { + let name = metadata.name + let isActive = name == context.activeDatabase + return Label { + Text(name) + .fontWeight(isActive ? .bold : .regular) + } icon: { + Image(systemName: metadata.isSystemDatabase ? "gearshape" : "cylinder") + } + .lineLimit(1) + .foregroundStyle(foreground(isActive: isActive, isSystem: metadata.isSystemDatabase)) + .contextMenu { + Button(String(localized: "Use as Active Database")) { + actions.setActiveDatabase(name) + } + .disabled(isActive) + Button(String(localized: "Refresh")) { + actions.refreshDatabase(name) + } + } + } + + // MARK: - Schema + + private func schemaRow(database: String, schema: String) -> some View { + let isActive = database == context.activeDatabase && schema == context.activeSchema + return Label { + Text(schema) + .fontWeight(isActive ? .bold : .regular) + } icon: { + Image(systemName: "folder") + } + .lineLimit(1) + .foregroundStyle(foreground(isActive: isActive, isSystem: context.systemSchemas.contains(schema))) + .contextMenu { + Button(String(localized: "Use as Active Schema")) { + actions.setActiveSchema(database, schema) + } + .disabled(isActive) + Button(String(localized: "Refresh")) { + actions.refreshObjects(database, schema) + } + } + } + + // MARK: - Table + + private func tableRow(_ ref: DatabaseTreeTableRef) -> some View { + TableRow( + table: ref.table, + isPendingTruncate: context.pendingTruncates.contains(ref.table.name), + isPendingDelete: context.pendingDeletes.contains(ref.table.name) + ) + .contextMenu { + SidebarContextMenu( + clickedTable: ref.table, + selectedTables: actions.selectedTables(), + isReadOnly: actions.isReadOnly, + onBatchToggleTruncate: actions.batchToggleTruncate, + onBatchToggleDelete: actions.batchToggleDelete, + coordinator: actions.coordinator, + activateBeforeAction: { await actions.activate(ref) } + ) + } + } + + // MARK: - Routine + + private func routineRow(_ ref: DatabaseTreeRoutineRef) -> some View { + RoutineRowView(routine: ref.routine) + .contextMenu { + RoutineContextMenu(routine: ref.routine, onShowDDL: actions.showRoutineDDL) + } + } + + // MARK: - Status + + @ViewBuilder + private func statusRow(_ status: DatabaseTreeNode.Status) -> some View { + switch status { + case .loading: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(String(localized: "Loading\u{2026}")) + .font(.callout) + .foregroundStyle(.secondary) + } + case .empty: + Text(String(localized: "No items")) + .font(.callout) + .foregroundStyle(.secondary) + case .error(let message): + Label(message, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + private func foreground(isActive: Bool, isSystem: Bool) -> AnyShapeStyle { + if isEmphasized { return AnyShapeStyle(.white) } + if isActive { return AnyShapeStyle(.tint) } + if isSystem { return AnyShapeStyle(.secondary) } + return AnyShapeStyle(.primary) + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index cc84c7c73..0f0cdbb29 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -43,40 +43,6 @@ struct DatabaseTreeSchemaRef: Identifiable { } } -private enum SchemaTreeRow: Identifiable { - case loading - case empty - case error(String) - case schema(DatabaseTreeSchemaRef) - - var id: String { - switch self { - case .loading: return "status.loading" - case .empty: return "status.empty" - case .error: return "status.error" - case .schema(let ref): return "schema.\(ref.id)" - } - } -} - -private enum ObjectTreeRow: Identifiable { - case loading - case empty - case error(String) - case table(DatabaseTreeTableRef) - case routine(DatabaseTreeRoutineRef) - - var id: String { - switch self { - case .loading: return "status.loading" - case .empty: return "status.empty" - case .error: return "status.error" - case .table(let ref): return "table.\(ref.id)" - case .routine(let ref): return "routine.\(ref.id)" - } - } -} - struct DatabaseTreeView: View { @Bindable private var treeService = DatabaseTreeMetadataService.shared @@ -89,17 +55,8 @@ struct DatabaseTreeView: View { let coordinator: MainContentCoordinator? let sidebarState: SharedSidebarState - @State private var localSelection: Set = [] @State private var searchText: String = "" - private var groupingStrategy: GroupingStrategy { - PluginManager.shared.databaseGroupingStrategy(for: databaseType) - } - - private var supportsSchemaLevel: Bool { - groupingStrategy == .bySchema - } - private var activeDatabase: String? { let name = coordinator?.toolbarState.currentDatabase ?? "" return name.isEmpty ? nil : name @@ -117,19 +74,17 @@ struct DatabaseTreeView: View { isConnected ? "connected" : "down" } - private var systemSchemas: Set { - Set(PluginManager.shared.systemSchemaNames(for: databaseType)) - } - private var databases: [DatabaseMetadata] { treeService.databases(for: connectionId) } - private var selectedTablesBinding: Binding> { - Binding( - get: { localSelection }, - set: { localSelection = $0 } - ) + private var filteredDatabases: [DatabaseMetadata] { + DatabaseTreeVisibility.visible(databases: databases, selected: sidebarState.databaseFilterSelected) + } + + private var isFilterHidingEverything: Bool { + DatabaseTreeVisibility.isFiltering(selected: sidebarState.databaseFilterSelected) + && filteredDatabases.isEmpty } var body: some View { @@ -142,7 +97,7 @@ struct DatabaseTreeView: View { case .loaded where isFilterHidingEverything: filteredEmptyState case .loaded: - treeList + outline case .idle, .loading: loadingState } @@ -157,227 +112,22 @@ struct DatabaseTreeView: View { guard !Task.isCancelled else { return } searchText = live } - .onAppear { expandActive() } - .onChange(of: activeContextKey) { _, _ in expandActive() } - .onChange(of: localSelection) { oldRefs, newRefs in - guard let ref = SelectionDelta.singleAddition(old: oldRefs, new: newRefs) else { return } - openTable(ref.table, in: ref.database, schema: ref.schema) - } - } - - private var activeContextKey: String { - "\(activeDatabase ?? "")|\(activeSchema ?? "")" - } - - private var treeList: some View { - List(selection: selectedTablesBinding) { - ForEach(visibleDatabases, id: \.id) { db in - DisclosureGroup(isExpanded: databaseExpansionBinding(for: db.name)) { - databaseBody(db) - } label: { - databaseHeader(db) - } - } - } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - .contextMenu(forSelectionType: DatabaseTreeTableRef.self) { selection in - SidebarContextMenu( - clickedTable: selection.first?.table, - selectedTables: Set(selection.map(\.table)), - isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, - onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, - onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, - coordinator: coordinator, - activateBeforeAction: { await activate(selection.first) } - ) - } primaryAction: { selection in - guard let ref = selection.first else { return } - openTable(ref.table, in: ref.database, schema: ref.schema, activateGridFocus: true) - } - .onExitCommand { - localSelection.removeAll() - } - } - - @ViewBuilder - private func databaseBody(_ db: DatabaseMetadata) -> some View { - if supportsSchemaLevel { - schemasContent(for: db.name) - } else { - objectsContent(database: db.name, schema: nil) - } - } - - private func databaseHeader(_ db: DatabaseMetadata) -> some View { - let isActive = db.name == activeDatabase - return Label { - Text(db.name) - .fontWeight(isActive ? .bold : .regular) - .foregroundStyle(rowForeground(isActive: isActive, isSystem: db.isSystemDatabase)) - } icon: { - Image(systemName: db.isSystemDatabase ? "gearshape" : "cylinder") - .foregroundStyle(db.isSystemDatabase ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint)) - } - .accessibilityLabel(String(format: String(localized: "Database: %@"), db.name)) - .accessibilityAddTraits(.isHeader) - .contextMenu { - Button(String(localized: "Use as Active Database")) { - setActiveDatabase(db.name) - } - .disabled(isActive) - Button(String(localized: "Refresh")) { - Task { await treeService.refreshSchemas(connectionId: connectionId, database: db.name) } - } - } - } - - private func schemaHeader(database: String, schema: String) -> some View { - let isActive = (database == activeDatabase) && (schema == activeSchema) - let isSystem = systemSchemas.contains(schema) - return Label { - Text(schema) - .fontWeight(isActive ? .bold : .regular) - .foregroundStyle(rowForeground(isActive: isActive, isSystem: isSystem)) - } icon: { - Image(systemName: "folder") - .foregroundStyle(.tint) - } - .accessibilityLabel(String(format: String(localized: "Schema: %@"), schema)) - .accessibilityAddTraits(.isHeader) - .contextMenu { - Button(String(localized: "Use as Active Schema")) { - setActiveSchema(database: database, schema: schema) - } - .disabled(isActive) - Button(String(localized: "Refresh")) { - Task { - await treeService.refreshObjects(connectionId: connectionId, database: database, schema: schema) - } - } - } - } - - private func rowForeground(isActive: Bool, isSystem: Bool) -> AnyShapeStyle { - if isActive { return AnyShapeStyle(.tint) } - if isSystem { return AnyShapeStyle(.secondary) } - return AnyShapeStyle(.primary) - } - - @ViewBuilder - private func schemasContent(for database: String) -> some View { - Group { - ForEach(schemaRows(for: database)) { row in - schemaRow(row, database: database) - } - } - .task(id: "\(database)|\(connectionToken)") { - await treeService.loadSchemas(connectionId: connectionId, database: database) - } - } - - private func schemaRows(for database: String) -> [SchemaTreeRow] { - switch treeService.schemaListState(connectionId: connectionId, database: database) { - case .idle, .loading: - return [.loading] - case .failed(let message): - return [.error(message)] - case .loaded(let schemas): - let visible = visibleSchemas(database: database, all: schemas) - guard !visible.isEmpty else { return [.empty] } - return visible.map { .schema(DatabaseTreeSchemaRef(database: database, schema: $0)) } - } - } - - @ViewBuilder - private func schemaRow(_ row: SchemaTreeRow, database: String) -> some View { - switch row { - case .loading: - loadingRow(String(localized: "Loading schemas\u{2026}")) - case .empty: - emptyRow(String(localized: "No schemas")) - case .error(let message): - errorRow(message) - case .schema(let ref): - DisclosureGroup(isExpanded: schemaExpansionBinding(database: ref.database, schema: ref.schema)) { - objectsContent(database: ref.database, schema: ref.schema) - } label: { - schemaHeader(database: ref.database, schema: ref.schema) - } - } - } - - @ViewBuilder - private func objectsContent(database: String, schema: String?) -> some View { - Group { - ForEach(objectRows(database: database, schema: schema)) { row in - objectRow(row, database: database, schema: schema) - } - } - .task(id: "tables|\(database)|\(schema ?? "")|\(connectionToken)") { - await treeService.loadTables(connectionId: connectionId, database: database, schema: schema) - } - .task(id: "routines|\(database)|\(schema ?? "")|\(connectionToken)") { - await treeService.loadRoutines(connectionId: connectionId, database: database, schema: schema) - } - } - - private func objectRows(database: String, schema: String?) -> [ObjectTreeRow] { - switch treeService.tablesLoadState(connectionId: connectionId, database: database, schema: schema) { - case .idle, .loading: - return [.loading] - case .failed(let message): - return [.error(message)] - case .loaded: - let tables = filteredTables(database: database, schema: schema) - let routines = filteredRoutines(database: database, schema: schema) - let routinesState = treeService.routinesLoadState( - connectionId: connectionId, database: database, schema: schema - ) - guard !tables.isEmpty || !routines.isEmpty else { - switch routinesState { - case .failed(let message): return [.error(message)] - case .loaded: return [.empty] - case .idle, .loading: return [.loading] - } - } - var rows: [ObjectTreeRow] = tables.map { - .table(DatabaseTreeTableRef(database: database, schema: schema, table: $0)) - } - rows += routines.map { - .routine(DatabaseTreeRoutineRef(database: database, schema: schema, routine: $0)) - } - if case .failed(let message) = routinesState { - rows.append(.error(message)) - } - return rows - } } - @ViewBuilder - private func objectRow(_ row: ObjectTreeRow, database: String, schema: String?) -> some View { - switch row { - case .loading: - loadingRow(String(localized: "Loading\u{2026}")) - case .empty: - emptyRow(String(localized: "No items")) - case .error(let message): - errorRow(message) - case .table(let ref): - TableRow( - table: ref.table, - isPendingTruncate: pendingTruncates.contains(ref.table.name), - isPendingDelete: pendingDeletes.contains(ref.table.name) - ) - .tag(ref) - case .routine(let ref): - RoutineRowView(routine: ref.routine) - .contextMenu { - RoutineContextMenu(routine: ref.routine) { selected in - coordinator?.showRoutineDDL(selected) - } - } - } + private var outline: some View { + DatabaseTreeOutlineView( + connectionId: connectionId, + databaseType: databaseType, + coordinator: coordinator, + windowState: windowState, + sidebarState: sidebarState, + viewModel: viewModel, + pendingTruncates: pendingTruncates, + pendingDeletes: pendingDeletes, + searchText: searchText, + activeDatabase: activeDatabase, + activeSchema: activeSchema + ) } private var loadingState: some View { @@ -408,11 +158,6 @@ struct DatabaseTreeView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var isFilterHidingEverything: Bool { - DatabaseTreeVisibility.isFiltering(selected: sidebarState.databaseFilterSelected) - && filteredDatabases.isEmpty - } - private var filteredEmptyState: some View { ContentUnavailableView { Label(String(localized: "No Databases Shown"), systemImage: "line.3.horizontal.decrease.circle") @@ -425,180 +170,4 @@ struct DatabaseTreeView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - - private func loadingRow(_ text: String) -> some View { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text(text) - .font(.callout) - .foregroundStyle(.secondary) - } - } - - private func errorRow(_ message: String) -> some View { - Label(message, systemImage: "exclamationmark.triangle") - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(2) - } - - private func emptyRow(_ text: String) -> some View { - Text(text) - .font(.callout) - .foregroundStyle(.secondary) - } - - // MARK: - Selection actions - - @MainActor - private func activate(_ ref: DatabaseTreeTableRef?) async { - guard let ref else { return } - if ref.database != activeDatabase { - await coordinator?.switchDatabase(to: ref.database) - } - if let schema = ref.schema, - schema != coordinator?.toolbarState.currentSchema, - PluginManager.shared.supportsSchemaSwitching(for: databaseType) { - await coordinator?.switchSchema(to: schema) - } - } - - private func setActiveDatabase(_ database: String) { - guard database != activeDatabase else { return } - Task { await coordinator?.switchDatabase(to: database) } - } - - private func setActiveSchema(database: String, schema: String) { - Task { - if database != activeDatabase { - await coordinator?.switchDatabase(to: database) - } - if schema != coordinator?.toolbarState.currentSchema { - await coordinator?.switchSchema(to: schema) - } - } - } - - private func openTable(_ table: TableInfo, in database: String, schema: String?, activateGridFocus: Bool = false) { - Task { @MainActor in - if database != activeDatabase { - await coordinator?.switchDatabase(to: database) - } - if let schema, - schema != coordinator?.toolbarState.currentSchema, - PluginManager.shared.supportsSchemaSwitching(for: databaseType) { - await coordinator?.switchSchema(to: schema) - } - coordinator?.openTableTab(table, activateGridFocus: activateGridFocus) - } - } - - private func expandActive() { - guard let active = activeDatabase else { return } - windowState.expandedTreeDatabases.insert(active) - if let schema = activeSchema { - windowState.expandedTreeDatabaseSchemas.insert( - DatabaseSchemaKey(database: active, schema: schema) - ) - } - } - - // MARK: - Expansion - - private func databaseExpansionBinding(for database: String) -> Binding { - Binding( - get: { !searchText.isEmpty || windowState.expandedTreeDatabases.contains(database) }, - set: { isExpanded in - if isExpanded { - windowState.expandedTreeDatabases.insert(database) - } else { - windowState.expandedTreeDatabases.remove(database) - } - } - ) - } - - private func schemaExpansionBinding(database: String, schema: String) -> Binding { - let key = DatabaseSchemaKey(database: database, schema: schema) - return Binding( - get: { !searchText.isEmpty || windowState.expandedTreeDatabaseSchemas.contains(key) }, - set: { isExpanded in - if isExpanded { - windowState.expandedTreeDatabaseSchemas.insert(key) - } else { - windowState.expandedTreeDatabaseSchemas.remove(key) - } - } - ) - } - - // MARK: - Search filtering - - private func tables(database: String, schema: String?) -> [TableInfo] { - treeService.tables(connectionId: connectionId, database: database, schema: schema) - } - - private func routines(database: String, schema: String?) -> [RoutineInfo] { - treeService.routines(connectionId: connectionId, database: database, schema: schema) - } - - private var filteredDatabases: [DatabaseMetadata] { - DatabaseTreeVisibility.visible(databases: databases, selected: sidebarState.databaseFilterSelected) - } - - private var visibleDatabases: [DatabaseMetadata] { - let matched = searchText.isEmpty ? filteredDatabases : filteredDatabases.filter { databaseMatchesSearch($0) } - var seen = Set() - return matched.filter { seen.insert($0.id).inserted } - } - - private func databaseMatchesSearch(_ db: DatabaseMetadata) -> Bool { - if FuzzyMatcher.matches(query: searchText, candidate: db.name) { return true } - if case .loaded(let list) = treeService.schemaListState(connectionId: connectionId, database: db.name) { - if list.contains(where: { FuzzyMatcher.matches(query: searchText, candidate: $0) }) { return true } - for schema in list where schemaContentMatchesSearch(database: db.name, schema: schema) { - return true - } - } - return schemaContentMatchesSearch(database: db.name, schema: nil) - } - - private func schemaContentMatchesSearch(database: String, schema: String?) -> Bool { - if let schema, FuzzyMatcher.matches(query: searchText, candidate: schema) { return true } - if tables(database: database, schema: schema).contains(where: { FuzzyMatcher.matches(query: searchText, candidate: $0.name) }) { - return true - } - return routines(database: database, schema: schema).contains { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } - } - - private func visibleSchemas(database: String, all: [String]) -> [String] { - let nonSystem = all.filter { !systemSchemas.contains($0) } - let matched = searchText.isEmpty - ? nonSystem - : nonSystem.filter { schema in - FuzzyMatcher.matches(query: searchText, candidate: schema) - || schemaContentMatchesSearch(database: database, schema: schema) - } - var seen = Set() - return matched.filter { seen.insert($0).inserted } - } - - private func filteredTables(database: String, schema: String?) -> [TableInfo] { - let all = tables(database: database, schema: schema) - let matched = searchText.isEmpty - ? all - : all.filter { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } - var seen = Set() - return matched.filter { seen.insert($0.id).inserted } - } - - private func filteredRoutines(database: String, schema: String?) -> [RoutineInfo] { - let all = routines(database: database, schema: schema) - let matched = searchText.isEmpty - ? all - : all.filter { FuzzyMatcher.matches(query: searchText, candidate: $0.name) } - var seen = Set() - return matched.filter { seen.insert($0.id).inserted } - } } diff --git a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift index 853ab846d..c9bd4b4ac 100644 --- a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift +++ b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @Suite("DatabaseTreeMetadataService") +@MainActor struct DatabaseTreeMetadataServiceTests { private typealias ObjectsKey = DatabaseTreeMetadataService.ObjectsKey diff --git a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift new file mode 100644 index 000000000..91e3c36f4 --- /dev/null +++ b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift @@ -0,0 +1,66 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DatabaseTreeFilter") +struct DatabaseTreeFilterTests { + private func table(_ name: String) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: 0) + } + + private func routine(_ name: String) -> RoutineInfo { + RoutineInfo(name: name, schema: "public", kind: .function, signature: nil) + } + + @Test("filteredTables returns every table and deduplicates when search is empty") + func filteredTablesNoSearch() { + let tables = [table("users"), table("orders"), table("users")] + let result = DatabaseTreeFilter.filteredTables(tables, searchText: "") + #expect(result.map(\.name) == ["users", "orders"]) + } + + @Test("filteredTables keeps only fuzzy matches when searching") + func filteredTablesSearch() { + let tables = [table("users"), table("orders"), table("invoices")] + let result = DatabaseTreeFilter.filteredTables(tables, searchText: "ord") + #expect(result.map(\.name) == ["orders"]) + } + + @Test("filteredRoutines deduplicates and fuzzy matches") + func filteredRoutinesSearch() { + let routines = [routine("calc_total"), routine("audit_log"), routine("calc_total")] + #expect(DatabaseTreeFilter.filteredRoutines(routines, searchText: "").count == 2) + #expect(DatabaseTreeFilter.filteredRoutines(routines, searchText: "audit").map(\.name) == ["audit_log"]) + } + + @Test("visibleSchemas drops system schemas and deduplicates") + func visibleSchemasNoSearch() { + let schemas = ["public", "pg_catalog", "public", "sales"] + let result = DatabaseTreeFilter.visibleSchemas( + schemas, + systemSchemas: ["pg_catalog"], + searchText: "", + contentMatches: { _ in false } + ) + #expect(result == ["public", "sales"]) + } + + @Test("visibleSchemas keeps a schema when its content matches even if the name does not") + func visibleSchemasContentMatch() { + let schemas = ["public", "sales"] + let result = DatabaseTreeFilter.visibleSchemas( + schemas, + systemSchemas: [], + searchText: "invoice", + contentMatches: { $0 == "sales" } + ) + #expect(result == ["sales"]) + } + + @Test("matches is a fuzzy subsequence test") + func matchesFuzzy() { + #expect(DatabaseTreeFilter.matches("usr", "users")) + #expect(!DatabaseTreeFilter.matches("zzz", "users")) + } +} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift new file mode 100644 index 000000000..f16ae6a2a --- /dev/null +++ b/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DatabaseTreeNode") +struct DatabaseTreeNodeTests { + private func tableRef(_ name: String, schema: String? = "public") -> DatabaseTreeTableRef { + DatabaseTreeTableRef(database: "shop", schema: schema, table: TableInfo(name: name, type: .table, rowCount: 0)) + } + + @Test("identity helpers are unique across kinds and stable") + func identityHelpers() { + let databaseId = DatabaseTreeNode.databaseId("shop") + let schemaId = DatabaseTreeNode.schemaId(database: "shop", schema: "public") + let tableId = DatabaseTreeNode.tableId(tableRef("users")) + + #expect(databaseId == DatabaseTreeNode.databaseId("shop")) + #expect(Set([databaseId, schemaId, tableId]).count == 3) + } + + @Test("status ids are unique per parent and per status") + func statusIds() { + let loading = DatabaseTreeNode.statusId(parentId: "db\u{1}shop", status: .loading) + let empty = DatabaseTreeNode.statusId(parentId: "db\u{1}shop", status: .empty) + let errored = DatabaseTreeNode.statusId(parentId: "db\u{1}shop", status: .error("x")) + let otherParent = DatabaseTreeNode.statusId(parentId: "db\u{1}other", status: .loading) + + #expect(Set([loading, empty, errored, otherParent]).count == 4) + } + + @Test("only database and schema nodes are expandable") + func expandable() { + let database = DatabaseTreeNode(id: "d", kind: .database(.minimal(name: "shop"))) + let schema = DatabaseTreeNode(id: "s", kind: .schema(database: "shop", schema: "public")) + let table = DatabaseTreeNode(id: "t", kind: .table(tableRef("users"))) + let status = DatabaseTreeNode(id: "x", kind: .status(.loading)) + + #expect(database.isExpandable) + #expect(schema.isExpandable) + #expect(!table.isExpandable) + #expect(!status.isExpandable) + } + + @Test("tableRef is returned only for table nodes") + func tableRefExtraction() { + let ref = tableRef("users") + let table = DatabaseTreeNode(id: "t", kind: .table(ref)) + let schema = DatabaseTreeNode(id: "s", kind: .schema(database: "shop", schema: "public")) + + #expect(table.tableRef == ref) + #expect(schema.tableRef == nil) + } +} From d6f637c4f5e7804e4a4d70aadf62f78fb6cc215a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 19:46:59 +0700 Subject: [PATCH 2/4] fix(sidebar): open the tree context menu across the full row and expand on double-click --- .../DatabaseTreeOutlineCoordinator.swift | 14 +- .../Views/Sidebar/DatabaseTreeRowView.swift | 157 +++++++++--------- TablePro/Views/Sidebar/DatabaseTreeView.swift | 9 - 3 files changed, 88 insertions(+), 92 deletions(-) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 619e5d138..e750a337b 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -461,9 +461,17 @@ final class DatabaseTreeOutlineCoordinator: NSObject { @objc func handleDoubleClick() { guard let outlineView, outlineView.clickedRow >= 0, - let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode, - let ref = node.tableRef else { return } - open(ref, activateGridFocus: true) + let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode else { return } + if let ref = node.tableRef { + open(ref, activateGridFocus: true) + return + } + guard node.isExpandable else { return } + if outlineView.isItemExpanded(node) { + outlineView.collapseItem(node) + } else { + outlineView.expandItem(node) + } } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 89f22ca03..0899f115d 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -35,106 +35,62 @@ struct DatabaseTreeRowView: View { let actions: DatabaseTreeRowActions var body: some View { - content + if hasContextMenu { + row.contextMenu { menuItems } + } else { + row + } + } + + private var row: some View { + rowContent .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .contentShape(Rectangle()) } @ViewBuilder - private var content: some View { + private var rowContent: some View { switch node.kind { case .database(let metadata): - databaseRow(metadata) + header( + text: metadata.name, + systemImage: metadata.isSystemDatabase ? "gearshape" : "cylinder", + isActive: metadata.name == context.activeDatabase, + isSystem: metadata.isSystemDatabase + ) case .schema(let database, let schema): - schemaRow(database: database, schema: schema) + header( + text: schema, + systemImage: "folder", + isActive: database == context.activeDatabase && schema == context.activeSchema, + isSystem: context.systemSchemas.contains(schema) + ) case .table(let ref): - tableRow(ref) + TableRow( + table: ref.table, + isPendingTruncate: context.pendingTruncates.contains(ref.table.name), + isPendingDelete: context.pendingDeletes.contains(ref.table.name) + ) + .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) case .routine(let ref): - routineRow(ref) + RoutineRowView(routine: ref.routine) + .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) case .status(let status): statusRow(status) } } - // MARK: - Database - - private func databaseRow(_ metadata: DatabaseMetadata) -> some View { - let name = metadata.name - let isActive = name == context.activeDatabase - return Label { - Text(name) - .fontWeight(isActive ? .bold : .regular) - } icon: { - Image(systemName: metadata.isSystemDatabase ? "gearshape" : "cylinder") - } - .lineLimit(1) - .foregroundStyle(foreground(isActive: isActive, isSystem: metadata.isSystemDatabase)) - .contextMenu { - Button(String(localized: "Use as Active Database")) { - actions.setActiveDatabase(name) - } - .disabled(isActive) - Button(String(localized: "Refresh")) { - actions.refreshDatabase(name) - } - } - } - - // MARK: - Schema - - private func schemaRow(database: String, schema: String) -> some View { - let isActive = database == context.activeDatabase && schema == context.activeSchema - return Label { - Text(schema) + private func header(text: String, systemImage: String, isActive: Bool, isSystem: Bool) -> some View { + Label { + Text(text) .fontWeight(isActive ? .bold : .regular) } icon: { - Image(systemName: "folder") + Image(systemName: systemImage) } .lineLimit(1) - .foregroundStyle(foreground(isActive: isActive, isSystem: context.systemSchemas.contains(schema))) - .contextMenu { - Button(String(localized: "Use as Active Schema")) { - actions.setActiveSchema(database, schema) - } - .disabled(isActive) - Button(String(localized: "Refresh")) { - actions.refreshObjects(database, schema) - } - } - } - - // MARK: - Table - - private func tableRow(_ ref: DatabaseTreeTableRef) -> some View { - TableRow( - table: ref.table, - isPendingTruncate: context.pendingTruncates.contains(ref.table.name), - isPendingDelete: context.pendingDeletes.contains(ref.table.name) - ) - .contextMenu { - SidebarContextMenu( - clickedTable: ref.table, - selectedTables: actions.selectedTables(), - isReadOnly: actions.isReadOnly, - onBatchToggleTruncate: actions.batchToggleTruncate, - onBatchToggleDelete: actions.batchToggleDelete, - coordinator: actions.coordinator, - activateBeforeAction: { await actions.activate(ref) } - ) - } - } - - // MARK: - Routine - - private func routineRow(_ ref: DatabaseTreeRoutineRef) -> some View { - RoutineRowView(routine: ref.routine) - .contextMenu { - RoutineContextMenu(routine: ref.routine, onShowDDL: actions.showRoutineDDL) - } + .foregroundStyle(foreground(isActive: isActive, isSystem: isSystem)) } - // MARK: - Status - @ViewBuilder private func statusRow(_ status: DatabaseTreeNode.Status) -> some View { switch status { @@ -157,6 +113,47 @@ struct DatabaseTreeRowView: View { } } + private var hasContextMenu: Bool { + if case .status = node.kind { return false } + return true + } + + @ViewBuilder + private var menuItems: some View { + switch node.kind { + case .database(let metadata): + Button(String(localized: "Use as Active Database")) { + actions.setActiveDatabase(metadata.name) + } + .disabled(metadata.name == context.activeDatabase) + Button(String(localized: "Refresh")) { + actions.refreshDatabase(metadata.name) + } + case .schema(let database, let schema): + Button(String(localized: "Use as Active Schema")) { + actions.setActiveSchema(database, schema) + } + .disabled(database == context.activeDatabase && schema == context.activeSchema) + Button(String(localized: "Refresh")) { + actions.refreshObjects(database, schema) + } + case .table(let ref): + SidebarContextMenu( + clickedTable: ref.table, + selectedTables: actions.selectedTables(), + isReadOnly: actions.isReadOnly, + onBatchToggleTruncate: actions.batchToggleTruncate, + onBatchToggleDelete: actions.batchToggleDelete, + coordinator: actions.coordinator, + activateBeforeAction: { await actions.activate(ref) } + ) + case .routine(let ref): + RoutineContextMenu(routine: ref.routine, onShowDDL: actions.showRoutineDDL) + case .status: + EmptyView() + } + } + private func foreground(isActive: Bool, isSystem: Bool) -> AnyShapeStyle { if isEmphasized { return AnyShapeStyle(.white) } if isActive { return AnyShapeStyle(.tint) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 0f0cdbb29..7715620ba 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -34,15 +34,6 @@ struct DatabaseTreeRoutineRef: Identifiable { } } -struct DatabaseTreeSchemaRef: Identifiable { - let database: String - let schema: String - - var id: String { - "\(database)|\(schema)" - } -} - struct DatabaseTreeView: View { @Bindable private var treeService = DatabaseTreeMetadataService.shared From 36e3fcbdd4c07951ba8407548fbd30e814338e71 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 19:54:21 +0700 Subject: [PATCH 3/4] fix(sidebar): reload tree contents for expanded nodes after reconnect --- .../Views/Sidebar/DatabaseTreeOutlineCoordinator.swift | 10 +++++++++- TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift | 1 + TablePro/Views/Sidebar/DatabaseTreeView.swift | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index e750a337b..ba90ce2d4 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -20,6 +20,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var sidebarState: SharedSidebarState? private weak var viewModel: SidebarViewModel? private var searchText = "" + private var connectionToken = "" private var activeDatabase: String? private var activeSchema: String? private var pendingTruncates: Set = [] @@ -58,11 +59,13 @@ final class DatabaseTreeOutlineCoordinator: NSObject { let activeChanged = activeDatabase != view.activeDatabase || activeSchema != view.activeSchema let changed = searchText != view.searchText + || connectionToken != view.connectionToken || activeChanged || pendingTruncates != view.pendingTruncates || pendingDeletes != view.pendingDeletes searchText = view.searchText + connectionToken = view.connectionToken activeDatabase = view.activeDatabase activeSchema = view.activeSchema pendingTruncates = view.pendingTruncates @@ -294,13 +297,18 @@ final class DatabaseTreeOutlineCoordinator: NSObject { ? databaseMatchesSearch(metadata) : windowState?.expandedTreeDatabases.contains(metadata.name) ?? false setExpanded(databaseNode, want) - guard supportsSchemaLevel, outlineView.isItemExpanded(databaseNode) else { continue } + guard outlineView.isItemExpanded(databaseNode) else { continue } + triggerLoad(for: databaseNode) + guard supportsSchemaLevel else { continue } for schemaNode in resolvedChildren(of: databaseNode) { guard case .schema(let database, let schema) = schemaNode.kind else { continue } let wantSchema = searching ? DatabaseTreeFilter.matches(searchText, schema) || schemaContentMatchesSearch(database: database, schema: schema) : windowState?.expandedTreeDatabaseSchemas.contains(DatabaseSchemaKey(database: database, schema: schema)) ?? false setExpanded(schemaNode, wantSchema) + if outlineView.isItemExpanded(schemaNode) { + triggerLoad(for: schemaNode) + } } } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift index 17fc7811b..10a52020f 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift @@ -17,6 +17,7 @@ struct DatabaseTreeOutlineView: NSViewRepresentable { let pendingTruncates: Set let pendingDeletes: Set let searchText: String + let connectionToken: String let activeDatabase: String? let activeSchema: String? diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 7715620ba..5f69e7426 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -116,6 +116,7 @@ struct DatabaseTreeView: View { pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, searchText: searchText, + connectionToken: connectionToken, activeDatabase: activeDatabase, activeSchema: activeSchema ) From 54be79542ad113a78dff2294fca793a75c87e7fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 18 Jun 2026 20:03:39 +0700 Subject: [PATCH 4/4] fix(sidebar): keep tree selection across reconciles and re-arm observation once per refresh --- .../Views/Sidebar/DatabaseTreeOutlineCoordinator.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index ba90ce2d4..4575cf768 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -31,6 +31,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var lastSelection: Set = [] private var isApplyingExpansion = false private var isSyncingSelection = false + private var isReloading = false private var hasRenderedOnce = false private var reconcileScheduled = false private var observationGeneration = 0 @@ -78,10 +79,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { if !hasRenderedOnce { hasRenderedOnce = true refresh() - beginObserving() } else if changed { refresh() - beginObserving() } } @@ -119,7 +118,6 @@ final class DatabaseTreeOutlineCoordinator: NSObject { Task { @MainActor in self.reconcileScheduled = false self.refresh() - self.beginObserving() } } @@ -142,10 +140,13 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func refresh() { guard let outlineView else { return } + isReloading = true childrenCache.removeAll() outlineView.reloadData() applyDesiredExpansion() syncSelectionToModel() + isReloading = false + beginObserving() } // MARK: - Node building @@ -522,7 +523,7 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { } func outlineViewSelectionDidChange(_ notification: Notification) { - guard !isSyncingSelection else { return } + guard !isSyncingSelection, !isReloading else { return } let refs = Set(selectedRefs()) if let added = SelectionDelta.singleAddition(old: lastSelection, new: refs) { open(added, activateGridFocus: false)