diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6a0a343..dc4830825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Health monitor now detects stuck queries beyond the configured timeout - SSH tunnel closure errors now logged instead of silently discarded - Schema/database restore errors during reconnect now logged +- Memory not released after closing tabs +- New tabs opening as separate windows instead of joining the connection tab group +- Clicking tables in sidebar not opening table tabs ## [0.23.1] - 2026-03-24 diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index fb95c81c1..22fbdc795 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -290,9 +290,31 @@ public class TextViewController: NSViewController { self.gutterView.setNeedsDisplay(self.gutterView.frame) } + /// Release heavy resources (tree-sitter, highlighter, text storage) early, + /// without waiting for deinit. Call when the editor is no longer visible but + /// SwiftUI may keep the controller alive in @State. + public func releaseHeavyState() { + if let highlighter { + textView?.removeStorageDelegate(highlighter) + } + highlighter = nil + treeSitterClient = nil + highlightProviders.removeAll() + // Don't call textCoordinators.destroy() here — the caller (coordinator.destroy()) + // is already a coordinator, so calling back into destroy() causes infinite recursion. + textCoordinators.removeAll() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + } + localEventMonitor = nil + textView?.setText("") + } + deinit { if let highlighter { - textView.removeStorageDelegate(highlighter) + textView?.removeStorageDelegate(highlighter) } highlighter = nil highlightProviders.removeAll() diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index e575a6b99..f443d2bfd 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -107,15 +107,18 @@ extension AppDelegate { } func isMainWindow(_ window: NSWindow) -> Bool { - window.identifier?.rawValue == WindowId.main + guard let rawValue = window.identifier?.rawValue else { return false } + return rawValue == WindowId.main || rawValue.hasPrefix("\(WindowId.main)-") } func isWelcomeWindow(_ window: NSWindow) -> Bool { - window.identifier?.rawValue == WindowId.welcome + guard let rawValue = window.identifier?.rawValue else { return false } + return rawValue == WindowId.welcome || rawValue.hasPrefix("\(WindowId.welcome)-") } private func isConnectionFormWindow(_ window: NSWindow) -> Bool { - window.identifier?.rawValue == WindowId.connectionForm + guard let rawValue = window.identifier?.rawValue else { return false } + return rawValue == WindowId.connectionForm || rawValue.hasPrefix("\(WindowId.connectionForm)-") } // MARK: - Welcome Window @@ -239,15 +242,26 @@ extension AppDelegate { let existingIdentifier = NSApp.windows .first { $0 !== window && isMainWindow($0) && $0.isVisible }? .tabbingIdentifier - window.tabbingIdentifier = TabbingIdentifierResolver.resolve( + let resolvedIdentifier = TabbingIdentifierResolver.resolve( pendingConnectionId: pendingId, existingIdentifier: existingIdentifier ) + window.tabbingIdentifier = resolvedIdentifier configuredWindows.insert(windowId) if !NSWindow.allowsAutomaticWindowTabbing { NSWindow.allowsAutomaticWindowTabbing = true } + + // Explicitly attach to existing tab group — automatic tabbing + // doesn't work when tabbingIdentifier is set after window creation. + if let existingWindow = NSApp.windows.first(where: { + $0 !== window && isMainWindow($0) && $0.isVisible + && $0.tabbingIdentifier == resolvedIdentifier + }) { + existingWindow.addTabbedWindow(window, ordered: .above) + window.makeKeyAndOrderFront(nil) + } } } @@ -317,8 +331,8 @@ extension AppDelegate { } func closeRestoredMainWindows() { - DispatchQueue.main.async { - for window in NSApp.windows where window.identifier?.rawValue.contains("main") == true { + DispatchQueue.main.async { [weak self] in + for window in NSApp.windows where self?.isMainWindow(window) == true { window.close() } } diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 9ee562e62..1bbf967f6 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -114,7 +114,8 @@ struct ContentView: View { // Match by checking if the window is registered for our connectionId // in WindowLifecycleMonitor (subtitle may not be set yet on first appear). guard let notificationWindow = notification.object as? NSWindow, - notificationWindow.identifier?.rawValue.contains("main") == true, + let windowId = notificationWindow.identifier?.rawValue, + windowId == "main" || windowId.hasPrefix("main-"), let connectionId = payload?.connectionId else { return } diff --git a/TablePro/Extensions/NSApplication+WindowManagement.swift b/TablePro/Extensions/NSApplication+WindowManagement.swift index 96c16f0ba..fd201eb4d 100644 --- a/TablePro/Extensions/NSApplication+WindowManagement.swift +++ b/TablePro/Extensions/NSApplication+WindowManagement.swift @@ -10,12 +10,14 @@ import AppKit extension NSApplication { - /// Close all windows whose identifier contains the given ID. - /// Legacy workaround from when the minimum was macOS 13. Now that macOS 14+ is the minimum, - /// callers could use SwiftUI's `dismissWindow(id:)` instead. + /// Close all windows whose identifier matches the given ID (exact or SwiftUI-suffixed). + /// SwiftUI appends "-AppWindow-N" to WindowGroup IDs, so we match by prefix. func closeWindows(withId id: String) { - for window in windows where window.identifier?.rawValue.contains(id) == true { - window.close() + for window in windows { + guard let rawValue = window.identifier?.rawValue else { continue } + if rawValue == id || rawValue.hasPrefix("\(id)-") { + window.close() + } } } } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 5963cac2a..a58b187b4 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -7,6 +7,7 @@ import Foundation import Observation +import os import TableProPluginKit /// Type of tab @@ -269,6 +270,13 @@ final class RowBuffer { self.rows = newRows isEvicted = false } + + deinit { + #if DEBUG + Logger(subsystem: "com.TablePro", category: "RowBuffer") + .debug("RowBuffer deallocated — columns: \(self.columns.count), evicted: \(self.isEvicted)") + #endif + } } /// Represents a single tab (query or table) @@ -676,4 +684,11 @@ final class QueryTabManager { tabs[index] = tab } } + + deinit { + #if DEBUG + Logger(subsystem: "com.TablePro", category: "QueryTabManager") + .debug("QueryTabManager deallocated") + #endif + } } diff --git a/TablePro/Models/Query/RowProvider.swift b/TablePro/Models/Query/RowProvider.swift index ed91e419c..2a81e1a1d 100644 --- a/TablePro/Models/Query/RowProvider.swift +++ b/TablePro/Models/Query/RowProvider.swift @@ -66,7 +66,12 @@ final class TableRowData { /// Direct-access methods `value(atRow:column:)` and `rowValues(at:)` avoid /// heap allocations by reading straight from the source `[String?]` array. final class InMemoryRowProvider: RowProvider { - private let rowBuffer: RowBuffer + private weak var rowBuffer: RowBuffer? + /// Strong reference only when the provider created its own buffer (convenience init). + /// External buffers are owned by QueryTab, so we hold them weakly. + private var ownedBuffer: RowBuffer? + private static let emptyBuffer = RowBuffer() + private var safeBuffer: RowBuffer { rowBuffer ?? Self.emptyBuffer } private var sortIndices: [Int]? private var appendedRows: [[String?]] = [] private(set) var columns: [String] @@ -86,7 +91,7 @@ final class InMemoryRowProvider: RowProvider { /// Number of rows coming from the buffer (respecting sort indices count when present) private var bufferRowCount: Int { - sortIndices?.count ?? rowBuffer.rows.count + sortIndices?.count ?? safeBuffer.rows.count } init( @@ -130,6 +135,7 @@ final class InMemoryRowProvider: RowProvider { columnEnumValues: columnEnumValues, columnNullable: columnNullable ) + ownedBuffer = buffer } func fetchRows(offset: Int, limit: Int) -> [TableRowData] { @@ -157,7 +163,8 @@ final class InMemoryRowProvider: RowProvider { guard rowIndex < totalRowCount else { return } let sourceIndex = resolveSourceIndex(rowIndex) if let bufferIdx = sourceIndex.bufferIndex { - rowBuffer.rows[bufferIdx][columnIndex] = value + guard let buffer = rowBuffer else { return } + buffer.rows[bufferIdx][columnIndex] = value displayCache.removeValue(forKey: bufferIdx) } else if let appendedIdx = sourceIndex.appendedIndex { appendedRows[appendedIdx][columnIndex] = value @@ -215,9 +222,18 @@ final class InMemoryRowProvider: RowProvider { displayCache.removeAll() } + /// Release cached data to free memory when this provider is no longer active. + func releaseData() { + displayCache.removeAll() + appendedRows.removeAll() + sortIndices = nil + ownedBuffer = nil + } + /// Update rows by replacing the buffer contents and clearing appended rows func updateRows(_ newRows: [[String?]]) { - rowBuffer.rows = newRows + guard let buffer = rowBuffer else { return } + buffer.rows = newRows appendedRows.removeAll() sortIndices = nil displayCache.removeAll() @@ -240,9 +256,10 @@ final class InMemoryRowProvider: RowProvider { guard appendedIdx < appendedRows.count else { return } appendedRows.remove(at: appendedIdx) } else { + guard let buffer = rowBuffer else { return } if let sorted = sortIndices { let bufferIdx = sorted[index] - rowBuffer.rows.remove(at: bufferIdx) + buffer.rows.remove(at: bufferIdx) var newIndices = sorted newIndices.remove(at: index) for i in newIndices.indices where newIndices[i] > bufferIdx { @@ -250,7 +267,7 @@ final class InMemoryRowProvider: RowProvider { } sortIndices = newIndices } else { - rowBuffer.rows.remove(at: index) + buffer.rows.remove(at: index) } } displayCache.removeAll() @@ -297,9 +314,9 @@ final class InMemoryRowProvider: RowProvider { return appendedRows[displayIndex - bCount] } if let sorted = sortIndices { - return rowBuffer.rows[sorted[displayIndex]] + return safeBuffer.rows[sorted[displayIndex]] } - return rowBuffer.rows[displayIndex] + return safeBuffer.rows[displayIndex] } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 569d2ca3c..43d9993db 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -27181,6 +27181,9 @@ } } } + }, + "SSH Connection Test Failed" : { + }, "SSH connection timed out" : { "localizations" : { diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 53cb569d1..e19cfdb64 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -146,7 +146,22 @@ final class SQLEditorCoordinator: TextViewCoordinator { inlineSuggestionManager?.uninstall() inlineSuggestionManager = nil + // Release closure captures to break potential retain cycles + onCloseTab = nil + onExecuteQuery = nil + onAIExplain = nil + onAIOptimize = nil + onSaveAsFavorite = nil + schemaProvider = nil + contextMenu = nil + vimEngine = nil + vimCursorManager = nil + + // Release editor controller heavy state + controller?.releaseHeavyState() + EditorEventRouter.shared.unregister(self) + Self.logger.debug("SQLEditorCoordinator destroyed") cleanupMonitors() } diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 07bdd4acc..c1e727931 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -126,6 +126,7 @@ struct SQLEditorView: View { .onDisappear { teardownFavoritesObserver() coordinator.destroy() + completionAdapter = nil } .onChange(of: coordinator.vimMode) { _, newMode in vimMode = newMode diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 32a4910ed..a6aa331ef 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -147,6 +147,11 @@ struct MainEditorContentView: View { if let tab = tabManager.selectedTab { cacheRowProvider(for: tab) } + coordinator.onTeardown = { [self] in + tabProviderCache.removeAll() + sortCache.removeAll() + cachedChangeManager = nil + } } .onChange(of: tabManager.selectedTab?.resultVersion) { _, newVersion in guard let tab = tabManager.selectedTab, newVersion != nil else { return } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3698cd4dc..6ceb445e5 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -46,6 +46,10 @@ enum ActiveSheet: Identifiable { final class MainContentCoordinator { static let logger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator") + /// Posted during teardown so DataGridView coordinators can release cell views. + /// Object is the connection UUID. + static let teardownNotification = Notification.Name("MainContentCoordinator.teardown") + // MARK: - Dependencies let connection: DatabaseConnection @@ -125,6 +129,9 @@ final class MainContentCoordinator { /// (e.g. save-then-close). Set before calling `saveChanges`, resumed by `executeCommitStatements`. @ObservationIgnored internal var saveCompletionContinuation: CheckedContinuation? + /// Called during teardown to let the view layer release cached row providers and sort data. + @ObservationIgnored var onTeardown: (() -> Void)? + /// True while a database switch is in progress. Guards against /// side-effect window creation during the switch cascade. var isSwitchingDatabase = false @@ -338,6 +345,7 @@ final class MainContentCoordinator { /// synchronously on MainActor so we don't depend on deinit + Task scheduling. func teardown() { _didTeardown.withLock { $0 = true } + unregisterFromPersistence() for observer in urlFilterObservers { NotificationCenter.default.removeObserver(observer) @@ -360,15 +368,39 @@ final class MainContentCoordinator { for task in activeSortTasks.values { task.cancel() } activeSortTasks.removeAll() + // Let the view layer release cached row providers before we drop RowBuffers. + // Called synchronously here because SwiftUI onChange handlers don't fire + // reliably on disappearing views. + onTeardown?() + onTeardown = nil + + // Notify DataGridView coordinators to release NSTableView cell views + NotificationCenter.default.post( + name: Self.teardownNotification, + object: connection.id + ) + // Release heavy data so memory drops even if SwiftUI delays deallocation for tab in tabManager.tabs { tab.rowBuffer.evict() } querySortCache.removeAll() + cachedTableColumnTypes.removeAll() + cachedTableColumnNames.removeAll() tabManager.tabs.removeAll() tabManager.selectedTabId = nil + // Release change manager state — pluginDriver holds a strong reference + // to the entire database driver which prevents deallocation + changeManager.clearChanges() + changeManager.pluginDriver = nil + + // Release metadata and filter state + tableMetadata = nil + filterStateManager.filters.removeAll() + filterStateManager.appliedFilters.removeAll() + SchemaProviderRegistry.shared.release(for: connection.id) SchemaProviderRegistry.shared.purgeUnused() } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index b9742f119..90d66f6e6 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -253,6 +253,11 @@ struct MainContentView: View { // Only handleTabsChange(count=0) clears state (user explicitly closed all tabs). guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { return } await DatabaseManager.shared.disconnectSession(connectionId) + + // Give SwiftUI/AppKit time to deallocate view hierarchies, + // then hint malloc to return freed pages to the OS + try? await Task.sleep(for: .seconds(2)) + malloc_zone_pressure_relief(nil, 0) } } .onChange(of: pendingChangeTrigger) { @@ -595,6 +600,7 @@ struct MainContentView: View { isPreview: isPreview ) viewWindow = window + isKeyWindow = window.isKeyWindow // Update command actions window reference now that it's available commandActions?.window = window diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index f63ff6aab..790ce3921 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -166,6 +166,9 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView + if let connectionId { + context.coordinator.observeTeardown(connectionId: connectionId) + } return scrollView } @@ -632,6 +635,7 @@ struct DataGridView: NSViewRepresentable { NotificationCenter.default.removeObserver(observer) coordinator.themeObserver = nil } + coordinator.rowProvider = InMemoryRowProvider(rows: [], columns: []) } func makeCoordinator() -> TableViewCoordinator { @@ -839,6 +843,51 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } + /// Subscribe to coordinator teardown to release NSTableView cell views. + func observeTeardown(connectionId: UUID) { + teardownObserver = NotificationCenter.default.addObserver( + forName: MainContentCoordinator.teardownNotification, + object: connectionId, + queue: .main + ) { [weak self] _ in + self?.releaseData() + } + } + + /// Release all data and cell views from the NSTableView. + /// Called during coordinator teardown to free memory while SwiftUI holds the view. + private func releaseData() { + overlayEditor?.dismiss(commit: false) + rowProvider = InMemoryRowProvider(rows: [], columns: []) + rowVisualStateCache.removeAll() + cachedRowCount = 0 + cachedColumnCount = 0 + // Remove columns and reload to release cell views + if let tableView { + while let col = tableView.tableColumns.last { + tableView.removeTableColumn(col) + } + tableView.reloadData() + } + // Release closures + onRefresh = nil + onCellEdit = nil + onDeleteRows = nil + onCopyRows = nil + onPasteRows = nil + onUndo = nil + onRedo = nil + onSort = nil + onAddRow = nil + onUndoInsert = nil + onFilterColumn = nil + onHideColumn = nil + onNavigateFK = nil + getVisualState = nil + } + + private var teardownObserver: NSObjectProtocol? + deinit { if let observer = settingsObserver { NotificationCenter.default.removeObserver(observer) @@ -846,6 +895,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData if let observer = themeObserver { NotificationCenter.default.removeObserver(observer) } + if let observer = teardownObserver { + NotificationCenter.default.removeObserver(observer) + } } func updateCache() {