Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 81 additions & 4 deletions CLI/cmux_open.swift
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,10 @@ extension CMUXCLI {
case scrollToBottom = "diffViewerScrollToBottom"
case scrollToTop = "diffViewerScrollToTop"
case openFileSearch = "diffViewerOpenFileSearch"
case nextHunk = "diffViewerNextHunk"
case prevHunk = "diffViewerPrevHunk"
case nextFile = "diffViewerNextFile"
case prevFile = "diffViewerPrevFile"

var defaultShortcut: DiffViewerShortcut {
switch self {
Expand All @@ -481,6 +485,14 @@ extension CMUXCLI {
)
case .openFileSearch:
return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "/"))
case .nextHunk:
return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "n"))
case .prevHunk:
return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "p"))
case .nextFile:
return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "]"))
case .prevFile:
return DiffViewerShortcut(first: DiffViewerShortcutStroke(key: "["))
}
}
}
Expand Down Expand Up @@ -1267,9 +1279,55 @@ extension CMUXCLI {
if let rawLayout {
return (try parseDiffViewerLayout(rawLayout, errorMessage: "--layout must be split|unified"), "explicit")
}
// 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")
Comment on lines +1282 to 1288

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).

}

/// Reads the diff viewer display preferences persisted by the app
/// (`~/Library/Application Support/cmux/diff-viewer/preferences.json`),
/// sanitized to known keys/values. Returns an empty dictionary when the
/// file is missing or unreadable.
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
}
Comment on lines +1295 to +1329

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!


private func parseDiffViewerLayout(_ rawValue: String, errorMessage: String) throws -> String {
let normalized = rawValue
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down Expand Up @@ -1436,7 +1494,15 @@ extension CMUXCLI {
let sourceLabel: String
switch source {
case .unstaged:
patch = try gitStdout(gitDiffPatchArguments(["--"]), in: repoRoot)
// 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)
}
)
Comment on lines +1500 to +1505

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.

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.

Comment on lines +1497 to +1505

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

sourceLabel = "git unstaged"
case .staged:
patch = try gitStdout(gitDiffPatchArguments(["--cached", "--"]), in: repoRoot)
Expand Down Expand Up @@ -3888,10 +3954,14 @@ extension CMUXCLI {
try? writeDiffViewerEmptyStatePage(message: error.message, page: page, sourceSet: sourceSet)
completion.completedPageURLs.insert(page.url)
return completion
} catch is EmptyDiffSourceError {
} 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).
Comment thread
cursor[bot] marked this conversation as resolved.
continue
Comment on lines +3957 to 3964

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.

} catch let fallbackError {
throw fallbackError
}
}
// No source has changes: render the selected source's friendly empty
Expand Down Expand Up @@ -5633,6 +5703,13 @@ extension CMUXCLI {
"baseOptions": baseOptions.map(\.jsonObject),
"generatedAt": ISO8601DateFormatter().string(from: Date())
]
// Persisted display toggles (word wrap, line numbers, …) seed the
// page's initial options so first paint matches the user's last
// session; the page then re-syncs live through the viewerPrefs bridge.
let viewerOptions = persistedDiffViewerPreferences().filter { $0.key != "layout" }
if !viewerOptions.isEmpty {
payload["viewerOptions"] = viewerOptions
}
if let statusMessage {
payload["statusMessage"] = statusMessage
payload["statusIsError"] = statusIsError
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ extension ShortcutAction {
case .diffViewerScrollToBottom: return ShortcutStroke(key: "g", shift: true)
case .diffViewerScrollToTop: return nil
case .diffViewerOpenFileSearch: return ShortcutStroke(key: "/")
case .diffViewerNextHunk: return ShortcutStroke(key: "n")
case .diffViewerPrevHunk: return ShortcutStroke(key: "p")
case .diffViewerNextFile: return ShortcutStroke(key: "]")
case .diffViewerPrevFile: return ShortcutStroke(key: "[")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod
case diffViewerScrollToTop
/// Opens file search inside the focused diff viewer.
case diffViewerOpenFileSearch
/// Jumps to the next hunk in the focused diff viewer.
case diffViewerNextHunk
/// Jumps to the previous hunk in the focused diff viewer.
case diffViewerPrevHunk
/// Jumps to the next file in the focused diff viewer.
case diffViewerNextFile
/// Jumps to the previous file in the focused diff viewer.
case diffViewerPrevFile
}

extension ShortcutAction {
Expand Down Expand Up @@ -171,7 +179,8 @@ extension ShortcutAction {
.hideFind, .useSelectionForFind, .toggleBrowserDeveloperTools,
.showBrowserJavaScriptConsole, .toggleBrowserFocusMode, .toggleReactGrab,
.diffViewerScrollDown, .diffViewerScrollUp, .diffViewerScrollToBottom,
.diffViewerScrollToTop, .diffViewerOpenFileSearch:
.diffViewerScrollToTop, .diffViewerOpenFileSearch,
.diffViewerNextHunk, .diffViewerPrevHunk, .diffViewerNextFile, .diffViewerPrevFile:
return .browser
}
}
Expand Down Expand Up @@ -207,7 +216,11 @@ extension ShortcutAction {
.diffViewerScrollUp,
.diffViewerScrollToBottom,
.diffViewerScrollToTop,
.diffViewerOpenFileSearch:
.diffViewerOpenFileSearch,
.diffViewerNextHunk,
.diffViewerPrevHunk,
.diffViewerNextFile,
.diffViewerPrevFile:
return true
default:
return false
Expand All @@ -234,7 +247,8 @@ extension ShortcutAction {
.toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole,
.browserZoomIn, .browserZoomOut, .browserZoomReset, .toggleBrowserFocusMode,
.diffViewerScrollDown, .diffViewerScrollUp, .diffViewerScrollToBottom,
.diffViewerScrollToTop, .diffViewerOpenFileSearch:
.diffViewerScrollToTop, .diffViewerOpenFileSearch,
.diffViewerNextHunk, .diffViewerPrevHunk, .diffViewerNextFile, .diffViewerPrevFile:
return .atom(.browserFocus)
case .markdownZoomIn, .markdownZoomOut, .markdownZoomReset:
return .atom(.markdownFocus)
Expand Down Expand Up @@ -364,6 +378,14 @@ extension ShortcutAction {
return String(localized: "shortcut.diffViewerScrollToTop.label", defaultValue: "Diff Viewer: Scroll to Top")
case .diffViewerOpenFileSearch:
return String(localized: "shortcut.diffViewerOpenFileSearch.label", defaultValue: "Diff Viewer: Open File Search")
case .diffViewerNextHunk:
return String(localized: "shortcut.diffViewerNextHunk.label", defaultValue: "Diff Viewer: Next Hunk")
case .diffViewerPrevHunk:
return String(localized: "shortcut.diffViewerPrevHunk.label", defaultValue: "Diff Viewer: Previous Hunk")
case .diffViewerNextFile:
return String(localized: "shortcut.diffViewerNextFile.label", defaultValue: "Diff Viewer: Next File")
case .diffViewerPrevFile:
return String(localized: "shortcut.diffViewerPrevFile.label", defaultValue: "Diff Viewer: Previous File")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ struct ShortcutActionNumberedDigitTests {
.diffViewerScrollToBottom,
.diffViewerScrollToTop,
.diffViewerOpenFileSearch,
.diffViewerNextHunk,
.diffViewerPrevHunk,
.diffViewerNextFile,
.diffViewerPrevFile,
]

for action in ShortcutAction.allCases {
Expand Down
Loading
Loading