Skip to content

Add a flag-gated AppKit NSTableView core for the workspace sidebar - #8281

Closed
azooz2003-bit wants to merge 5 commits into
mainfrom
sidebar-appkit-table
Closed

azooz2003-bit wants to merge 5 commits into
mainfrom
sidebar-appkit-table

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

#6707 is not fixed at scale. On current main (with #8211 and #8236), 128 workspaces with mixed-height rows: after a selection/scroll sweep ends, the main thread never converges — CPU stays pinned at ~100% indefinitely while idle, with 86% of main-thread samples inside NSHostingView.beginTransaction → GraphHost.flushTransactions → AG::Subgraph::update and the hot cmux frames in VerticalTabsSidebar.workspaceRows/workspaceRow (LazyVStack realization re-running forever). That is the exact field signature from the issue, reproduced on a symbolicated Debug build. The scroller-knob stutter also remains: the sidebar's document height changed 61 times in one sweep (44pt oscillation band), because LazyVStack re-estimates unrealized row heights from the realized average on every scroll. This class has now regressed through six distinct mechanisms (#2586, #5764, #5845, #6210, #6556, #8004); each fix removed one feedback edge and the next one found another.

Full capture artifacts (samples, probe logs) are in cmuxterm-hq out/sidebar-post8211-verify/.

What changed

sidebar.beta.appKitList.enabled (Settings → Beta Features → "AppKit Sidebar List", also in cmux.json) selects the workspace list core. Default off: the existing SwiftUI LazyVStack path is byte-for-byte unchanged. With the flag on, workspaceScrollArea mounts an NSTableView-based list instead:

  • One container-level NSViewRepresentable (SidebarWorkspaceTableView) owns an NSScrollView + NSTableView with usesAutomaticRowHeights = false. Row heights are measured once per (row content, column width, font scale) into an explicit cache and returned from heightOfRow, so the document height is deterministic — zero doc-height churn while scrolling, by construction.
  • An explicit differ applies render-context updates: ID-order changes → reloadData; content changes → reconfigure only the affected visible cells (value-equality via the same Equatable row snapshots the SwiftUI rows use). Unrealized rows never enter SwiftUI layout.
  • Cells host the existing row content (TabItemView, SidebarWorkspaceGroupHeaderView) through one reused NSHostingView each (sizing negotiation disabled), fed by the same immutable snapshot values as the SwiftUI path — the Fix sidebar scroll layout livelock #8211 snapshot boundary is unchanged, and both paths share the row-input/group-snapshot builders and the action factory.
  • Hover, middle-click-to-close, empty-area double-click/context-menu, and selection-follow scrolling are native table behaviors (tracking areas, otherMouseDown, scrollRowToVisible). Workspace reorder and bonsplit drops reuse the existing AppKit drop views, with drop-target rects resolved from table row geometry only while a drag is active.
  • Table-hosted cells set a sidebarPlatformListHosted environment flag so TabItemView skips its SwiftUI drag/bonsplit-drop mounts inside the table (NSTableView owns the drag session); in the legacy list those mounts are untouched.

Verification (tag sbtbl, 128 workspaces + 42 notification rows)

  • Flag on, same sweep protocol as the baseline: with the flag on, the identical sweep protocol produced 0 document-height changes across the full sweep (194 scroll clip moves — real scrolling), and idle CPU 15 seconds after the sweep is 0.5% with the main thread parked in the event loop, versus ~100% pinned indefinitely on main. Two integration defects found and fixed during this verification: the representable reported content-derived fitting size (the window grew to fit all 128 rows; now clamped to the proposal), and per-pass equivalence values built throwaway row views whose action-closure bundles dominated idle destroy time (equivalence now compares the immutable row/group snapshots directly).
  • Flag off: legacy path unchanged (restored verbatim from main).

scripts/check-sidebar-lazy-layout.py keeps guarding the SwiftUI path exactly as on main; table-specific source guards and 300-workspace table scale tests land next on this branch (implementation was prioritized first).

Supersedes #8068.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Adds a beta switch to render the workspace sidebar with an AppKit NSTableView instead of the SwiftUI list. Default is off. When on, scrolling is stable and idle CPU stays low on large workspaces.

  • New Features
    • Adds sidebar.beta.appKitList.enabled (Settings → Beta Features → “AppKit Sidebar List”, also in cmux.json).
    • When enabled, mounts an NSTableView with measured-once row heights and explicit diffing; unrealized rows never enter SwiftUI.
    • Reuses existing row UIs (TabItemView, group headers) via a single reused NSHostingView per cell; both paths share the same immutable snapshots and actions.
    • Native behaviors: hover, middle-click-to-close, empty-area context menu/double-click, selection-follow scroll, and drag-and-drop with AppKit geometry.
    • Adds a sidebarPlatformListHosted environment flag so table-hosted rows skip SwiftUI drag/drop mounts; legacy SwiftUI list is unchanged when the flag is off.

