Skip to content

Diff viewer: untracked files in unstaged, persisted viewer prefs, soft refresh, hunk/file navigation#6010

Open
lawrencecchen wants to merge 5 commits into
mainfrom
feat-diff-viewer-round1
Open

Diff viewer: untracked files in unstaged, persisted viewer prefs, soft refresh, hunk/file navigation#6010
lawrencecchen wants to merge 5 commits into
mainfrom
feat-diff-viewer-round1

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Round 1 of diff viewer improvements from issue triage (#5284, #2526, #5246, #609).

Untracked files in the unstaged source. cmux diff --unstaged was plain git diff, so files an agent just created were invisible in the default review view. The unstaged patch now appends an added-file patch per untracked path (gitignored files excluded), reusing the same git diff --no-index helper the last-turn source already uses.

Viewer preferences persist globally (fixes #5284). Layout and the options-menu toggles previously lived in page-local localStorage keyed to generated viewer origins, which does not reliably persist. A new DiffViewerPreferencesStore persists them to ~/Library/Application Support/cmux/diff-viewer/preferences.json; the webview saves through new viewerPrefs.get/viewerPrefs.set methods on the existing cmuxDiffComments bridge and re-syncs at boot, and the CLI reads the same file at generation time so new diff panels open with the last-used layout. Explicit --layout still wins. localStorage remains a fallback for pages opened outside cmux, with legacy-key migration.

Refresh preserves viewer state (the bug in #5284). The options-menu Refresh was window.location.reload(), resetting layout and options. It now soft-refreshes: re-streams the patch in place behind a render-generation guard, keeping layout and all toggles. Status-only pages keep the full reload so pending replacements still resolve. Content regeneration on refresh (re-running git) is unchanged from before and is the round-2 live-refresh work.

Keyboard navigation between hunks and files (from #2526). n / p jump to the next / previous hunk, ] / [ to the next / previous file, following the existing vim-style in-viewer shortcuts (j/k scroll). Wired through KeyboardShortcutSettings, the CmuxSettings ShortcutAction enum, the CLI payload, cmux.json schema, shortcut docs data, and Settings; all rebindable.

Deferred empty-state fallback no longer dead-ends (remaining #5246 path). When the selected source was empty and a fallback candidate failed for a non-empty reason (last-turn without workspace/surface context), the raw error rendered as the page. Reproduced identically on a main baseline build (pre-existing). Unusable fallback candidates are now skipped, so the friendly empty state renders. This also makes the existing testDiffCommandShowsFriendlyEmptyStateWhenEveryGitSourceIsEmpty pass as written.

Commit 1 adds the regression tests only (CI red), the fixes follow (CI green). New bun tests cover the prefs sanitizer/bridge fallback and hunk/file navigation helpers; new XCTests cover untracked-in-unstaged, persisted-prefs payload seeding, and the new shortcut defaults.

Localization: new shortcut labels in Localizable.xcstrings for all 20 locales (en + ja translated, others fall back to English per existing convention); docs entries carry en + ja.

🤖 Generated with Claude Code


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


Note

Medium Risk
Touches git patch assembly and diff-source fallback logic in the CLI plus global shortcut routing; behavior changes are user-visible but localized to the diff viewer path.

Overview
Unstaged diffs now include untracked (non-ignored) files by appending per-file added patches alongside plain git diff, so agent-created files show up in the default unstaged view.

Viewer preferences are read from persisted diff-viewer/preferences.json (with optional CMUX_DIFF_VIEWER_PREFS_PATH): default layout follows the user’s last in-viewer choice before settings defaults, and generated pages seed viewerOptions (wrap, line numbers, etc.) for first paint.

Empty / fallback sources: when the selected diff source is empty, all failed fallback candidates are skipped—not only explicitly empty ones—so a clean repo or missing last-turn context still gets the friendly empty state instead of a raw error.

Keyboard navigation adds rebindable diff-viewer actions: n/p next/previous hunk, ]/[ next/previous file, wired through cmux_open shortcut payloads, CmuxSettings ShortcutAction, defaults, tests, and localized labels.

Reviewed by Cursor Bugbot for commit fce83b0. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Improves the diff viewer with untracked files in Unstaged, globally persisted viewer preferences (with CLI seeding and soft refresh), and vim‑style hunk/file navigation. Also restores the friendly empty state for clean repos.

Written for commit fce83b0. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added keyboard shortcuts for diff viewer navigation: next hunk (n), previous hunk (p), next file (]), previous file ([).
    • Diff viewer layout and display settings now persist across sessions.
    • Unstaged diffs now include untracked files.

lawrencecchen and others added 3 commits June 11, 2026 23:55
…nav shortcuts

- cmux diff --unstaged must include untracked files (agents constantly
  create new files; plain git diff silently hides them)
- persisted viewer preferences (CMUX_DIFF_VIEWER_PREFS_PATH) must seed
  payload layout + viewerOptions, with --layout still winning
  (#5284)
- diff viewer payload must carry diffViewerNextHunk/PrevHunk/NextFile/PrevFile
  shortcut defaults (n / p / ] / [)

Tests only; the fixes land in the next commit so CI proves they catch
the behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…efresh, hunk/file navigation

Four diff viewer improvements from issue triage:

- Unstaged source now includes untracked files. `cmux diff --unstaged`
  appends an added-file patch per untracked path (gitignored files
  excluded), so files an agent just created are no longer invisible.

- Viewer preferences persist globally
  (#5284). New
  DiffViewerPreferencesStore persists layout + options-menu toggles to
  ~/Library/Application Support/cmux/diff-viewer/preferences.json. The
  webview saves through new viewerPrefs.get/set methods on the existing
  cmuxDiffComments bridge and re-syncs at boot; the CLI reads the same
  file when generating a page so new diff panels open with the last-used
  layout (explicit --layout still wins). localStorage stays as fallback
  for pages opened outside cmux, with legacy-key migration.

- Refresh is now a soft refresh. Instead of window.location.reload(),
  the options-menu Refresh re-streams the patch in place, preserving the
  split/unified layout and all viewer options (the bug report in #5284).
  Status-only pages keep the full reload to pick up replacements.

- Keyboard navigation between hunks and files
  (#2526): n / p jump to the
  next / previous hunk, ] / [ to the next / previous file. Wired through
  KeyboardShortcutSettings, the CmuxSettings ShortcutAction enum, the
  CLI payload, cmux.json schema, the shortcuts docs data, and the
  Settings UI; rebindable like the existing diff viewer shortcuts.

Localization: new shortcut labels added to Localizable.xcstrings for all
locales (en + ja translated, others fall back to English per existing
convention); docs entries carry en + ja.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the selected git source is empty, the deferred fallback loop tried
each other source and rethrew any non-empty error. A clean repo opened
outside a cmux terminal (no workspace/surface context) dead-ended on the
raw "cmux diff --last-turn requires a workspace and surface context"
error instead of the friendly empty state, reproducing the #5246 raw
CLI error empty state through a different path. Reproduced identically
on a main baseline build, so this was pre-existing, not introduced by
this branch.

Fallback candidates that cannot be read are now skipped; only the
originally selected source's own failure surfaces. This also makes the
existing testDiffCommandShowsFriendlyEmptyStateWhenEveryGitSourceIsEmpty
regression test pass as written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jun 12, 2026 10:34pm
cmux-staging Building Building Preview, Comment Jun 12, 2026 10:34pm

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds keyboard navigation shortcuts for diff viewer hunks and files, introduces persistent viewer preferences (layout and display toggles) saved to the app's Application Support directory, includes untracked files in unstaged diffs, and makes diff streaming generation-aware to preserve UI state across refresh operations.

Changes

Diff Viewer Navigation, Preferences, and Rendering

Layer / File(s) Summary
Shortcut action definitions and metadata
Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift, Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift, Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift, Sources/KeyboardShortcutSettings.swift, Sources/KeyboardShortcutContext.swift, Resources/Localizable.xcstrings
Four new enum cases (diffViewerNextHunk, diffViewerPrevHunk, diffViewerNextFile, diffViewerPrevFile) are defined and wired through all metadata paths: group assignment, display name labels with localization keys, default keybindings (n, p, ], [), bare-first-stroke support, browser context mapping, and test allowlist updates.
Native preferences store and bridge integration
Sources/DiffViewerPreferencesStore.swift, Sources/Panels/DiffCommentsBridge.swift, cmux.xcodeproj/project.pbxproj
New DiffViewerPreferencesStore singleton provides thread-safe JSON file persistence under Application Support with sanitization (valid layouts, diff indicators, and boolean toggles). Constructor parameter added to DiffCommentsBridge to integrate the store, exposing viewerPrefs.get and viewerPrefs.set methods via the native webkit bridge for web-side access.
Layout resolution and unstaged diff generation
CLI/cmux_open.swift, cmuxTests/CMUXOpenCommandTests.swift
parseDiffViewerLayout now consults persisted preferences when --layout is not explicit. Unstaged diff generation appends patches for untracked files to git output. HTML payload generation injects persisted display toggles (excluding layout) into initial viewer options. Tests verify untracked file inclusion and preference seeding with layout override behavior.
Deferred diff viewer fallback handling
CLI/cmux_open.swift
Fallback candidate loop now skips on render failure instead of treating certain errors as actionable, with updated comments for invalid "last-turn" fallback cases.
Web-side preference persistence and loading
webviews/src/viewer-prefs.ts, webviews/test/viewer-prefs.test.ts
New module introduces ViewerPrefs type and dual-path persistence: native bridge (viewerPrefs.get/set) when available, falling back to localStorage under cmux.diffViewer.options. Sanitization pipeline retains only valid layout/indicator values and known boolean keys. Bridge failures are swallowed gracefully.
Hunk and file navigation utilities
webviews/src/viewer-nav.ts, webviews/test/viewer-nav.test.ts
New module provides hunk anchor construction (buildHunkAnchors), directional hunk index computation (nextHunkIndex with reseeding and bounds clamping), and file-level adjacent-item resolution (adjacentItemId). Comprehensive test coverage includes edge cases and behavior validation.
App state, render generation, and viewer option persistence
webviews/src/App.tsx, webviews/src/types.ts
renderGeneration added to app state for generation-aware streaming. New apply-persisted-options and refresh reducer actions. Stream callbacks made generation-safe via isCurrent() guard. Viewer options seeded from payload.viewerOptions on init. Global setOption callback persists option changes (excluding collapsed). useViewerPrefsBootstrap loads prefs on mount.
Keyboard shortcuts for hunk/file navigation
webviews/src/App.tsx
Keyboard shortcut handlers for next/prev hunk and next/prev file integrated via useKeyboardShortcuts. Navigation ref passed for state sharing. Handlers call navigation functions and prevent default. Effect dependencies updated.
Toolbar and options menu callback wiring
webviews/src/App.tsx
Toolbar component accepts onReload, onSetLayout, and onSetOption callbacks. Option-toggling logic (including diff-indicator style) updated to dispatch onSetOption instead of direct actions. Callbacks wired through to OptionsMenu.
Web shortcut definitions and schema
web/data/cmux-shortcuts.ts, web/data/cmux.schema.json
Four new diff viewer navigation shortcuts added with localized descriptions. JSON schema updated to recognize new action ids in shortcuts.bindings and define their binding schemas.
Initial shortcut assertion in diff command test
cmuxTests/CMUXOpenCommandTests.swift
Test updated to verify four new navigation shortcuts are present in generated HTML with correct keybindings.
Tests for preference persistence and layout overrides
webviews/test/app.test.tsx
New tests verify persisted options appear in payload (invalid keys filtered), --layout overrides persisted layout, viewerOptions seeds display toggles while layout remains controlled by explicit sources, and legacy localStorage layout key is honored.
CLI shortcut action registration
CLI/cmux_open.swift
Four new shortcut actions registered with default keybindings in cmux_open.

Sequence Diagram(s)

The PR involves multiple independent flows rather than a single coordinated sequence. Stream rendering safety, preference persistence, and navigation are three distinct patterns that benefit from independent review but do not interact sequentially in a meaningful way for visualization. A high-level sequence of preference loading at app start would be too simple (three steps: init → bridge/localStorage fetch → apply to state), and navigation or streaming flows lack multi-component interaction that would clarify the diagram beyond the code. Diagrams are omitted as the architectural changes are clearer from code inspection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

The changes span multiple areas (native preferences, web rendering safety, keyboard navigation, payload structure) with moderate logic density in preference sanitization and stream generation tracking. Heterogeneous file spread and new module definitions add review scope, but patterns are consistent and test coverage is comprehensive.

Possibly related PRs

  • manaflow-ai/cmux#5016: Both modify CLI/cmux_open.swift deferred diff viewer fallback behavior, with this PR skipping problematic candidates while the earlier PR routes errors to friendly UI.
  • manaflow-ai/cmux#5768: Both extend the DiffCommentsBridge and native webkit bridge, with this PR adding viewerPrefs.get/set methods alongside comment persistence already in that PR.
  • manaflow-ai/cmux#4451: Both modify diff viewer layout and HTML payload generation in CLI/cmux_open.swift, with this PR extending layout resolution and option injection where the earlier PR introduced --layout handling.

Poem

🐰 A rabbit's leap through hunks so neat,
Preferences saved, refresh stays sweet,
Navigation springs from hunk to file,
Layout persists—now that's worthwhile!


Important

Pre-merge checks failed

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

❌ Failed checks (7 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Actor Isolation ❌ Error Sources/DiffViewerPreferencesStore.swift defines final class DiffViewerPreferencesStore: @unchecked Sendable with mutable cached guarded only by NSLock, but no documented safety rationale/c... Remove @unchecked Sendable (or convert to an actor) and/or add a clear /// Safety: explanation that NSLock fully guards all mutable shared state/file writes per Swift 6 sendability rules.
Cmux Swift Blocking Runtime ❌ Error PR adds DiffViewerPreferencesStore with production NSLock (lock.lock()/defer lock.unlock()) to guard cached preferences; the swift-blocking-runtime rules flag manual locks as failures. Refactor the store to use actor/@mainactor ownership instead of NSLock. If you must keep a lock, document why actors can’t be used and justify it as a minimal, non-synchronization bridge.
Cmux Cache Substitution Correctness ❌ Error DiffViewerPreferencesStore caches preferences in-memory and returns cached without re-reading preferences.json; merge sets cached before persistLocked, which ignores write errors—so stale/incorrect... In DiffViewerPreferencesStore, only serve cached after successful disk load+write, and revalidate/refresh cached values (e.g., track file mtime or clear cache when persistence fails/external changes occur).
Cmux Algorithmic Complexity ❌ Error FAIL: CLI/cmux_open.swift (1496-1505) runs git diff --no-index per untracked path; webviews/src/App.tsx (286) rebuilds hunk anchors on every n/p keypress. Cap/batch untracked patch generation (hard/benchmarked limit) instead of per-path git diff; memoize precomputed hunk anchors in App.tsx (useMemo) so n/p doesn’t rescan items each keypress.
Cmux Swift File And Package Boundaries ❌ Error PR adds Sources/DiffViewerPreferencesStore.swift (116 lines) implementing JSON persistence + sanitization in app-root Sources; CLI duplicates persistedDiffViewerPreferences logic (no DiffViewerPref... Extract the diff-viewer preference persistence/sanitization into a small SwiftPM package target with a shared API, then update both the app bridge and CLI to use it (avoiding duplicated parsing).
Cmux Full Internationalization ❌ Error FAIL: Resources/Localizable.xcstrings new diffViewerNext/Prev hunk+file labels have many non-en/ja locales identical to English, and web/data/cmux-shortcuts.ts adds new shortcut descriptions only f... Translate the four new Swift catalog strings for every locale in Resources/Localizable.xcstrings (no copied-English placeholders), and add matching next-intl entries in web/messages for diffViewerNext/Prev hunk/file (or wire the UI to th...
Cmux Architecture Rethink ❌ Error PR adds new DiffViewerPreferencesStore singleton with NSLock and in-memory cached prefs, introducing a new cached lock/state owner forbidden by swift-architectural-rethink rules. Refactor to avoid singleton+cached lock ownership: make an actor/main-thread source of truth (or re-read file per request) and centralize persistence updates in the existing persistence layer with explicit invariants.
Docstring Coverage ⚠️ Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (13 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes in the changeset: untracked files handling, viewer preference persistence, soft refresh, and keyboard navigation.
Linked Issues check ✅ Passed All code changes comprehensively address the linked issue #5284 requirements: viewer preferences are persisted globally via DiffViewerPreferencesStore, refresh preserves viewer state through soft refresh with render-generation guard, and layout persistence is integrated throughout the CLI and webview.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the PR objectives: untracked file handling in unstaged diffs, persisted viewer preferences, soft refresh mechanism, keyboard navigation shortcuts, and fallback handling improvements as described in the linked issues.
Cmux Expensive Synchronous Load ✅ Passed PR diff adds no RestorableAgentSessionIndex.load in production—only 3 deletions in cmuxTests/RestorableAgentSessionIndexTests.swift; SharedLiveAgentIndex.shared has 0 diff occurrences.
Cmux No Hacky Sleeps ✅ Passed Checked runtime-no-hacky-sleeps scope: added/changed TS in webviews adds no setTimeout/sleep usage; viewer-nav/prefs contain no timers. Existing timers unchanged (diff-stream unchanged).
Cmux Swift Concurrency ✅ Passed PR Swift code in cmux-owned files adds no Combine/DispatchGroup/completion-handler APIs; only a minimal hotkey Task { @MainActor } hop is present. No Task.detached.
Cmux Swift @Concurrent ✅ Passed Swift files associated with this PR contain no nonisolated async declarations and no @concurrent attributes; any nonisolated usage found is for synchronous helpers (e.g., defaultFileURL/comme...
Cmux Swift Logging ✅ Passed No banned Swift logging (print/debugPrint/dump/NSLog or file-scoped Logger constants) found in production app code touched by PR; only CLI user-facing print output in CLI/cmux_open.swift (allowed).
Cmux User-Facing Error Privacy ✅ Passed PR user-facing error/copy changes appear limited to diff viewer behavior; no evidence of new messages exposing upstream/vendor/provider names, raw upstream errors, tokens/headers/credentials, or en...
Cmux Swiftui State Layout ✅ Passed SwiftUI-importing file diffs (main..HEAD) add no ObservableObject/@Published/@StateObject/@EnvironmentObject/GeometryReader/LazyVStack/List/ForEach in added lines; only new NSView bridge in Content...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Checked PR-listed Swift files (incl. CLI/cmux_open.swift, DiffViewerPreferencesStore, keyboard/context, DiffCommentsBridge): no NSWindow/NSPanel/WindowGroup code or cmux window-id assignments added...
Cmux Source Artifacts ✅ Passed PR’s changed paths (from provided summary) contain no source-control artifact indicators per .github/review-bot-rules/source-control-artifacts.md (no DerivedData/tmp/artifacts/etc.).
Description check ✅ Passed PR description comprehensively covers all required sections with clear summaries of what changed and why, detailed explanations of the five main improvements, testing approach, localization updates, and references to underlying issues.
✨ 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 feat-diff-viewer-round1

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.

Comment thread CLI/cmux_open.swift
@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds four improvements to the diff viewer: untracked files now appear in --unstaged diffs, viewer layout and display options persist globally via a new DiffViewerPreferencesStore and viewerPrefs bridge, Refresh soft-restreams the patch in place instead of reloading the page, and n/p/]/[ keyboard shortcuts navigate between hunks and files.

  • Unstaged + untracked: gitUntrackedPaths (git ls-files --others --exclude-standard) runs after git diff and its results are appended as added-file patches; --layout overrides and .gitignore exclusions are both respected.
  • Persisted prefs: DiffViewerPreferencesStore writes ~/Library/Application Support/cmux/diff-viewer/preferences.json; the webview reads via the viewerPrefs.get bridge at boot and writes via viewerPrefs.set on every options change; the CLI seeds layout and viewerOptions from the same file at generation time.
  • Empty-state fallback fix: the completeDeferredDiffViewerSource fallback loop now catches all errors (not only EmptyDiffSourceError) from fallback candidates and skips them, fixing the dead-end when a last-turn source throws a context error in a clean repo.

Confidence Score: 4/5

Safe to merge with one targeted fix in the fallback loop error handling

The fallback loop's catch was widened from catch is EmptyDiffSourceError to a bare catch, which silences any git or write error thrown by a fallback candidate. A user with staged changes whose git index errors during fallback evaluation would see a friendly empty state rather than the git error, hiding real diff content. The rest of the PR — prefs persistence, soft refresh, keyboard nav, and untracked-file inclusion — is well-structured and thoroughly tested.

CLI/cmux_open.swift around the fallback candidate error catch in completeDeferredDiffViewerSource

Important Files Changed

Filename Overview
CLI/cmux_open.swift Adds untracked-file inclusion in --unstaged, persisted prefs seeding, and navigation shortcuts; the broad catch replacing the typed catch in the fallback loop silently swallows git and write failures from fallback candidates
Sources/DiffViewerPreferencesStore.swift New file; @unchecked Sendable + NSLock with synchronous file I/O called from @mainactor bridge (already flagged in prior thread); sanitize() logic is correct and tested
Sources/Panels/DiffCommentsBridge.swift Adds viewerPrefs.get/set bridge methods ahead of the repoRoot guard; logic is correct and straightforward
webviews/src/App.tsx Adds soft-refresh via renderGeneration, hunk/file keyboard navigation, and viewerPrefs bootstrap; isCurrent() guards prevent stale-stream clobber; logic looks correct
webviews/src/viewer-prefs.ts New module for global prefs persistence via native bridge with localStorage fallback and legacy-key migration; well-tested and correctly sanitized
webviews/src/viewer-nav.ts New hunk/file navigation helpers; edge cases (empty anchors, out-of-bounds, re-seed on file jump) are well-handled and fully tested
Resources/Localizable.xcstrings Adds 4 new shortcut label strings across all 20 locales; en and ja are translated, others fall back to English per established convention
Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift Adds 4 new diff viewer shortcut actions with correct localized labels, defaults, and context registrations

Sequence Diagram

sequenceDiagram
    participant CLI as cmux CLI
    participant FS as preferences.json
    participant WV as Webview (App.tsx)
    participant Bridge as DiffCommentsBridge
    participant Store as DiffViewerPreferencesStore

    CLI->>FS: read (persistedDiffViewerPreferences)
    FS-->>CLI: layout + toggles
    CLI->>WV: generate page (payload.layout, payload.viewerOptions)
    WV->>WV: initialAppState (seeded from payload)
    WV->>Bridge: viewerPrefs.get
    Bridge->>Store: preferences()
    Store->>FS: read (cached)
    Store-->>Bridge: "{layout, wordWrap, ...}"
    Bridge-->>WV: "{ok, value.preferences}"
    WV->>WV: apply-persisted-options (override initial state)
    WV->>Bridge: viewerPrefs.set (on user change)
    Bridge->>Store: merge(updates)
    Store->>FS: write atomically
Loading

Reviews (2): Last reviewed commit: "Retrigger CI (no run scheduled for previ..." | Re-trigger Greptile

Comment on lines +54 to +65
func merge(_ updates: [String: Any]) -> [String: Any] {
let sanitizedUpdates = Self.sanitize(updates)
lock.lock()
defer { lock.unlock() }
var merged = loadLocked()
for (key, value) in sanitizedUpdates {
merged[key] = value
}
cached = merged
persistLocked(merged)
return merged
}

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.

P2 Synchronous file I/O under NSLock from the main actor

merge() acquires NSLock and calls persistLocked, which executes FileManager.createDirectory, JSONSerialization.data, and data.write(to:options:.atomic) while holding the lock — all synchronously on the @MainActor bridge thread. DiffCommentsBridge is @MainActor, so every viewerPrefs.set message from the webview runs this I/O on the main thread. The preferences file is small so latency is negligible today, but the @unchecked Sendable + NSLock pattern bypasses actor isolation — the Swift concurrency rule for this codebase flags this where actor isolation or an async off-main path should own coordination.

Comment thread CLI/cmux_open.swift
Comment on lines +1295 to +1329
private func persistedDiffViewerPreferences() -> [String: Any] {
let fileURL: URL
if let override = ProcessInfo.processInfo.environment["CMUX_DIFF_VIEWER_PREFS_PATH"],
!override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
fileURL = URL(fileURLWithPath: override, isDirectory: false)
} else if let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first {
fileURL = appSupport
.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent("diff-viewer", isDirectory: true)
.appendingPathComponent("preferences.json", isDirectory: false)
} else {
return [:]
}
guard let data = try? Data(contentsOf: fileURL),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
var sanitized: [String: Any] = [:]
if let layout = object["layout"] as? String, layout == "split" || layout == "unified" {
sanitized["layout"] = layout
}
if let indicators = object["diffIndicators"] as? String,
indicators == "bars" || indicators == "classic" || indicators == "none" {
sanitized["diffIndicators"] = indicators
}
for key in ["wordWrap", "wordDiffs", "lineNumbers", "showBackgrounds", "expandUnchanged"] {
if let value = object[key] as? Bool {
sanitized[key] = value
}
}
return sanitized
}

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.

P2 Sanitization logic duplicated from DiffViewerPreferencesStore.sanitize()

persistedDiffViewerPreferences() re-implements the same validation (layout enum, diffIndicators enum, five boolean keys) that DiffViewerPreferencesStore.sanitize() already encodes. The CLI cannot import the app target so the duplication is unavoidable at the binary level, but the two tables will drift if a new preference key is added to one side and missed on the other. A shared comment or schema reference would make it obvious when they need to be kept in sync.

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!

Comment thread CLI/cmux_open.swift
Comment on lines +1500 to +1505
patch = try joinedGitDiffPatches(
[gitStdout(gitDiffPatchArguments(["--"]), in: repoRoot)]
+ gitUntrackedPaths(in: repoRoot).map { path in
try gitAddedUntrackedPatch(path: path, in: repoRoot)
}
)

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 gitUntrackedPaths failure aborts the entire --unstaged diff

gitUntrackedPaths is now throws. If git ls-files --others --exclude-standard fails for any reason, the thrown error propagates out and the caller receives no diff at all — not even the regular git diff output already computed in [gitStdout(gitDiffPatchArguments(["--"]), in: repoRoot)]. Previously --unstaged never called ls-files, so this is a new failure mode. A non-throwing helper that returns [] on error would degrade gracefully: users see the staged/modified changes even when ls-files has a problem.

CMUXOpenCommandTests.swift +106 (regression tests), cmux_open.swift +77
(untracked-in-unstaged, persisted prefs, new shortcut actions),
KeyboardShortcutSettings.swift +24 (four new diff viewer actions).
Regenerated with scripts/swift_file_length_budget.py --write-budget,
which also ratchets down entries that shrank on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9fa2556. Configure here.

Comment thread CLI/cmux_open.swift
+ gitUntrackedPaths(in: repoRoot).map { path in
try gitAddedUntrackedPatch(path: path, in: repoRoot)
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One bad untracked fails unstaged

Medium Severity

Building the unstaged patch runs git diff and then generates a patch for every untracked path in one throwing chain. If any single untracked path cannot be diffed, the whole unstaged read fails, so tracked unstaged edits and other untracked files never appear in the viewer.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9fa2556. Configure here.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
webviews/src/App.tsx (1)

1257-1329: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Soft refresh starts overlapping diff streams without cancellation.

The generation guard prevents stale callbacks from mutating state, but previous streamPatch runs still continue after refresh. Repeated refreshes can stack concurrent parse/fetch work and degrade responsiveness. Add effect cleanup cancellation (AbortSignal or explicit stream cancel handle) so superseded generations stop work, not just ignore callbacks.

🤖 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 `@CLI/cmux_open.swift`:
- Around line 1497-1505: The current unstaged-patch generation code iterates
gitUntrackedPaths and calls gitAddedUntrackedPatch for each path, spawning an
unbounded subprocess per file (see gitUntrackedPaths, gitAddedUntrackedPatch,
joinedGitDiffPatches, gitStdout, gitDiffPatchArguments); change this to a
bounded strategy by introducing a configurable batch size constant (e.g.
UNTRACKED_BATCH_SIZE) and produce patches in batches (group N paths, call
gitAddedUntrackedPatch or a batched variant for that group) and if there are
more files than the cap append a single synthetic "TRUNCATED_UNTRACKED_FILES"
patch or metadata entry to signal truncation so callers know results are partial
and avoid per-file process explosion.
- Around line 1282-1288: Update the CLI help/usage text that describes the
--layout flag to state that when --layout is omitted the app will prefer the
user's persisted viewer preference (persistedDiffViewerPreferences()["layout"])
over the settings-file default (diffViewerDefaultLayoutSetting()), rather than
always falling back to the settings default; locate the string that documents
the --layout option in the CLI usage/help output and change its wording to
reflect this new precedence (persisted viewer prefs win, then settings default,
then "unified" fallback).

In `@Sources/DiffViewerPreferencesStore.swift`:
- Around line 21-23: preferences() currently trusts the in-memory cached
dictionary and never re-reads preferences.json, and merge() updates cached
before the file write succeeds; change behavior so the on-disk file is
authoritative: acquire lock, read and decode fileURL (preferences.json) on each
call to preferences() to refresh stale/cold state before returning; in merge(),
merge into a temporary copy, write the updated JSON to disk first (handling
write errors), and only after a successful write update the in-memory cached
property; ensure all file read/write and cached accesses are protected by the
existing lock (NSLock) and keep fileURL, cached, preferences(), and merge() as
the key symbols to modify.

In `@webviews/src/App.tsx`:
- Around line 284-287: navigateHunk is rebuilding
buildHunkAnchors(current.items) on every keystroke causing repeated O(n) scans;
instead compute and cache the anchors when the source collection changes (e.g.,
when latestState.current.items updates) and have navigateHunk read from that
cached value (store in a ref or on latestState) so nextHunkIndex and
hunkNavIndex.current use the precomputed anchors; apply the same caching for the
other hot-path call sites noted (around the 321-326 block) to avoid repeated
full scans.
🪄 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: df35aa03-24e5-436a-a8c9-b47f236afa37

📥 Commits

Reviewing files that changed from the base of the PR and between aeb8847 and 87a764b.

📒 Files selected for processing (21)
  • CLI/cmux_open.swift
  • Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift
  • Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift
  • Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift
  • Resources/Localizable.xcstrings
  • Resources/markdown-viewer/webviews-app/chunks/diffSurface.mjs
  • Sources/DiffViewerPreferencesStore.swift
  • Sources/KeyboardShortcutContext.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/Panels/DiffCommentsBridge.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/CMUXOpenCommandTests.swift
  • web/data/cmux-shortcuts.ts
  • web/data/cmux.schema.json
  • webviews/src/App.tsx
  • webviews/src/types.ts
  • webviews/src/viewer-nav.ts
  • webviews/src/viewer-prefs.ts
  • webviews/test/app.test.tsx
  • webviews/test/viewer-nav.test.ts
  • webviews/test/viewer-prefs.test.ts

Comment thread CLI/cmux_open.swift
Comment on lines +1282 to 1288
// The user's last in-viewer layout choice (persisted by the app's
// viewerPrefs bridge) wins over the settings-file default, so new diff
// panels open the way the user last left one (#5284).
if let persisted = persistedDiffViewerPreferences()["layout"] as? String {
return (persisted, "default")
}
return (diffViewerDefaultLayoutSetting() ?? "unified", "default")

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 | 🟡 Minor | ⚡ Quick win

Update CLI usage text for the new layout precedence.

Layout now prefers persisted viewer prefs when --layout is omitted, but Line 6191 still says the default is unified/settings-based. That help output is now misleading.

Suggested help-text update
-          --layout <split|unified>     Diff layout (default: unified; configurable via diffViewer.defaultLayout in cmux.json)
+          --layout <split|unified>     Diff layout (default: last persisted viewer layout; otherwise diffViewer.defaultLayout or unified)
🤖 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 `@CLI/cmux_open.swift` around lines 1282 - 1288, Update the CLI help/usage text
that describes the --layout flag to state that when --layout is omitted the app
will prefer the user's persisted viewer preference
(persistedDiffViewerPreferences()["layout"]) over the settings-file default
(diffViewerDefaultLayoutSetting()), rather than always falling back to the
settings default; locate the string that documents the --layout option in the
CLI usage/help output and change its wording to reflect this new precedence
(persisted viewer prefs win, then settings default, then "unified" fallback).

Comment thread CLI/cmux_open.swift
Comment on lines +1497 to +1505
// Untracked files are part of the unstaged working-tree state but
// plain `git diff` omits them, which silently hides files agents
// just created. Append an added-file patch per untracked path.
patch = try joinedGitDiffPatches(
[gitStdout(gitDiffPatchArguments(["--"]), in: repoRoot)]
+ gitUntrackedPaths(in: repoRoot).map { path in
try gitAddedUntrackedPatch(path: path, in: repoRoot)
}
)

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 | 🏗️ Heavy lift

Bound untracked patch fan-out in --unstaged generation.

Line 1502 spawns one git diff --no-index subprocess per untracked path. This is an unbounded per-target batch scan and can become very slow in large repos with many untracked files. Add a bounded/benchmarked strategy (e.g., explicit cap + truncation signaling, or a batched generation path) before merge.

As per coding guidelines, production scalable paths must flag per-target rescans and unbenchmarked algorithm choices.

🤖 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 `@CLI/cmux_open.swift` around lines 1497 - 1505, The current unstaged-patch
generation code iterates gitUntrackedPaths and calls gitAddedUntrackedPatch for
each path, spawning an unbounded subprocess per file (see gitUntrackedPaths,
gitAddedUntrackedPatch, joinedGitDiffPatches, gitStdout, gitDiffPatchArguments);
change this to a bounded strategy by introducing a configurable batch size
constant (e.g. UNTRACKED_BATCH_SIZE) and produce patches in batches (group N
paths, call gitAddedUntrackedPatch or a batched variant for that group) and if
there are more files than the cap append a single synthetic
"TRUNCATED_UNTRACKED_FILES" patch or metadata entry to signal truncation so
callers know results are partial and avoid per-file process explosion.

Source: Coding guidelines

Comment on lines +21 to +23
private let lock = NSLock()
private let fileURL: URL?
private var cached: [String: Any]?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the file authoritative over the in-memory cache.

After the first load, preferences() never revalidates preferences.json, and merge() updates cached before the write succeeds. Any external update or local write failure can leave the bridge serving a different preference set than the persisted file, which breaks this persistence path’s source-of-truth contract.

As per coding guidelines, cached substitutions in persistence paths must handle cold and stale caches explicitly.

Also applies to: 54-64, 83-113

🧰 Tools
🪛 SwiftLint (0.63.3)

[Warning] 23-23: Prefer empty collection over optional collection

(discouraged_optional_collection)

🤖 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/DiffViewerPreferencesStore.swift` around lines 21 - 23, preferences()
currently trusts the in-memory cached dictionary and never re-reads
preferences.json, and merge() updates cached before the file write succeeds;
change behavior so the on-disk file is authoritative: acquire lock, read and
decode fileURL (preferences.json) on each call to preferences() to refresh
stale/cold state before returning; in merge(), merge into a temporary copy,
write the updated JSON to disk first (handling write errors), and only after a
successful write update the in-memory cached property; ensure all file
read/write and cached accesses are protected by the existing lock (NSLock) and
keep fileURL, cached, preferences(), and merge() as the key symbols to modify.

Source: Coding guidelines

Comment thread webviews/src/App.tsx
Comment on lines +284 to +287
const navigateHunk = (direction: 1 | -1) => {
const current = latestState.current;
const anchors = buildHunkAnchors(current.items);
const index = nextHunkIndex(anchors, hunkNavIndex.current, current.activeItemId, direction);

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

Avoid rebuilding all hunk anchors on every next/prev-hunk keystroke.

navigateHunk currently calls buildHunkAnchors(current.items) per keypress, which does a full scan each time. On large diffs, this creates avoidable repeated O(n) work on an interaction hot path.

♻️ Proposed fix
-import { useCallback, useEffect, useReducer, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
@@
-  const hunkNavIndex = useRef(-1);
+  const hunkNavIndex = useRef(-1);
+  const hunkAnchors = useMemo(() => buildHunkAnchors(state.items), [state.items]);
@@
-    const anchors = buildHunkAnchors(current.items);
+    const anchors = hunkAnchors;

As per coding guidelines, production code over scalable user data should flag repeated full-collection scans in hot paths.

Also applies to: 321-326

🤖 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 `@webviews/src/App.tsx` around lines 284 - 287, navigateHunk is rebuilding
buildHunkAnchors(current.items) on every keystroke causing repeated O(n) scans;
instead compute and cache the anchors when the source collection changes (e.g.,
when latestState.current.items updates) and have navigateHunk read from that
cached value (store in a ref or on latestState) so nextHunkIndex and
hunkNavIndex.current use the precomputed anchors; apply the same caching for the
other hot-path call sites noted (around the 321-326 block) to avoid repeated
full scans.

Source: Coding guidelines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread CLI/cmux_open.swift
Comment on lines +3957 to 3964
} catch {
// A fallback candidate that cannot be read (empty, or e.g.
// last-turn without a workspace/surface context) is skipped;
// only the originally selected source's own failure may
// surface. Otherwise `cmux diff --unstaged` in a clean repo
// outside a cmux terminal dead-ends on a raw last-turn
// context error instead of the friendly empty state (#5246).
continue

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 Broad catch silences git failures during fallback evaluation

The outer catch block (changed from catch is EmptyDiffSourceError) now swallows every error thrown by writeDeferredDiffViewerSource for every fallback candidate — including git failures (e.g., corrupted index, git binary missing) and disk-write errors that indicate real problems.

Concrete failure: the user's --unstaged view is empty, the staged fallback is tried, git returns an error while reading the staged index (corrupted pack or misconfigured git config). The old code would surface that error; the new code silently falls through to the friendly empty state. The user now sees "no changes" when there are actually staged diffs that failed to load.

The intended fix was specifically for the case where last-turn context throws a non-EmptyDiffSourceError because there is no workspace/surface context. Catching that specific error class (or matching on it) would avoid swallowing genuine git or write failures from other fallback sources.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant