Skip to content

Fix File Preview hang when drag-selecting large files (#4576) - #4962

Merged
azooz2003-bit merged 3 commits into
mainfrom
issue-4576-filepreview-selection-hang
May 29, 2026
Merged

azooz2003-bit merged 3 commits into
mainfrom
issue-4576-filepreview-selection-hang

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented May 28, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4576.

A crash/hang report on cmux 0.64.9 traced to opening a large JSON file in File Preview and drag-selecting. The main thread pegged inside TextKit 2's NSTextSelectionNavigation.textSelectionsInteractingAtPoint, which is O(N) in line-fragment count per hit-test, so each mouse-drag event re-walks fragments from the document start.

File Preview (SavingTextView) hosts files up to 16 MB in a plain TextKit 2 NSTextView. The fix drops that view to TextKit 1 and enables allowsNonContiguousLayout: TextKit 1 mouse hit-testing is ~O(log N) and non-contiguous layout keeps display lazy, so large files still open instantly.

The #4576 comment noted an earlier TextKit 1 attempt regressed. That attempt omitted allowsNonContiguousLayout, which forces eager full layout. Enabling it is what keeps both selection and display fast.

Validated with a headless benchmark timing characterIndexForInsertion(at:) near the bottom of a file (the realistic "scroll down, select a few lines" case):

file TextKit 2 (before) TextKit 1 + nonContiguous (after)
60k lines 7,936 ms 6 ms
200k lines 43,327 ms 18 ms

Open-file cost stays bounded (one-time, not size-dependent) and first-viewport layout stays instant, so there is no display regression.

Two-commit structure per repo policy: commit 1 adds the failing regression test (config refactored into a shared SavingTextView.makeFilePreviewTextView() so production and test share one path), commit 2 adds the fix. The test times bottom-of-file selection hit-tests against a 1.0s ceiling that sits far from both the TextKit 2 (seconds) and TextKit 1 (milliseconds) costs, so it is a clean, non-flaky signal. It lives in the already-wired FilePreviewReviewFeedbackTests, so the cmux-unit CI suite runs it.

🤖 Generated with Claude Code


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


Summary by cubic

Fixes a hang when drag-selecting deep into large files in File Preview. Switches to TextKit 1 with non-contiguous layout so selection stays responsive, and hardens the config and tests to prevent regressions (fixes #4576).

  • Bug Fixes
    • Use TextKit 1 and set allowsNonContiguousLayout = true to avoid TextKit 2’s O(N) selection hit-tests.
    • Centralize setup in SavingTextView.makeFilePreviewTextView() and require enableLargeDocumentSelectionPerformance() before any TextKit 2 access (DEBUG assertion if violated).
    • Strengthen the regression test with a geometry precondition to avoid false negatives; observed speed: 60k lines 7.9s -> 6ms, 200k lines 43s -> 18ms.

Written for commit 6364c21. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • Performance

    • Improved responsiveness when selecting and hit-testing deep into large file previews by enabling optimized large-document layout and centralized text-view configuration.
  • Tests

    • Added a regression test that verifies selection/hit-testing remains responsive for very large document previews.

Review Change Stack

azooz2003-bit and others added 2 commits May 28, 2026 18:05
File Preview hosts files up to 16 MB in a plain TextKit 2 NSTextView.
TextKit 2's NSTextSelectionNavigation hit-tests are O(N) in line-fragment
count, so drag-selecting deep into a large file pegs the main thread
(#4576). A real hang report on cmux 0.64.9 traced to
opening a large JSON file in File Preview.

Extract the text-view configuration into SavingTextView.makeFilePreviewTextView()
so production and the test share one path, then add a regression test that
times selection hit-testing near the bottom of a 60k-line file. With the
current TextKit 2 configuration this takes several seconds and fails the
1.0s ceiling; the next commit makes it pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ous layout

Drop the File Preview text view to TextKit 1 and enable non-contiguous
layout. TextKit 2's NSTextSelectionNavigation hit-tests are O(N) in
line-fragment count, so drag-selecting deep into a large file pegged the
main thread (#4576). TextKit 1 hit-testing is ~O(log N),
and non-contiguous layout keeps display lazy so large files still open
instantly.

Validated with headless benchmarks (characterIndexForInsertion at the
bottom of a file): 60k lines 7936ms -> 6ms, 200k lines 43327ms -> 18ms.
An earlier TextKit 1 attempt regressed because it omitted
allowsNonContiguousLayout, forcing eager full layout; enabling it is what
keeps both selection and display fast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented May 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 29, 2026 1:48am
cmux-staging Ready Ready Preview, Comment May 29, 2026 1:48am

@coderabbitai

coderabbitai Bot commented May 28, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1dbbba9e-8078-4a6a-8389-fa6ebf8c71cd

📥 Commits

Reviewing files that changed from the base of the PR and between 0efba87 and 6364c21.

📒 Files selected for processing (2)
  • Sources/Panels/FilePreviewTextEditor.swift
  • cmuxTests/FilePreviewReviewFeedbackTests.swift

📝 Walkthrough

Walkthrough

FilePreviewTextEditor now obtains a preconfigured SavingTextView from SavingTextView.makeFilePreviewTextView(), which enables non-contiguous layout on the layout manager before textContainer access; a regression test verifies hit-testing stays responsive on ~60k-line documents.

Changes

Large Document Selection Performance Fix

Layer / File(s) Summary
Factory and layout performance helper
Sources/Panels/FilePreviewTextEditor.swift
Adds SavingTextView.makeFilePreviewTextView() and NSTextView.enableLargeDocumentSelectionPerformance() to centralize large-plain-text configuration and set layoutManager.allowsNonContiguousLayout before any textContainer access.
FilePreviewTextEditor integration
Sources/Panels/FilePreviewTextEditor.swift
FilePreviewTextEditor.makeNSView() now uses SavingTextView.makeFilePreviewTextView() instead of inline text-view configuration.
Large-file selection responsiveness test
cmuxTests/FilePreviewReviewFeedbackTests.swift
Adds testLargeFileSelectionHitTestStaysResponsive() which generates a ~60k-line buffer, forces layout, performs repeated characterIndexForInsertion hit-tests near the bottom, and asserts completion under 1.0s.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through lines both wide and deep,
I nudged the layout awake from sleep;
Non‑contiguous paths now let me leap,
Hit-tests sprint where once they'd creep —
Sixty thousand rows and the rabbit's pleased.

🚥 Pre-merge checks | ✅ 17 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix: preventing a hang when drag-selecting large files in File Preview, with the specific issue number (#4576) for reference.
Description check ✅ Passed The description covers the problem summary, root cause analysis, proposed solution, validation with benchmarks, test strategy, and expected outcomes. It provides sufficient context for review.
Linked Issues check ✅ Passed The PR directly addresses issue #4576 by replacing TextKit 2 with TextKit 1 and enabling allowsNonContiguousLayout to fix the O(N) selection hit-test hang during drag-selection on large files.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the reported hang: refactoring FilePreviewTextEditor to use a factory method, centralizing configuration, enabling TextKit 1 with non-contiguous layout, and adding a regression test.
Cmux Swift Actor Isolation ✅ Passed FilePreviewTextEditor.swift correctly implements Swift 6 actor isolation: @MainActor protocol, NSViewRepresentable struct (implicitly MainActor), NSTextView subclass. No violations found.
Cmux Swift Blocking Runtime ✅ Passed No blocking primitives in production code. Test uses systemUptime clock read for deterministic regression measurement, permitted by rules.
Cmux No Hacky Sleeps ✅ Passed Check applies only to TypeScript, JavaScript, shell, and build/runtime scripts. This PR modifies only Swift files, which are explicitly excluded and covered by a separate check.
Cmux Algorithmic Complexity ✅ Passed PR fixes O(N)→O(log N) algorithmic complexity by switching to TextKit 1 with non-contiguous layout. Test adds only fixed-size loop (0..<20) and benchmark scaffolding, both allowed per rules.
Cmux Swift Concurrency ✅ Passed No DispatchQueue, fire-and-forget Tasks, new completion handlers, or new Combine patterns introduced. @ObservedObject is pre-existing codebase pattern. Tests properly use async/await.
Cmux Swift @Concurrent ✅ Passed FilePreviewTextEditor.swift functions are synchronous/UI-bound; test helpers are private async coordination staying on MainActor with no heavy I/O.
Cmux Swift File And Package Boundaries ✅ Passed FilePreviewTextEditor.swift is 271 lines with 40 lines added for TextKit 1 fix, under 400-line threshold for new files, focused UI/AppKit responsibility, no mixed concerns.
Cmux Swift Logging ✅ Passed No prohibited logging (print, debugPrint, dump, NSLog) in production code. Only DEBUG-only assertionFailure guard in new method, which is allowed per swift-logging.md rules.
Cmux User-Facing Error Privacy ✅ Passed No user-facing error privacy violations. Production code contains only a DEBUG-only assertionFailure message with no restricted content. Test assertions are developer-only.
Cmux Full Internationalization ✅ Passed Production changes add only DEBUG-mode assertionFailure strings; test changes contain only test fixtures and XCTest infrastructure. No user-facing strings, Info.plist, or string catalogs modified.
Cmux Swiftui State Layout ✅ Passed FilePreviewTextEditor NSViewRepresentable is allowed AppKit bridge. No new @Observable/@Published/@StateObject, GeometryReader, or render-time state mutations.
Cmux Architecture Rethink ✅ Passed Fixes TextKit 2 O(N) hang by switching to TextKit 1. No timing patches, flags, or observers. Single factory, clear invariant. Meets allowed correctness-fix criteria.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR modifies text view config in FilePreviewTextEditor.swift but adds no standalone NSWindow/NSPanel/WindowGroup. Only NSWindow usage is existing test fixture (allowed).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 issue-4576-filepreview-selection-hang

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 and usage tips.

@greptile-apps

greptile-apps Bot commented May 28, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the drag-selection hang in File Preview by switching SavingTextView from TextKit 2 to TextKit 1 with allowsNonContiguousLayout = true, reducing selection hit-test cost from O(N) to O(log N). Configuration is centralized in a new makeFilePreviewTextView() factory so production and test code share a single, ordering-correct setup path.

  • TextKit downgrade: enableLargeDocumentSelectionPerformance() accesses layoutManager before any TextKit 2 property, forcing TextKit 1 mode; allowsNonContiguousLayout keeps glyph layout lazy so large files still open instantly.
  • Factory pattern: Inline text-view configuration in makeNSView is extracted to SavingTextView.makeFilePreviewTextView(), ensuring enableLargeDocumentSelectionPerformance() always runs before textContainer accesses.
  • Regression test: testLargeFileSelectionHitTestStaysResponsive times 20 bottom-of-file characterIndexForInsertion calls against a 1.0 s ceiling that sits cleanly between the TextKit 2 (seconds) and TextKit 1 (milliseconds) regimes.

Confidence Score: 5/5

The change is safe to merge: the factory pattern guarantees call order, the production path is straightforward, and the regression test provides a clean before/after signal well-separated from both TextKit regimes.

The fix is targeted — accessing layoutManager before any TextKit 2 property is the documented, correct way to force TextKit 1 compatibility mode on macOS 12+, and the factory method makes that ordering invariant hard to violate accidentally. No new actor isolation, blocking primitives, or complexity regressions are introduced. The moved configuration is identical to what was inline before, so display behavior is unchanged.

No files require special attention; both changed files are self-contained and straightforward.

Important Files Changed

Filename Overview
Sources/Panels/FilePreviewTextEditor.swift Extracts text-view setup into a factory method that enforces TextKit 1 mode (via layoutManager access) before any textContainer touch, and enables allowsNonContiguousLayout; call-site in makeNSView is correspondingly simplified.
cmuxTests/FilePreviewReviewFeedbackTests.swift Adds a timing-based regression test that exercises bottom-of-file hit-testing on a 60k-line document; uses sizeToFit() to realize layout in a windowless context and asserts elapsed < 1.0s as a mode-detection signal.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["makeFilePreviewTextView()"] --> B["SavingTextView()"]
    B --> C["enableLargeDocumentSelectionPerformance()\naccess layoutManager → forces TextKit 1"]
    C --> D{layoutManager\nnon-nil?}
    D -- Yes --> E["allowsNonContiguousLayout = true\nLazy glyph layout enabled"]
    D -- No\n(TK2 already locked) --> F["Silent no-op\n(misuse: TK2 already accessed)"]
    E --> G["Configure textContainer,\nresizing, font, insets"]
    G --> H["Return SavingTextView\n(TextKit 1, non-contiguous)"]
    H --> I["makeNSView: set drawsBackground,\nstring, delegate, attach to scroll view"]
Loading

Reviews (2): Last reviewed commit: "Harden TextKit 1 opt-in and selection re..." | Re-trigger Greptile

Comment on lines +158 to +160
func enableLargeDocumentSelectionPerformance() {
layoutManager?.allowsNonContiguousLayout = true
}

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 Silent no-op leaves the bug unfixed

layoutManager?.allowsNonContiguousLayout = true uses optional chaining. The method's own docstring says that if any TextKit 2 property (textLayoutManager, textContainer, etc.) is accessed before this call, layoutManager returns nil — and the optional silently short-circuits without setting allowsNonContiguousLayout. That means the view stays in TextKit 2, the entire performance fix silently doesn't apply, and the hang from #4576 persists with no indication. An assert(layoutManager != nil, "layoutManager is nil; enableLargeDocumentSelectionPerformance must be called before any TextKit 2 access") (or even a non-optional dereference) would catch this ordering violation at the call site instead of silently regressing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in the latest commit: replaced the optional chaining with guard let + assertionFailure, so a call-order violation now fails loudly in DEBUG while release behavior stays a no-op.

— Claude Code

Comment on lines +412 to +418
textView.sizeToFit()

let bottomY = max(textView.bounds.height - 5, 1)
let start = ProcessInfo.processInfo.systemUptime
for offset in 0..<20 {
_ = textView.characterIndexForInsertion(at: CGPoint(x: 200, y: bottomY - CGFloat(offset)))
}

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 Headless sizeToFit() may yield zero bounds, making the test a false negative

The text view has no window and no initial frame. With allowsNonContiguousLayout = true, NSLayoutManager performs lazy on-demand layout and may not realize the full document during sizeToFit(). If textView.bounds.height remains 0 (or near-zero), bottomY clamps to 1, and all 20 characterIndexForInsertion calls land near y ≤ 1 — the very top of the document, where TextKit 2 is also O(1). The test would then always pass regardless of whether the TextKit 1 downgrade succeeded, turning the regression guard into a no-op. A safer approach would call layoutManager?.ensureLayout(forCharacterRange: NSRange(location: 0, length: textView.textStorage?.length ?? 0)) before checking bounds.height, or assert bounds.height > 1000 as a precondition so the test fails fast when the geometry precondition isn't met.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed: added a geometry precondition (XCTAssertGreaterThan(bounds.height, 100_000)) so the test fails loudly if headless layout collapses instead of silently passing. Deliberately not realizing layout via layoutManager/ensureLayout, since accessing it would force a TextKit mode in the test view and stop the test from ever catching a regression back to TextKit 2. sizeToFit is mode-neutral and empirically realizes full layout (~820k pt for 60k lines).

— Claude Code

@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: 1

🤖 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/Panels/FilePreviewTextEditor.swift`:
- Around line 158-160: enableLargeDocumentSelectionPerformance currently
silently no-ops when layoutManager is nil; change its implementation to guard
that layoutManager is non-nil, call allowsNonContiguousLayout = true on the
unwrapped layoutManager, and call assertionFailure (or similar) if nil so DEBUG
builds fail loudly while keeping release behavior unchanged; update the function
enableLargeDocumentSelectionPerformance and reference layoutManager and its
allowsNonContiguousLayout property when making this change.
🪄 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: 007ff713-6b35-4d06-ba4d-6b761509d54f

📥 Commits

Reviewing files that changed from the base of the PR and between 0bd42ab and 0efba87.

📒 Files selected for processing (2)
  • Sources/Panels/FilePreviewTextEditor.swift
  • cmuxTests/FilePreviewReviewFeedbackTests.swift

Comment thread Sources/Panels/FilePreviewTextEditor.swift
Address CodeRabbit and Greptile findings on #4962:

- enableLargeDocumentSelectionPerformance() previously used optional
  chaining (layoutManager?...), which would silently no-op and reintroduce
  the hang if it ever ran after a TextKit 2 access. Replace with guard let +
  assertionFailure so the call-order violation fails loudly in DEBUG; release
  behavior is unchanged.
- The regression test could be a false negative if headless sizeToFit left
  bounds.height near zero, putting all hit-tests at the cheap top of the doc.
  Add a geometry precondition (bounds.height > 100_000) so it fails loudly
  instead of passing trivially. Layout is realized via mode-neutral sizeToFit,
  not layoutManager access, which would force a TextKit mode and defeat the
  regression signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 6364c21e Deployed May 29, 2026 by vercel[bot]
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.

Drag-selecting a many-line document hangs the main thread at 100% CPU — NSTextSelectionNavigation.textSelectionsInteractingAtPoint O(N) per hit-test

1 participant