Written for commit 3ff4b53. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added an optional native AppKit sidebar list beta feature.
    • Improved sidebar workspace rendering, scrolling, row sizing, hover behavior, context menus, and drag-and-drop interactions when enabled.
    • Added English and Japanese localized descriptions for the new setting.
  • Bug Fixes

    • Reduced unnecessary sidebar row updates and re-measurement during scrolling and resizing.
  • Tests

    • Added coverage for row sizing, caching, cell reuse, hover behavior, and drag-and-drop geometry.

cmux reload-cloud added 5 commits July 15, 2026 21:49
The workspace list becomes one container-level NSViewRepresentable
mounting NSTableView with measured-once row heights
(usesAutomaticRowHeights=false + explicit height cache), an explicit
differ (ID-order change → reloadData; content change → reconfigure
visible cells only), AppKit-owned hover via table tracking areas, and
table-geometry drop targets. Cells host the existing SwiftUI row
content (TabItemView / SidebarWorkspaceGroupHeaderView) through one
reused NSHostingView each, fed by the same immutable snapshots as
before; the snapshot boundary from #8211 is unchanged.

Deletes the SwiftUI list plumbing this replaces: scroll-follow proxy
state, the drag-gated row-frame preference reader, the per-row drop
modifiers and drag gate, both drop-overlay representables in the
default list path, and the sidebar pointer-interaction monitor (hover
and middle-click are native table events now).

scripts/check-sidebar-lazy-layout.py now guards the AppKit boundary:
the old lazy-layout functions must stay deleted, row types must not
mount platform representables, and table mutations are banned from
AppKit layout callbacks. SidebarLazyLayoutScaleTests keeps the
300-workspace realization/convergence gates against the table.
# Conflicts:
#	cmux.xcodeproj/project.pbxproj
sidebar.beta.appKitList.enabled (Settings → Beta Features → AppKit
Sidebar List, also settable in cmux.json) selects the list core:
off (default) keeps the existing SwiftUI LazyVStack path unchanged —
scroll-follow proxy, pointer-interaction monitor, drag-gated frame
reader, drop overlays, and row drag mounts all restored verbatim; on
mounts the NSTableView path. workspaceScrollArea branches on the flag
and hosts the shared workspace-observation/snapshot-refresh stack.

Table-hosted cells set a sidebarPlatformListHosted environment flag so
TabItemView keeps its SwiftUI drag/drop mounts in the legacy list but
skips them inside table cells, where NSTableView owns the drag session
and drop-target geometry.
…ence

Two defects found by at-scale runtime verification (128 workspaces):

1. The representable's default sizing falls back to the container's
   fitting size, which derives from the table's full content height. At
   128 workspaces the ideal-size pass adopted ~5,200pt and the window
   grew to fit every row. sizeThatFits now reports exactly the
   proposal, with unspecified dimensions reporting zero, so
   content-derived metrics never escape the viewport.

2. Row-configuration equivalence was an Equatable TabItemView /
   SidebarWorkspaceGroupHeaderView built per row per body pass, which
   also built each row's ~45-closure actions bundle just to be thrown
   away on the next pass — destroying the previous pass's bundles
   dominated idle main-thread time. Equivalence now compares the
   immutable row/group snapshots directly (the same values the views
   diff on), and SidebarWorkspaceGroupRowSnapshot gains a synthesized
   Equatable.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a beta-gated AppKit NSTableView workspace sidebar with SwiftUI-hosted rows, cached heights, drag/drop interactions, hover and context-menu coordination, and focused tests.

Changes

AppKit Sidebar List

