Skip to content

fix(sidebar): rebuild database tree on NSOutlineView to stop the expand/collapse crash#1717

Merged
datlechin merged 4 commits into
mainfrom
fix/sidebar-tree-outline-crash
Jun 18, 2026
Merged

fix(sidebar): rebuild database tree on NSOutlineView to stop the expand/collapse crash#1717
datlechin merged 4 commits into
mainfrom
fix/sidebar-tree-outline-crash

Conversation

@datlechin

Copy link
Copy Markdown
Member

Problem

The connection sidebar database tree crashed on servers with several databases and many schemas (for example PostgreSQL). It crashed on first display and when expanding or collapsing a database. Single-schema databases like SQLite were fine. This reproduced on main, independent of any open PR.

Root cause

The tree was a List(.sidebar) with manually nested DisclosureGroups whose isExpanded was driven by programmatic bindings (expandedTreeDatabases / expandedTreeDatabaseSchemas), plus a search force-open and expandActive(). On macOS, DisclosureGroup misbehaves when opened or closed programmatically with animation (Apple Developer Forums thread 681275). When async schema and table loads re-drove those bindings during an animated outline diff, SwiftUI asserted in ViewListTree.visitItem inside the NSOutlineView collapse path. OutlineGroup and List(children:) could not replace it: on macOS they are not lazy (FB8782243) and expose no programmatic expansion API, so they would drop lazy loading, auto-expand of the active node, and search reveal.

Fix

Rebuild the tree on NSOutlineView via NSViewRepresentable, so the framework owns expansion and the broken programmatic-DisclosureGroup path is gone, while keeping every current behavior.

  • DatabaseTreeOutlineView wraps NSScrollView + NSOutlineView (source list, overlay auto-hiding scrollers).
  • DatabaseTreeOutlineCoordinator is the data source and delegate: lazy load on expand, a loading/empty/error row per node so a driver error never crashes the app, crash-safe programmatic expandItem for auto-expand-active and search reveal, and an observation bridge that reconciles when DatabaseTreeMetadataService state changes.
  • DatabaseTreeNode is the node model with stable ids (cached instances keep expansion and selection across reloads). DatabaseTreeFilter is the extracted, testable filtering. DatabaseTreeRowView and DatabaseTreeCellView host the existing row views and context menus.
  • DatabaseTreeView keeps the outer state switch and the search debounce and now embeds the outline. Its public init is unchanged, so SidebarView is untouched. DatabaseTreeMetadataService is unchanged.

Both grouping shapes are preserved: .bySchema (database to schema to objects) and .byDatabase (database to objects). Selection, single-click open, double-click grid focus, multi-select, per-row context menus, active bolding, search filtering, and per-window expansion all carry over.

Tests

  • DatabaseTreeFilterTests: visibility, fuzzy match, content-match reveal, dedup.
  • DatabaseTreeNodeTests: id stability and uniqueness, expandability, table-ref extraction.
  • Existing DatabaseTreeSelectionTests and SelectionDeltaTests stay green.
  • DatabaseTreeMetadataServiceTests marked @MainActor so the suite builds under Xcode 27 strict concurrency.

All pass. Build and lint clean.

Manual verification needed

Build is reviewer-run. Please check on a multi-schema PostgreSQL connection: no crash on load or expand/collapse, lazy load on expand, search reveals matches, single-click opens and double-click focuses the grid, context menus on database/schema/table/routine, and that collapsing the active schema stays collapsed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 97ea17b1b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +60 to +63
let changed = searchText != view.searchText
|| activeChanged
|| pendingTruncates != view.pendingTruncates
|| pendingDeletes != view.pendingDeletes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include filter changes in outline refresh

When databaseFilterSelected changes from one visible subset to another, SwiftUI can re-enter updateNSView, but this changed key ignores the filter set even though rootNodes() uses it. Since refresh() is skipped, the outline keeps showing the previous database list until some unrelated search/active/pending state changes, making the database filter popover appear not to work for partial filters.

Useful? React with 👍 / 👎.

private func rowActions() -> DatabaseTreeRowActions {
DatabaseTreeRowActions(
coordinator: mainCoordinator,
isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh row actions when safe mode changes

When the user switches the connection into read-only/safe mode while the database tree is already visible, existing hosted rows keep the isReadOnly value captured here because the outline is only reloaded for search/active/pending changes. That leaves table context menus in the database tree enabled with the old writeable state, so actions like Truncate/Delete can still be presented and staged until another refresh rebuilds the cells.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 36e3fcbdd4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +141 to +144
SidebarContextMenu(
clickedTable: ref.table,
selectedTables: actions.selectedTables(),
isReadOnly: actions.isReadOnly,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route row context actions to the clicked table

When a table is already selected and the user right-clicks a different, unselected table, this row-level SwiftUI context menu does not update the NSOutlineView selection first. SidebarContextMenu.effectiveTableNames prefers selectedTables whenever it is non-empty, so actions such as Export, Truncate, or Delete are applied to the previous selection while the menu was opened on clickedTable, which can stage operations for the wrong table.

Useful? React with 👍 / 👎.

Comment on lines +61 to +65
let changed = searchText != view.searchText
|| connectionToken != view.connectionToken
|| activeChanged
|| pendingTruncates != view.pendingTruncates
|| pendingDeletes != view.pendingDeletes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the outline when the connection changes

If SwiftUI reuses this NSViewRepresentable while switching to another connected connection, connectionId and databaseType are assigned before changed is computed and neither value participates in the refresh key. With the same search/active/pending values, the coordinator keeps the old childrenCache/observation dependencies and skips reloadData(), so the sidebar can continue showing the previous connection's database tree until an unrelated state change forces a refresh.

Useful? React with 👍 / 👎.

@datlechin datlechin merged commit 1767c99 into main Jun 18, 2026
2 of 3 checks passed
@datlechin datlechin deleted the fix/sidebar-tree-outline-crash branch June 18, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant