From 6984de35e13dc7d3b4b7730a50e6ffb2c4878e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 21:59:23 +0700 Subject: [PATCH 1/9] fix(inspector): prevent window overflow on inspector pane toggle --- CHANGELOG.md | 4 + .../MainSplitViewController.swift | 164 +++++++++++------- ...ViewControllerWindowMinimumSizeTests.swift | 56 ++++++ 3 files changed, 163 insertions(+), 61 deletions(-) create mode 100644 TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e469c93..1758b0085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Toggling the right inspector in a narrow editor window now updates the window minimum width from the visible split panes, so the inspector no longer squeezes content or overflows. + ## [0.48.0] - 2026-06-02 ### Added diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index cbb3d0638..5d2fdf13f 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -44,6 +44,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! private var hasMaterializedInspector = false + private var baseWindowContentMinSize: NSSize? // MARK: - Toolbar @@ -227,7 +228,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi override func splitViewDidResizeSubviews(_ notification: Notification) { super.splitViewDidResizeSubviews(notification) - recomputeWindowMinSize() + recomputeWindowMinimumSize() } private func materializeInspectorIfNeeded() { @@ -236,6 +237,94 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi inspectorHosting.rootView = AnyView(buildInspectorView()) } + internal struct PaneMinimum { + internal let minimumThickness: CGFloat + internal let isCollapsed: Bool + } + + internal static func resolvedContentMinSize( + base: NSSize, + panes: [PaneMinimum], + dividerThickness: CGFloat + ) -> NSSize { + let visiblePanes = panes.filter { !$0.isCollapsed } + let paneWidth = visiblePanes.reduce(CGFloat.zero) { partialResult, pane in + partialResult + max(CGFloat.zero, pane.minimumThickness) + } + let dividerCount = max(visiblePanes.count - 1, 0) + let resolvedWidth = max(base.width, paneWidth + (CGFloat(dividerCount) * dividerThickness)) + return NSSize(width: resolvedWidth, height: base.height) + } + + private func recomputeWindowMinimumSize( + sidebarCollapsed: Bool? = nil, + inspectorCollapsed: Bool? = nil + ) { + guard let window = view.window else { return } + + if baseWindowContentMinSize == nil { + baseWindowContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size + } + guard let baseWindowContentMinSize else { return } + + let resolvedMinSize = Self.resolvedContentMinSize( + base: baseWindowContentMinSize, + panes: [ + PaneMinimum( + minimumThickness: sidebarSplitItem?.minimumThickness ?? .zero, + isCollapsed: sidebarCollapsed ?? (sidebarSplitItem?.isCollapsed ?? true) + ), + PaneMinimum( + minimumThickness: detailSplitItem?.minimumThickness ?? .zero, + isCollapsed: detailSplitItem?.isCollapsed ?? false + ), + PaneMinimum( + minimumThickness: inspectorSplitItem?.minimumThickness ?? .zero, + isCollapsed: inspectorCollapsed ?? (inspectorSplitItem?.isCollapsed ?? true) + ) + ], + dividerThickness: splitView.dividerThickness + ) + + if window.contentMinSize != resolvedMinSize { + window.contentMinSize = resolvedMinSize + } + + let currentContentSize = window.contentRect(forFrameRect: window.frame).size + guard currentContentSize.width < resolvedMinSize.width || currentContentSize.height < resolvedMinSize.height else { return } + window.setContentSize(NSSize( + width: max(currentContentSize.width, resolvedMinSize.width), + height: max(currentContentSize.height, resolvedMinSize.height) + )) + } + + private func setCollapsed( + _ isCollapsed: Bool, + for splitItem: NSSplitViewItem?, + prepareWindowMinimumSize: (() -> Void)? = nil + ) { + guard let splitItem else { return } + + if splitItem.isCollapsed == isCollapsed { + recomputeWindowMinimumSize() + return + } + + prepareWindowMinimumSize?() + + guard view.window?.isVisible == true else { + splitItem.isCollapsed = isCollapsed + recomputeWindowMinimumSize() + return + } + + NSAnimationContext.runAnimationGroup { _ in + splitItem.animator().isCollapsed = isCollapsed + } completionHandler: { [weak self] in + self?.recomputeWindowMinimumSize() + } + } + override func viewWillAppear() { super.viewWillAppear() guard let window = view.window else { return } @@ -257,7 +346,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } installObservers() - recomputeWindowMinSize() + recomputeWindowMinimumSize() window.recalculateKeyViewLoop() } @@ -324,11 +413,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi sessionState = nil currentSession = nil sidebarContainer.updateSidebarState(nil, windowState: nil) - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = true - } else { - sidebarSplitItem.isCollapsed = true - } + setCollapsed(true, for: sidebarSplitItem) } return } @@ -356,10 +441,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } let collapseSidebar = newSession.driver == nil - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = collapseSidebar - } else { - sidebarSplitItem.isCollapsed = collapseSidebar + setCollapsed(collapseSidebar, for: sidebarSplitItem) { [weak self] in + guard !collapseSidebar else { return } + self?.recomputeWindowMinimumSize(sidebarCollapsed: false) } rebuildPanes() } @@ -526,15 +610,15 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi func showInspector() { materializeInspectorIfNeeded() - inspectorSplitItem?.animator().isCollapsed = false + setCollapsed(false, for: inspectorSplitItem) { [weak self] in + self?.recomputeWindowMinimumSize(inspectorCollapsed: false) + } UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) - recomputeWindowMinSize() } func hideInspector() { - inspectorSplitItem?.animator().isCollapsed = true + setCollapsed(true, for: inspectorSplitItem) UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) - recomputeWindowMinSize() } @objc override func toggleInspector(_ sender: Any?) { @@ -560,58 +644,16 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi if sidebarSplitItem?.isCollapsed == true { sidebarState.selectedSidebarTab = tab - sidebarSplitItem?.animator().isCollapsed = false + setCollapsed(false, for: sidebarSplitItem) { [weak self] in + self?.recomputeWindowMinimumSize(sidebarCollapsed: false) + } } else if sidebarState.selectedSidebarTab == tab { - sidebarSplitItem?.animator().isCollapsed = true + setCollapsed(true, for: sidebarSplitItem) } else { sidebarState.selectedSidebarTab = tab } } - // MARK: - Dynamic Window Minimum Size - - private static let baseWindowMinWidth: CGFloat = 720 - private static let baseWindowMinHeight: CGFloat = 480 - - private func recomputeWindowMinSize() { - guard let window = view.window else { return } - let sidebarVisible = !(sidebarSplitItem?.isCollapsed ?? true) - let inspectorVisible = !(inspectorSplitItem?.isCollapsed ?? true) - - let detailMin: CGFloat = detailSplitItem?.minimumThickness ?? 400 - let sidebarMin: CGFloat = sidebarSplitItem?.minimumThickness ?? 280 - let inspectorMin: CGFloat = inspectorSplitItem?.minimumThickness ?? 270 - let dividerThickness = splitView.dividerThickness - - var width: CGFloat = detailMin - if sidebarVisible { - width += sidebarMin + dividerThickness - } - if inspectorVisible { - width += inspectorMin + dividerThickness - } - - let resolvedWidth = max(Self.baseWindowMinWidth, width) - let newMinSize = NSSize(width: resolvedWidth, height: Self.baseWindowMinHeight) - - guard window.minSize != newMinSize else { return } - window.minSize = newMinSize - - var frame = window.frame - var resized = false - if frame.size.width < resolvedWidth { - frame.size.width = resolvedWidth - resized = true - } - if frame.size.height < Self.baseWindowMinHeight { - frame.size.height = Self.baseWindowMinHeight - resized = true - } - if resized { - window.setFrame(frame, display: true, animate: false) - } - } - // MARK: - Constants private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift new file mode 100644 index 000000000..aaf3b0c8d --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift @@ -0,0 +1,56 @@ +import AppKit +import Testing + +@testable import TablePro + +@Suite("MainSplitViewController window minimum size") +@MainActor +struct MainSplitViewControllerWindowMinimumSizeTests { + @Test("Uses all visible pane minimums when the inspector is shown") + func includesVisibleInspectorPane() { + let size = MainSplitViewController.resolvedContentMinSize( + base: NSSize(width: 720, height: 448), + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(size.width == 954) + #expect(size.height == 448) + } + + @Test("Keeps the base width floor when the inspector is hidden") + func keepsBaseWidthWhenInspectorHidden() { + let size = MainSplitViewController.resolvedContentMinSize( + base: NSSize(width: 720, height: 448), + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: true) + ], + dividerThickness: 2 + ) + + #expect(size.width == 720) + #expect(size.height == 448) + } + + @Test("Relaxes to the base width when only detail and inspector remain") + func keepsBaseWidthWithSidebarCollapsed() { + let size = MainSplitViewController.resolvedContentMinSize( + base: NSSize(width: 720, height: 448), + panes: [ + .init(minimumThickness: 280, isCollapsed: true), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(size.width == 720) + #expect(size.height == 448) + } +} From e24ea5b551350a040f769c5964a4f2316859b6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 22:13:39 +0700 Subject: [PATCH 2/9] fix(inspector): add resize call site, refresh base size, fix animation race, add test --- .../Infrastructure/MainSplitViewController.swift | 14 ++++++++------ ...litViewControllerWindowMinimumSizeTests.swift | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 5d2fdf13f..c461eae37 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -44,7 +44,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! private var hasMaterializedInspector = false - private var baseWindowContentMinSize: NSSize? // MARK: - Toolbar @@ -262,10 +261,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) { guard let window = view.window else { return } - if baseWindowContentMinSize == nil { - baseWindowContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size - } - guard let baseWindowContentMinSize else { return } + let baseWindowContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size let resolvedMinSize = Self.resolvedContentMinSize( base: baseWindowContentMinSize, @@ -355,6 +351,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi removeObservers() } + override func splitViewDidResizeSubviews(_ notification: Notification) { + recomputeWindowMinimumSize() + } + // MARK: - Observers private func installObservers() { @@ -617,7 +617,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } func hideInspector() { - setCollapsed(true, for: inspectorSplitItem) + setCollapsed(true, for: inspectorSplitItem) { [weak self] in + self?.recomputeWindowMinimumSize(inspectorCollapsed: true) + } UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) } diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift index aaf3b0c8d..fb3ab22b3 100644 --- a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift @@ -53,4 +53,20 @@ struct MainSplitViewControllerWindowMinimumSizeTests { #expect(size.width == 720) #expect(size.height == 448) } + + @Test("Uses the pane sum when detail and inspector exceed the base floor") + func usesPaneSumWhenItExceedsBaseWithSidebarCollapsed() { + let size = MainSplitViewController.resolvedContentMinSize( + base: NSSize(width: 720, height: 448), + panes: [ + .init(minimumThickness: 280, isCollapsed: true), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(size.width == 802) + #expect(size.height == 448) + } } From 77923ac1b1e633d93e898d54396019df6d90baef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Thu, 28 May 2026 20:48:49 +0700 Subject: [PATCH 3/9] fix(inspector): store original content min size to prevent floor drift --- .../MainSplitViewController.swift | 13 ++++---- ...ViewControllerWindowMinimumSizeTests.swift | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index c461eae37..0f645bf82 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -44,6 +44,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! private var hasMaterializedInspector = false + private var originalContentMinSize: CGSize = .zero // MARK: - Toolbar @@ -261,10 +262,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) { guard let window = view.window else { return } - let baseWindowContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size - let resolvedMinSize = Self.resolvedContentMinSize( - base: baseWindowContentMinSize, + base: originalContentMinSize, panes: [ PaneMinimum( minimumThickness: sidebarSplitItem?.minimumThickness ?? .zero, @@ -334,6 +333,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi installToolbar(coordinator: sessionState.coordinator) } + if originalContentMinSize == .zero { + originalContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size + } + if let currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id), @@ -351,10 +354,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi removeObservers() } - override func splitViewDidResizeSubviews(_ notification: Notification) { - recomputeWindowMinimumSize() - } - // MARK: - Observers private func installObservers() { diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift index fb3ab22b3..a3fe45e47 100644 --- a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift @@ -69,4 +69,34 @@ struct MainSplitViewControllerWindowMinimumSizeTests { #expect(size.width == 802) #expect(size.height == 448) } + + @Test("Returns to the original base width after showing then hiding the inspector") + func relaxesBackToOriginalBaseAfterInspectorCycle() { + let originalBase = NSSize(width: 720, height: 448) + + let shownSize = MainSplitViewController.resolvedContentMinSize( + base: originalBase, + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(shownSize.width == 954) + + let hiddenSize = MainSplitViewController.resolvedContentMinSize( + base: originalBase, + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: true) + ], + dividerThickness: 2 + ) + + #expect(hiddenSize.width == 720) + #expect(hiddenSize.height == 448) + } } From 4e69ac6a2db596afcc7f1d903214ff5714ddb3ea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 3 Jun 2026 17:52:06 +0700 Subject: [PATCH 4/9] refactor(coordinator): make split controller sole owner of window minimum size --- .../MainSplitViewController.swift | 9 ++--- .../Infrastructure/TabWindowController.swift | 1 - ...ViewControllerWindowMinimumSizeTests.swift | 37 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 0f645bf82..e8c22770a 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -44,7 +44,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! private var hasMaterializedInspector = false - private var originalContentMinSize: CGSize = .zero // MARK: - Toolbar @@ -263,7 +262,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi guard let window = view.window else { return } let resolvedMinSize = Self.resolvedContentMinSize( - base: originalContentMinSize, + base: NSSize(width: Self.baseContentMinWidth, height: Self.baseContentMinHeight), panes: [ PaneMinimum( minimumThickness: sidebarSplitItem?.minimumThickness ?? .zero, @@ -333,10 +332,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi installToolbar(coordinator: sessionState.coordinator) } - if originalContentMinSize == .zero { - originalContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size - } - if let currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id), @@ -657,5 +652,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Constants + internal static let baseContentMinWidth: CGFloat = 720 + internal static let baseContentMinHeight: CGFloat = 480 private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" } diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index acbb4c479..f291be9e3 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -51,7 +51,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { defer: false ) window.identifier = NSUserInterfaceItemIdentifier("main") - window.minSize = NSSize(width: 720, height: 480) window.isRestorable = AppSettingsStorage.shared.loadGeneral().startupBehavior == .reopenLast window.restorationClass = TabWindowRestoration.self window.toolbarStyle = .unified diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift index a3fe45e47..ac92aae22 100644 --- a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift @@ -99,4 +99,41 @@ struct MainSplitViewControllerWindowMinimumSizeTests { #expect(hiddenSize.width == 720) #expect(hiddenSize.height == 448) } + + @Test("Applies the controller base constants as the runtime content floor") + func usesControllerBaseConstantsAsFloor() { + #expect(MainSplitViewController.baseContentMinWidth == 720) + #expect(MainSplitViewController.baseContentMinHeight == 480) + + let base = NSSize( + width: MainSplitViewController.baseContentMinWidth, + height: MainSplitViewController.baseContentMinHeight + ) + + let sidebarAndDetail = MainSplitViewController.resolvedContentMinSize( + base: base, + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: true) + ], + dividerThickness: 2 + ) + + #expect(sidebarAndDetail.width == MainSplitViewController.baseContentMinWidth) + #expect(sidebarAndDetail.height == MainSplitViewController.baseContentMinHeight) + + let allPanesVisible = MainSplitViewController.resolvedContentMinSize( + base: base, + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(allPanesVisible.width == 954) + #expect(allPanesVisible.height == MainSplitViewController.baseContentMinHeight) + } } From 353999392d5a3533e755c71976247b83d1723b31 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 5 Jun 2026 00:05:23 +0700 Subject: [PATCH 5/9] chore: remove accidentally committed Libs/dylibs symlink --- Libs/dylibs | 1 - 1 file changed, 1 deletion(-) delete mode 120000 Libs/dylibs diff --git a/Libs/dylibs b/Libs/dylibs deleted file mode 120000 index d548da396..000000000 --- a/Libs/dylibs +++ /dev/null @@ -1 +0,0 @@ -/Users/hangvalong/Code/TablePro/Libs/dylibs \ No newline at end of file From 58cd1102c93851fb8c9cb829c2b222c5450f7a31 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 5 Jun 2026 00:23:55 +0700 Subject: [PATCH 6/9] refactor(inspector): grow the window via NSSplitViewItem.collapseBehavior instead of custom min-size code --- CHANGELOG.md | 2 +- .../MainSplitViewController.swift | 110 ++------------ .../Infrastructure/TabWindowController.swift | 1 + ...ViewControllerWindowMinimumSizeTests.swift | 139 ------------------ 4 files changed, 12 insertions(+), 240 deletions(-) delete mode 100644 TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 742d363d7..a6b5d271c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Find Next (Cmd+G) and Find Previous (Cmd+Shift+G) now work in the editor. - Pagination buttons no longer fire their page shortcut twice. - Running a PostgreSQL script with a `DO $$ ... $$` block or a dollar-quoted function body no longer fails with an unterminated dollar-quoted string error. (#1559) -- Toggling the right inspector in a narrow editor window now updates the window minimum width from the visible split panes, so the inspector no longer squeezes content or overflows. +- Toggling the right inspector in a narrow editor window now grows the window to fit, so the inspector no longer squeezes content or overflows. - AWS IAM connections no longer ask for a password on connect or reconnect. IAM supplies the credentials, so the prompt was never needed. The same now holds for any auth mode that replaces the password, such as a Postgres password file. - Oracle connection failures show the listener's actual reason (such as an unknown service name) instead of a generic "server closed the connection" message. (#483) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index e8c22770a..7074329c0 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -212,6 +212,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi inspectorSplitItem.canCollapse = true inspectorSplitItem.minimumThickness = 270 inspectorSplitItem.maximumThickness = 400 + inspectorSplitItem.collapseBehavior = .preferResizingSplitViewWithFixedSiblings addSplitViewItem(inspectorSplitItem) if currentSession?.driver == nil { @@ -225,97 +226,18 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi inspectorSplitItem.isCollapsed = !inspectorPresented } - override func splitViewDidResizeSubviews(_ notification: Notification) { - super.splitViewDidResizeSubviews(notification) - recomputeWindowMinimumSize() - } - private func materializeInspectorIfNeeded() { guard !hasMaterializedInspector, let inspectorHosting else { return } hasMaterializedInspector = true inspectorHosting.rootView = AnyView(buildInspectorView()) } - internal struct PaneMinimum { - internal let minimumThickness: CGFloat - internal let isCollapsed: Bool - } - - internal static func resolvedContentMinSize( - base: NSSize, - panes: [PaneMinimum], - dividerThickness: CGFloat - ) -> NSSize { - let visiblePanes = panes.filter { !$0.isCollapsed } - let paneWidth = visiblePanes.reduce(CGFloat.zero) { partialResult, pane in - partialResult + max(CGFloat.zero, pane.minimumThickness) - } - let dividerCount = max(visiblePanes.count - 1, 0) - let resolvedWidth = max(base.width, paneWidth + (CGFloat(dividerCount) * dividerThickness)) - return NSSize(width: resolvedWidth, height: base.height) - } - - private func recomputeWindowMinimumSize( - sidebarCollapsed: Bool? = nil, - inspectorCollapsed: Bool? = nil - ) { - guard let window = view.window else { return } - - let resolvedMinSize = Self.resolvedContentMinSize( - base: NSSize(width: Self.baseContentMinWidth, height: Self.baseContentMinHeight), - panes: [ - PaneMinimum( - minimumThickness: sidebarSplitItem?.minimumThickness ?? .zero, - isCollapsed: sidebarCollapsed ?? (sidebarSplitItem?.isCollapsed ?? true) - ), - PaneMinimum( - minimumThickness: detailSplitItem?.minimumThickness ?? .zero, - isCollapsed: detailSplitItem?.isCollapsed ?? false - ), - PaneMinimum( - minimumThickness: inspectorSplitItem?.minimumThickness ?? .zero, - isCollapsed: inspectorCollapsed ?? (inspectorSplitItem?.isCollapsed ?? true) - ) - ], - dividerThickness: splitView.dividerThickness - ) - - if window.contentMinSize != resolvedMinSize { - window.contentMinSize = resolvedMinSize - } - - let currentContentSize = window.contentRect(forFrameRect: window.frame).size - guard currentContentSize.width < resolvedMinSize.width || currentContentSize.height < resolvedMinSize.height else { return } - window.setContentSize(NSSize( - width: max(currentContentSize.width, resolvedMinSize.width), - height: max(currentContentSize.height, resolvedMinSize.height) - )) - } - - private func setCollapsed( - _ isCollapsed: Bool, - for splitItem: NSSplitViewItem?, - prepareWindowMinimumSize: (() -> Void)? = nil - ) { - guard let splitItem else { return } - - if splitItem.isCollapsed == isCollapsed { - recomputeWindowMinimumSize() - return - } - - prepareWindowMinimumSize?() - - guard view.window?.isVisible == true else { - splitItem.isCollapsed = isCollapsed - recomputeWindowMinimumSize() - return - } - - NSAnimationContext.runAnimationGroup { _ in + private func setCollapsed(_ isCollapsed: Bool, for splitItem: NSSplitViewItem?) { + guard let splitItem, splitItem.isCollapsed != isCollapsed else { return } + if view.window?.isVisible == true { splitItem.animator().isCollapsed = isCollapsed - } completionHandler: { [weak self] in - self?.recomputeWindowMinimumSize() + } else { + splitItem.isCollapsed = isCollapsed } } @@ -340,7 +262,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } installObservers() - recomputeWindowMinimumSize() window.recalculateKeyViewLoop() } @@ -435,10 +356,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } let collapseSidebar = newSession.driver == nil - setCollapsed(collapseSidebar, for: sidebarSplitItem) { [weak self] in - guard !collapseSidebar else { return } - self?.recomputeWindowMinimumSize(sidebarCollapsed: false) - } + setCollapsed(collapseSidebar, for: sidebarSplitItem) rebuildPanes() } @@ -604,16 +522,12 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi func showInspector() { materializeInspectorIfNeeded() - setCollapsed(false, for: inspectorSplitItem) { [weak self] in - self?.recomputeWindowMinimumSize(inspectorCollapsed: false) - } + setCollapsed(false, for: inspectorSplitItem) UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) } func hideInspector() { - setCollapsed(true, for: inspectorSplitItem) { [weak self] in - self?.recomputeWindowMinimumSize(inspectorCollapsed: true) - } + setCollapsed(true, for: inspectorSplitItem) UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) } @@ -640,9 +554,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi if sidebarSplitItem?.isCollapsed == true { sidebarState.selectedSidebarTab = tab - setCollapsed(false, for: sidebarSplitItem) { [weak self] in - self?.recomputeWindowMinimumSize(sidebarCollapsed: false) - } + setCollapsed(false, for: sidebarSplitItem) } else if sidebarState.selectedSidebarTab == tab { setCollapsed(true, for: sidebarSplitItem) } else { @@ -652,7 +564,5 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Constants - internal static let baseContentMinWidth: CGFloat = 720 - internal static let baseContentMinHeight: CGFloat = 480 private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" } diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index f291be9e3..acbb4c479 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -51,6 +51,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { defer: false ) window.identifier = NSUserInterfaceItemIdentifier("main") + window.minSize = NSSize(width: 720, height: 480) window.isRestorable = AppSettingsStorage.shared.loadGeneral().startupBehavior == .reopenLast window.restorationClass = TabWindowRestoration.self window.toolbarStyle = .unified diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift deleted file mode 100644 index ac92aae22..000000000 --- a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift +++ /dev/null @@ -1,139 +0,0 @@ -import AppKit -import Testing - -@testable import TablePro - -@Suite("MainSplitViewController window minimum size") -@MainActor -struct MainSplitViewControllerWindowMinimumSizeTests { - @Test("Uses all visible pane minimums when the inspector is shown") - func includesVisibleInspectorPane() { - let size = MainSplitViewController.resolvedContentMinSize( - base: NSSize(width: 720, height: 448), - panes: [ - .init(minimumThickness: 280, isCollapsed: false), - .init(minimumThickness: 400, isCollapsed: false), - .init(minimumThickness: 270, isCollapsed: false) - ], - dividerThickness: 2 - ) - - #expect(size.width == 954) - #expect(size.height == 448) - } - - @Test("Keeps the base width floor when the inspector is hidden") - func keepsBaseWidthWhenInspectorHidden() { - let size = MainSplitViewController.resolvedContentMinSize( - base: NSSize(width: 720, height: 448), - panes: [ - .init(minimumThickness: 280, isCollapsed: false), - .init(minimumThickness: 400, isCollapsed: false), - .init(minimumThickness: 270, isCollapsed: true) - ], - dividerThickness: 2 - ) - - #expect(size.width == 720) - #expect(size.height == 448) - } - - @Test("Relaxes to the base width when only detail and inspector remain") - func keepsBaseWidthWithSidebarCollapsed() { - let size = MainSplitViewController.resolvedContentMinSize( - base: NSSize(width: 720, height: 448), - panes: [ - .init(minimumThickness: 280, isCollapsed: true), - .init(minimumThickness: 400, isCollapsed: false), - .init(minimumThickness: 270, isCollapsed: false) - ], - dividerThickness: 2 - ) - - #expect(size.width == 720) - #expect(size.height == 448) - } - - @Test("Uses the pane sum when detail and inspector exceed the base floor") - func usesPaneSumWhenItExceedsBaseWithSidebarCollapsed() { - let size = MainSplitViewController.resolvedContentMinSize( - base: NSSize(width: 720, height: 448), - panes: [ - .init(minimumThickness: 280, isCollapsed: true), - .init(minimumThickness: 400, isCollapsed: false), - .init(minimumThickness: 400, isCollapsed: false) - ], - dividerThickness: 2 - ) - - #expect(size.width == 802) - #expect(size.height == 448) - } - - @Test("Returns to the original base width after showing then hiding the inspector") - func relaxesBackToOriginalBaseAfterInspectorCycle() { - let originalBase = NSSize(width: 720, height: 448) - - let shownSize = MainSplitViewController.resolvedContentMinSize( - base: originalBase, - panes: [ - .init(minimumThickness: 280, isCollapsed: false), - .init(minimumThickness: 400, isCollapsed: false), - .init(minimumThickness: 270, isCollapsed: false) - ], - dividerThickness: 2 - ) - - #expect(shownSize.width == 954) - - let hiddenSize = MainSplitViewController.resolvedContentMinSize( - base: originalBase, - panes: [ - .init(minimumThickness: 280, isCollapsed: false), - .init(minimumThickness: 400, isCollapsed: false), - .init(minimumThickness: 270, isCollapsed: true) - ], - dividerThickness: 2 - ) - - #expect(hiddenSize.width == 720) - #expect(hiddenSize.height == 448) - } - - @Test("Applies the controller base constants as the runtime content floor") - func usesControllerBaseConstantsAsFloor() { - #expect(MainSplitViewController.baseContentMinWidth == 720) - #expect(MainSplitViewController.baseContentMinHeight == 480) - - let base = NSSize( - width: MainSplitViewController.baseContentMinWidth, - height: MainSplitViewController.baseContentMinHeight - ) - - let sidebarAndDetail = MainSplitViewController.resolvedContentMinSize( - base: base, - panes: [ - .init(minimumThickness: 280, isCollapsed: false), - .init(minimumThickness: 400, isCollapsed: false), - .init(minimumThickness: 270, isCollapsed: true) - ], - dividerThickness: 2 - ) - - #expect(sidebarAndDetail.width == MainSplitViewController.baseContentMinWidth) - #expect(sidebarAndDetail.height == MainSplitViewController.baseContentMinHeight) - - let allPanesVisible = MainSplitViewController.resolvedContentMinSize( - base: base, - panes: [ - .init(minimumThickness: 280, isCollapsed: false), - .init(minimumThickness: 400, isCollapsed: false), - .init(minimumThickness: 270, isCollapsed: false) - ], - dividerThickness: 2 - ) - - #expect(allPanesVisible.width == 954) - #expect(allPanesVisible.height == MainSplitViewController.baseContentMinHeight) - } -} From 5aa43889a5cdf4360b4877920adb62b7c8103661 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 5 Jun 2026 01:17:14 +0700 Subject: [PATCH 7/9] fix(editor): parse schema and table from per-segment-quoted qualified names --- .../Autocomplete/SQLContextAnalyzer.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index 48b0f68e1..94a0da8b1 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -247,14 +247,14 @@ final class SQLContextAnalyzer { private static let tableRefRegexes: [NSRegularExpression] = { let patterns = [ - "(?i)\\bFROM\\s+[`\"']?([\\w.]+)[`\"']?" + + "(?i)\\bFROM\\s+([\\w.`\"']+)" + "(?:\\s+(?:AS\\s+)?[`\"']?([\\w]+)[`\"']?)?", "(?i)(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\\s*(?:OUTER)?\\s*JOIN\\s+" + - "[`\"']?([\\w.]+)[`\"']?(?:\\s+(?:AS\\s+)?[`\"']?([\\w]+)[`\"']?)?", - "(?i)\\bUPDATE\\s+[`\"']?([\\w.]+)[`\"']?" + + "([\\w.`\"']+)(?:\\s+(?:AS\\s+)?[`\"']?([\\w]+)[`\"']?)?", + "(?i)\\bUPDATE\\s+([\\w.`\"']+)" + "(?:\\s+(?:AS\\s+)?[`\"']?([\\w]+)[`\"']?)?", - "(?i)\\bINSERT\\s+INTO\\s+[`\"']?([\\w.]+)[`\"']?", - "(?i)\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+[`\"']?([\\w.]+)[`\"']?" + "(?i)\\bINSERT\\s+INTO\\s+([\\w.`\"']+)", + "(?i)\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+([\\w.`\"']+)" ] return patterns.map { compileRegex($0) } }() @@ -768,13 +768,17 @@ final class SQLContextAnalyzer { ] /// Strip schema prefix from a potentially schema-qualified name + private static let identifierQuotes = CharacterSet(charactersIn: "`\"'") + private static func stripSchemaPrefix(_ raw: String) -> String { let ns = raw as NSString let dotRange = ns.range(of: ".", options: .backwards) - guard dotRange.location != NSNotFound else { return raw } + guard dotRange.location != NSNotFound else { + return raw.trimmingCharacters(in: identifierQuotes) + } let start = dotRange.location + 1 - guard start < ns.length else { return raw } - return ns.substring(from: start) + guard start < ns.length else { return raw.trimmingCharacters(in: identifierQuotes) } + return ns.substring(from: start).trimmingCharacters(in: identifierQuotes) } /// Extract all table references (table names and aliases) from the query @@ -798,7 +802,7 @@ final class SQLContextAnalyzer { let segments = rawName.split(separator: ".") let schema = segments.count >= 2 ? String(segments[segments.count - 2]) - .trimmingCharacters(in: CharacterSet(charactersIn: "`\"")) + .trimmingCharacters(in: Self.identifierQuotes) : nil var alias: String? From 45075ac3f0af33dfa3a2fa3688f38361ba05e272 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 5 Jun 2026 01:24:03 +0700 Subject: [PATCH 8/9] fix(connections): capture full output from a password command that exits quickly --- .../Core/Utilities/Connection/PasswordSourceResolver.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift index 45cb71a7c..4395dcd73 100644 --- a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift +++ b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift @@ -130,6 +130,13 @@ enum PasswordSourceResolver { stdoutPipe.fileHandleForReading.readabilityHandler = nil stderrPipe.fileHandleForReading.readabilityHandler = nil + if let remainingStdout = try? stdoutPipe.fileHandleForReading.readToEnd(), !remainingStdout.isEmpty { + stdoutCollector.append(remainingStdout) + } + if let remainingStderr = try? stderrPipe.fileHandleForReading.readToEnd(), !remainingStderr.isEmpty { + stderrCollector.append(remainingStderr) + } + if stdoutCollector.overflowed { throw ResolutionError.outputTooLarge } From 96dfe45b5ddc71afd94c6d4f5a2391d1c8df10bf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 5 Jun 2026 01:24:03 +0700 Subject: [PATCH 9/9] fix(mcp): check cancellation and emit progress before resolving the connection --- TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift index f1c01d9a2..93ceb6091 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift @@ -77,6 +77,9 @@ public struct ExecuteQueryTool: MCPToolImplementation { throw MCPProtocolError.invalidParams(detail: "Query exceeds 100KB limit") } + try await throwIfCancelled(context) + await context.progress.emit(progress: 0.0, total: 1.0, message: "Connecting") + let meta = try await ToolConnectionMetadata.resolve(connectionId: connectionId) guard !QueryClassifier.isMultiStatement(query, databaseType: meta.databaseType) else { @@ -85,9 +88,6 @@ public struct ExecuteQueryTool: MCPToolImplementation { ) } - try await throwIfCancelled(context) - await context.progress.emit(progress: 0.0, total: 1.0, message: "Connecting") - if let database { _ = try await services.connectionBridge.switchDatabase( connectionId: connectionId,