Layer / File(s) Summary
Beta toggle and settings UI
Packages/macOS/CmuxSettings/..., Packages/macOS/CmuxSettingsUI/..., Resources/Localizable.xcstrings
Adds the disabled-by-default appKitSidebarList setting, its settings row, observation wiring, and English/Japanese localization.
Row snapshots and hosted SwiftUI contracts
Sources/ContentView.swift, Sources/Sidebar/AppKitList/*, Sources/SidebarWorkspaceGroupHeaderView.swift, Sources/SidebarWorkspaceGroupRowSnapshot.swift, Sources/SidebarWorkspaceRow*.swift, Sources/VerticalTabsSidebar+WorkspaceGroups.swift, Sources/Debug/SidebarLazyContractProbe.swift
Adds immutable row configurations, environment snapshots, cell hosting state, row-height estimation and caching, group-header integration, and conditional legacy drag/drop mounts.
AppKit table lifecycle and interactions
Sources/Sidebar/AppKitList/*, Sources/ContentView.swift
Adds the SwiftUI-to-AppKit bridge, table controller, viewport and hover handling, workspace actions, drag/drop routing, and drop-indicator geometry.
Project wiring and behavior tests
cmux.xcodeproj/project.pbxproj, cmuxTests/SidebarWorkspaceTableTests.swift
Adds the new sources to the application and test targets and covers table configuration, height caching, cell reuse, hover resolution, and drag/drop lifecycle behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

  • manaflow-ai/cmux#8068 — Implements the same AppKit NSTableView sidebar stack and related rendering integration.
  • manaflow-ai/cmux#8241 — Overlaps directly in the AppKit sidebar table components and ContentView.swift integration.
  • manaflow-ai/cmux#8275 — Uses the same feature-gated AppKit sidebar list rendering path.

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (4 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Algorithmic Complexity ❌ Error Sidebar table hover/drop paths still rescan the full rows array (rows.indices.filter, rows.firstIndex) on each update, violating the hot-path complexity rule. Build and cache ID→index maps during apply, then reuse them for hover, drag, and drop-indicator updates instead of rescanning rows each time.
Cmux Full Internationalization ❌ Error New beta-feature strings use localized APIs, but the added xcstrings keys only include en/ja while the catalog already supports 20 locales. Add translated values for every existing catalog locale (ar, bs, da, de, es, fr, it, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, zh-Hant) for the new keys.
Cmux Architecture Rethink ❌ Error AppKit table cells still host SidebarWorkspaceGroupHeaderView, which unconditionally starts SwiftUI drags; NSTableView also owns the session, so drag lifecycle is duplicated. Gate group-header drag mounts behind sidebarPlatformListHosted (or split a dragless snapshot view) so the table controller owns drag start/end and SwiftUI only serves the lazy path.
Cmux No Test Or Debug Seam In Production Source ❌ Error FAIL: Added DEBUG-only test probes in production Sources files (reconfigurationProbe, hostingViewIdentity, lazyContractProbe) used only by cmuxTests. Move these observation hooks into cmuxTests via @testable import, or isolate them in a dedicated debug-only module/folder; keep production types free of test seams.
Description check ⚠️ Warning The description is detailed and covers the problem, change, and verification, but it omits several template sections like demo video, review trigger, and checklist. Add the missing template sections: Summary, Testing, Demo Video, Review Trigger, and Checklist, or clearly mark any inapplicable items.
Docstring Coverage ⚠️ Warning Docstring coverage is 11.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (19 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: a beta-gated AppKit NSTableView sidebar core.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New UI/AppKit types are explicitly @MainActor, pure helpers remain nonisolated, and the new DefaultsValueModel setting state is already main-actor isolated.
Cmux Swift Blocking Runtime ✅ Passed Added lines across the PR contain no semaphores, waits, sleeps, syncs, or locks; matched asyncAfter/Task.sleep calls in ContentView are preexisting unchanged code.
Cmux Browser Automation Off-Main ✅ Passed PR only changes sidebar/AppKit list and settings files; it doesn't touch TerminalController or ControlCommandExecutionPolicy, so the browser automation routing rule isn't implicated.
Cmux Expensive Synchronous Load ✅ Passed Touched sidebar/AppKit files add no agent-history loads; exact banned symbols were absent and the only load() calls were GhosttyConfig.load().
Cmux Cache Substitution Correctness ✅ Passed The new height cache is transient UI-only, has a cold fallback to estimatedHeight, and remeasures on row/content or width mismatch.
Cmux No Hacky Sleeps ✅ Passed Diff only touches Swift source; no changed TS/JS/shell/build/runtime scripts or added waits/polling, so the rule doesn’t apply.
Cmux Swift Concurrency ✅ Passed No new legacy async patterns were introduced; the added AppKit/SwiftUI/XCTest code has no new DispatchQueue/Combine/Task/completion-handler usage in the diff.
Cmux Swift @Concurrent ✅ Passed No changed Swift code adds/misuses @concurrent; the new AppKit sidebar path is synchronous and @MainActor-bound, with no nonisolated async work.
Cmux Swift Package Boundaries ✅ Passed The new app-target files are AppKit/SwiftUI bridge and composition glue; reusable core pieces stay in packages like CmuxSettings and CmuxSidebar.
Cmux Swiftpm Lockfiles ✅ Passed cmux.xcodeproj only adds source files; no SwiftPM package-reference or Package.swift/.gitignore edits, and root Xcode Package.resolved is unchanged.
Cmux Swift Logging ✅ Passed No added print/debugPrint/dump/NSLog, file-scoped Logger, or ad hoc stdout/file logging was found; the new cmuxDebugLog uses are DEBUG-gated.
Cmux User-Facing Error Privacy ✅ Passed The new beta-setting and AppKit sidebar copy is generic feature text; I found no user-facing errors, alerts, or recovery copy exposing vendor/internal details.
Cmux Swiftui State Layout ✅ Passed Uses @Observable/value snapshots and AppKit bridge views; no new ObservableObject/@published, GeometryReader layout writes, store refs in lazy rows, or render-time state mutation.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The diff adds only sidebar table/views and a test-only NSWindow fixture; no new standalone NSWindow/NSPanel/WindowGroup or auxiliary close-shortcut ownership changes appear.
Cmux Source Artifacts ✅ Passed All changed paths are intentional source/config/localization/test files; no logs, caches, scratch dirs, or other artifact paths appear in the diff.
Cmux No Ambient Global State ✅ Passed No new file-scope mutable vars, free API funcs, or singleton state were added; new helpers are scoped types/instance methods, and the EnvironmentKey is a standard boundary.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sidebar-appkit-table

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ContentView.swift`:
- Around line 10550-10600: Move the shared handlers for checklist popover
dismissal, workspace CWD revision, multi-selection hiding, and multi-selection
collapse out of the beta-specific branch and attach them once to the common
workspaceScrollArea flow. Remove the duplicate legacy handlers, while keeping
only the scrollProxy request branch-specific. Preserve the existing state
updates and notification guards in the shared handlers.

In `@Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift`:
- Around line 289-309: Replace repeated full-sidebar scans in reconfigureRows
and positionAppKitDropIndicator with ID-to-index maps maintained by apply.
Rebuild the maps whenever rows are applied or reordered, then resolve hovered,
dragged, and drop-indicator IDs through those indexes while preserving existing
visible-row reconfiguration and indicator behavior.

In `@Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift`:
- Around line 31-36: Update the height-cache preparation and cache-hit paths
around prepareHostedRows, the shared prepare method, and the prototype
measurement state so cached entries retain only reusable height data, not
previous row configurations, makeContent closures, full list snapshots, or the
last measured row. Ensure each measurement uses the current rows and releases
temporary row-content/prototype references after preparation.

In `@Sources/VerticalTabsSidebar`+WorkspaceGroups.swift:
- Around line 112-168: The group header still enables a SwiftUI drag path while
hosted by the AppKit table, causing duplicate drag handling. In
sidebarWorkspaceGroupHeader, conditionally apply
.onDrag(...).internalOnlyTabDrag() only when sidebarPlatformListHosted is false,
matching the existing workspace-row behavior and leaving NSTableView as the sole
drag owner when hosted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4cd4deb1-554a-497d-9565-889f1b2f8c26

📥 Commits

Reviewing files that changed from the base of the PR and between 25dc913 and 3ff4b53.

📒 Files selected for processing (29)
  • Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/Debug/SidebarLazyContractProbe.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableActions.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellModel.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellRootView.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellState.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableEmptyDropIndicatorView.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableHoverResolver.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCalculator.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift
  • Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift
  • Sources/SidebarWorkspaceGroupHeaderView.swift
  • Sources/SidebarWorkspaceGroupRowSnapshot.swift
  • Sources/SidebarWorkspaceRowActions.swift
  • Sources/SidebarWorkspaceRowInput.swift
  • Sources/VerticalTabsSidebar+WorkspaceGroups.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/SidebarWorkspaceTableTests.swift

Comment thread Sources/ContentView.swift
Comment on lines +10550 to +10600
.onChange(of: tabManager.selectedTabId) { _, _ in
// Workspace switches produce no outside click for .transient auto-dismiss; close popovers explicitly.
if let dismissed = checklistPopoverWorkspaceId { checklistAddFieldActivationTokens[dismissed] = nil }
checklistPopoverWorkspaceId = nil
}
.onReceive(NotificationCenter.default.publisher(for: .workspaceCurrentDirectoryDidChange)) { _ in
// Drive a revision counter that the group-header resolver
// reads. Forces SwiftUI to re-invoke `cmuxConfigStore.resolveWorkspaceGroupConfig(forCwd:)`
// when the anchor's cwd changes while the anchor is not
// the selected workspace — otherwise group color/icon/menu
// and `+` placement reflect the previous cwd until some
// unrelated sidebar event fires.
anchorCwdRevision &+= 1
}
.onReceive(NotificationCenter.default.publisher(for: SidebarMultiSelectionDidHideEvent.notificationName)) { notification in
// Group collapse hides some workspaces without changing
// focus or wiping the rest of the multi-selection. Strip
// only the hidden ids; if focus moved, make sure the new
// focused id is still represented.
guard let model = notification.object as? SidebarMultiSelectionModel,
model === tabManager.sidebarMultiSelection,
let event = SidebarMultiSelectionDidHideEvent(notification) else { return }
var next = selectedTabIds.subtracting(event.hiddenWorkspaceIds)
if let movedFocus = event.focusedWorkspaceId {
next.insert(movedFocus)
if let index = tabManager.tabs.firstIndex(where: { $0.id == movedFocus }) {
lastSidebarSelectionIndex = index
}
}
if next != selectedTabIds {
selectedTabIds = next
}
}
.onReceive(NotificationCenter.default.publisher(for: SidebarMultiSelectionShouldCollapseEvent.notificationName)) { notification in
// Keyboard nav (selectNextTab/selectPreviousTab) posts
// this so any stale Shift-click range in the sidebar's
// SwiftUI selectedTabIds collapses to just the newly-
// focused workspace. Without this, batch context-menu /
// shortcut actions would still target the stale range.
guard let model = notification.object as? SidebarMultiSelectionModel,
model === tabManager.sidebarMultiSelection,
let event = SidebarMultiSelectionShouldCollapseEvent(notification) else { return }
let focusedId = event.focusedWorkspaceId
let next: Set<UUID> = tabManager.tabs.contains(where: { $0.id == focusedId }) ? [focusedId] : []
if selectedTabIds != next {
selectedTabIds = next
}
if let index = tabManager.tabs.firstIndex(where: { $0.id == focusedId }) {
lastSidebarSelectionIndex = index
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move shared sidebar lifecycle handling above the beta branch.

These popover, CWD-revision, and multi-selection handlers duplicate the legacy branch at Lines 10663-10726. Attach the common behavior once in workspaceScrollArea; keep only the scrollProxy request branch-local to prevent the two paths drifting.

As per coding guidelines, “Do not wire the same behavior separately through multiple surfaces; use one shared action path.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ContentView.swift` around lines 10550 - 10600, Move the shared
handlers for checklist popover dismissal, workspace CWD revision,
multi-selection hiding, and multi-selection collapse out of the beta-specific
branch and attach them once to the common workspaceScrollArea flow. Remove the
duplicate legacy handlers, while keeping only the scrollProxy request
branch-specific. Preserve the existing state updates and notification guards in
the shared handlers.

Source: Coding guidelines

Comment on lines +289 to +309
private func setHoveredRowId(_ next: SidebarWorkspaceRenderItemID?) {
guard hoveredRowId != next else { return }
let previous = hoveredRowId
hoveredRowId = next
reconfigureRows(withIds: [previous, next].compactMap { $0 })
}

private func contextMenuDidOpen(rowId: SidebarWorkspaceRenderItemID) {
contextMenuRowId = rowId
}

private func contextMenuDidClose(rowId: SidebarWorkspaceRenderItemID) {
guard contextMenuRowId == rowId else { return }
contextMenuRowId = nil
recomputeHoveredRow()
}

private func reconfigureRows(withIds ids: [SidebarWorkspaceRenderItemID]) {
let idSet = Set(ids)
let indexes = IndexSet(rows.indices.filter { idSet.contains(rows[$0].id) })
reconfigureVisibleRows(indexes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Index rows instead of rescanning the full sidebar on hover and drag updates.

reconfigureRows performs an O(n) scan for each hover transition, while positionAppKitDropIndicator repeats another O(n) lookup during active-drag viewport updates. Maintain ID-to-index maps rebuilt in apply so these hot paths are O(1).

As per path instructions, production code over scalable user data must avoid repeated hot-path full-collection scans and use indexes or single-pass plans.

Also applies to: 453-457

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift` around
lines 289 - 309, Replace repeated full-sidebar scans in reconfigureRows and
positionAppKitDropIndicator with ID-to-index maps maintained by apply. Rebuild
the maps whenever rows are applied or reordered, then resolve hovered, dragged,
and drop-indicator IDs through those indexes while preserving existing
visible-row reconfiguration and indicator behavior.

Source: Path instructions

Comment on lines +31 to +36
func prepareHostedRows(
_ rows: [SidebarWorkspaceTableRowConfiguration],
columnWidth: CGFloat
) -> IndexSet {
return prepare(rows: rows, columnWidth: columnWidth, measure: measureHostedRow)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Do not retain stale row-content closures in the height cache.

A cache hit stores previous, preserving its old makeContent closure and full list snapshot. The prototype also retains its last measured row. Across staggered invalidations this can keep multiple sidebar generations and removed workspaces alive.

Proposed fix
 func prepareHostedRows(
     _ rows: [SidebarWorkspaceTableRowConfiguration],
     columnWidth: CGFloat
 ) -> IndexSet {
+    defer { prototypeView.rootView = AnyView(EmptyView()) }
     return prepare(rows: rows, columnWidth: columnWidth, measure: measureHostedRow)
 }

 if let previous, previous.matches(row: row, columnWidth: columnWidth) {
-    nextEntries[row.id] = previous
+    nextEntries[row.id] = Entry(
+        row: row,
+        columnWidth: columnWidth,
+        height: previous.height
+    )
     continue
 }

Also applies to: 64-68, 111-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift` around
lines 31 - 36, Update the height-cache preparation and cache-hit paths around
prepareHostedRows, the shared prepare method, and the prototype measurement
state so cached entries retain only reusable height data, not previous row
configurations, makeContent closures, full list snapshots, or the last measured
row. Ensure each measurement uses the current rows and releases temporary
row-content/prototype references after preparation.

Comment on lines +112 to 168
/// Builds one group-header table row configuration. Hover is AppKit-owned
/// (table tracking areas); the content factory overlays it on the
/// immutable snapshot, and context-menu open/close freezes hover in the
/// table controller.
func sidebarWorkspaceGroupTableConfiguration(
snapshot: SidebarWorkspaceGroupRowSnapshot,
renderContext: WorkspaceListRenderContext
) -> SidebarWorkspaceTableRowConfiguration {
let makeHeader: (Bool, SidebarWorkspaceTableContextMenuActions) -> SidebarWorkspaceGroupHeaderView = { isPointerHovering, contextMenuActions in
var headerSnapshot = snapshot
headerSnapshot.isPointerHovering = isPointerHovering
return sidebarWorkspaceGroupHeader(
snapshot: headerSnapshot,
onContextMenuAppear: contextMenuActions.didOpen,
onContextMenuDisappear: contextMenuActions.didClose
)
}
// Equivalence compares the immutable group snapshot directly (see the
// workspace-row configuration for why views are not built per pass).
var equivalenceSnapshot = snapshot
equivalenceSnapshot.isPointerHovering = false
let equivalenceValue = equivalenceSnapshot
return SidebarWorkspaceTableRowConfiguration(
id: .group(snapshot.groupId),
workspaceId: snapshot.anchorWorkspaceId,
groupId: snapshot.groupId,
isGroupHeader: true,
isPinned: snapshot.isPinned,
environment: renderContext.environment,
equivalenceValue: equivalenceValue
) { isPointerHovering, contextMenuActions in
AnyView(
renderContext.environment.apply(
to: makeHeader(isPointerHovering, contextMenuActions)
.equatable()
.id(snapshot.anchorWorkspaceId)
.accessibilityIdentifier("sidebarWorkspaceGroup.\(snapshot.groupId.uuidString)")
)
)
}
}

/// Assembles one group header from immutable values when the table's
/// content factory asks for it. Model references appear only inside
/// user-invoked action closures; row realization performs no observable
/// reads or mutations.
func sidebarWorkspaceGroupHeader(
snapshot: SidebarWorkspaceGroupRowSnapshot,
onContextMenuAppear: @escaping () -> Void,
onContextMenuDisappear: @escaping () -> Void
) -> SidebarWorkspaceGroupHeaderView {
let onDragStart: () -> NSItemProvider = { [anchorId = snapshot.anchorWorkspaceId] in
#if DEBUG
cmuxDebugLog("sidebar.onDrag groupAnchor=\(anchorId.uuidString.prefix(5))")
#endif
dragState.beginDragging(tabId: anchorId)
return SidebarTabDragPayload(tabId: anchorId).provider()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Disable SwiftUI dragging for AppKit-hosted group headers.

The table factory hosts SidebarWorkspaceGroupHeaderView, which still unconditionally applies .onDrag(...).internalOnlyTabDrag() at Lines 301-302. NSTableView simultaneously owns the native drag session, so dragging a group anchor can invoke two drag paths and mutate dragState twice.

Gate the group header’s SwiftUI drag modifier with sidebarPlatformListHosted, as workspace rows already do.

As per coding guidelines, “Do not wire the same behavior separately through multiple surfaces; use one shared action path.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/VerticalTabsSidebar`+WorkspaceGroups.swift around lines 112 - 168,
The group header still enables a SwiftUI drag path while hosted by the AppKit
table, causing duplicate drag handling. In sidebarWorkspaceGroupHeader,
conditionally apply .onDrag(...).internalOnlyTabDrag() only when
sidebarPlatformListHosted is false, matching the existing workspace-row behavior
and leaving NSTableView as the sole drag owner when hosted.

Source: Coding guidelines

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a flag-gated AppKit NSTableView backend for the workspace sidebar, replacing the SwiftUI LazyVStack core when sidebar.beta.appKitList.enabled is on. The flag defaults to off, leaving the existing path byte-for-byte unchanged. The new core stores row heights in an explicit cache (measured once per content × column-width pair via a prototype NSHostingView), applies a structural-vs-content differ to minimize reloads, and routes hover/drag/drop through native AppKit mechanisms rather than SwiftUI layout.

  • New AppKitList/ module (16 files): SidebarWorkspaceTableController owns the NSTableView lifecycle and explicit differ; SidebarWorkspaceTableRowHeightCache eliminates doc-height churn during scroll; SidebarWorkspaceTableDropTargetGeometryGate lazily computes reorder/bonsplit rects only while a drag is active.
  • SidebarRowLegacyListDragMounts: TabItemView's SwiftUI drag/drop mounts are wrapped in a modifier that no-ops when sidebarPlatformListHosted is set, preventing the table's native drag session from fighting SwiftUI's onDrag/onDrop handlers.
  • Test coverage in cmuxTests/SidebarWorkspaceTableTests.swift: validates height-cache invalidation, hover resolver correctness, cell-reuse identity, and drop-target geometry lifecycle, including #if DEBUG-guarded integration tests that rely on probe properties added to production types.

Confidence Score: 4/5

Safe to merge behind the flag (default off); the existing SwiftUI path is untouched. The new AppKit path has one structural issue that should be resolved before the flag is turned on by default.

The implementation is architecturally sound — the height cache, explicit differ, and drop-target gating all work correctly, and the verification numbers in the PR description are backed by concrete profiling. The one issue is that hostingViewIdentity, hostedRootIdentity, and dropTargetComputationProbe are test-only accessors hung as #if DEBUG members on production types with no non-test caller. These violate the repo's established policy against test-observability seams in Sources/; the fix (widen the backing property to internal, access via @testable import) is mechanical and small.

Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift and Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift need the test-only #if DEBUG members removed or replaced with @testable import access.

Important Files Changed

Filename Overview
Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift Core AppKit table coordinator: correct lifecycle, explicit differ, and drop-target geometry gating. Contains a #if DEBUG test-only seam (dropTargetComputationProbe) with no production caller, violating the no-test-seam rule.
Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift Reusable cell with stable NSHostingView. Two #if DEBUG accessors (hostingViewIdentity, hostedRootIdentity) are test-only seams with no production caller; should be replaced with @testable import access to the widened internal property.
Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift Explicit per-row height cache with content/width invalidation. Measurement runs synchronously on the main thread via layoutSubtreeIfNeeded() but only on cache misses (once per unique row content × column width pair), satisfying the PR's 'measured once' invariant.
Sources/Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift Lazy drop-target geometry gating: computes reorder/bonsplit rects only while a drag is active. Contains a #if DEBUG computationProbe accessible only from tests (flagged in CellView comment).
Sources/ContentView.swift Adds flag-gated workspaceTableScrollArea and workspaceTableActions branches; the legacy SwiftUI path is untouched. Closure captures of renderContext are by value and rebuilt each body pass, so drag callbacks stay fresh.
Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift Immutable per-row descriptor with type-erased equivalence. estimatedHeight allocates a struct (stack, zero-cost) as a fallback only on cache misses. makeContent factory captures renderContext by value (struct), safe.
Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift NSTableView subclass owning hover tracking, middle-click close, empty-area double-click/context-menu, and drag sources. Tracking area uses .activeAlways correctly for a sidebar that shows in non-key windows.
cmuxTests/SidebarWorkspaceTableTests.swift Comprehensive unit tests for height cache, hover resolver, cell reuse, and drop-target lifecycle. #if DEBUG-gated integration tests exercise the cell model's reconfiguration guard and NSTableView tracking-area properties.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant SUI as VerticalTabsSidebar (SwiftUI)
    participant NSVRep as SidebarWorkspaceTableView (NSViewRepresentable)
    participant Ctrl as SidebarWorkspaceTableController
    participant Cache as SidebarWorkspaceTableRowHeightCache
    participant Table as NSTableView (SidebarWorkspaceTableViewImpl)
    participant Cell as SidebarWorkspaceTableCellView

    SUI->>NSVRep: updateNSView(rows, actions, ...)
    NSVRep->>Ctrl: apply(rows, actions, workspaceIds, selection)
    Ctrl->>Cache: prepareHostedRows(nextRows, columnWidth)
    Cache-->>Ctrl: IndexSet(changedHeights)
    alt structural change (ID order differs)
        Ctrl->>Table: reloadData()
        Table->>Ctrl: numberOfRows(in:)
        Table->>Ctrl: "tableView(_:heightOfRow:) -> cache lookup"
        Table->>Ctrl: tableView(_:viewFor:row:)
        Ctrl->>Cell: configure(row, isPointerHovering, ...)
    else content-only change
        Ctrl->>Cell: configure(changed visible cells)
        Ctrl->>Table: noteHeightOfRows(withIndexesChanged:)
    end
    Table->>Ctrl: pointerDidLeaveTable() / mouseMoved
    Ctrl->>Ctrl: recomputeHoveredRow()
    Ctrl->>Cell: configure(affected cells, isPointerHovering: true/false)
    Table->>Ctrl: pasteboardWriterForRow (drag start)
    Ctrl->>SUI: actions.beginWorkspaceDrag(workspaceId)
    Ctrl->>Ctrl: dropTargetGeometry.setWorkspaceDragSessionActive(true)
    Note over Ctrl: Reorder/bonsplit rects computed lazily while drag is active
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant SUI as VerticalTabsSidebar (SwiftUI)
    participant NSVRep as SidebarWorkspaceTableView (NSViewRepresentable)
    participant Ctrl as SidebarWorkspaceTableController
    participant Cache as SidebarWorkspaceTableRowHeightCache
    participant Table as NSTableView (SidebarWorkspaceTableViewImpl)
    participant Cell as SidebarWorkspaceTableCellView

    SUI->>NSVRep: updateNSView(rows, actions, ...)
    NSVRep->>Ctrl: apply(rows, actions, workspaceIds, selection)
    Ctrl->>Cache: prepareHostedRows(nextRows, columnWidth)
    Cache-->>Ctrl: IndexSet(changedHeights)
    alt structural change (ID order differs)
        Ctrl->>Table: reloadData()
        Table->>Ctrl: numberOfRows(in:)
        Table->>Ctrl: "tableView(_:heightOfRow:) -> cache lookup"
        Table->>Ctrl: tableView(_:viewFor:row:)
        Ctrl->>Cell: configure(row, isPointerHovering, ...)
    else content-only change
        Ctrl->>Cell: configure(changed visible cells)
        Ctrl->>Table: noteHeightOfRows(withIndexesChanged:)
    end
    Table->>Ctrl: pointerDidLeaveTable() / mouseMoved
    Ctrl->>Ctrl: recomputeHoveredRow()
    Ctrl->>Cell: configure(affected cells, isPointerHovering: true/false)
    Table->>Ctrl: pasteboardWriterForRow (drag start)
    Ctrl->>SUI: actions.beginWorkspaceDrag(workspaceId)
    Ctrl->>Ctrl: dropTargetGeometry.setWorkspaceDragSessionActive(true)
    Note over Ctrl: Reorder/bonsplit rects computed lazily while drag is active
Loading

Reviews (1): Last reviewed commit: "Clamp table sizing to the proposal; comp..." | Re-trigger Greptile

Comment on lines +12 to +16
#if DEBUG
var reconfigurationProbe: (() -> Void)?
var hostingViewIdentity: ObjectIdentifier { ObjectIdentifier(hostingView) }
var hostedRootIdentity: UUID { hostingView.rootView.identity }
#endif

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Test-only #if DEBUG seams in production source

hostingViewIdentity and hostedRootIdentity are accessed exclusively in cmuxTests/SidebarWorkspaceTableTests.swift (the cellReusePreservesOneHostingViewAndStableRootIdentity test) and have zero production callers. Similarly, dropTargetComputationProbe in SidebarWorkspaceTableController (and the backing computationProbe in SidebarWorkspaceTableDropTargetGeometryGate) is only set from the #if DEBUG-guarded test suite — its #if DEBUG computationProbe?() invocation in refreshIfActive is only meaningful when a test has wired it up.

Per the no-test-debug-seam rule, the canonical fix is to widen private let hostingView to internal and read it directly from the test target via @testable import, removing the two computed shims. For the drop-target computation count, @testable import lets tests observe the controller's own rows.count or side-effects without a probe property. The reconfigurationProbe closures that feed SidebarLazyContractProbe.tableRootViewReconfigure fall in the "genuine debug facility" exception since they connect to an established contract-testing channel, but the introspection-only accessors do not.

Rule Used: Flag Swift files under a production Sources path (... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants