Move diff viewer backend boundary to a Rust sidecar (#7804)

* Add Rust diff viewer sidecar

* Harden diff sidecar request handling

* Close sidecar review gaps

* Finish sidecar build and retry integration

* Gate sidecar transport and webview checks

* Remove sidecar setup and localization gaps

* Extract diff sidecar process boundary

* Use stdio for native diff sidecar transport

* Satisfy Swift file length guard

* Make custom scheme test deterministic

* Address sidecar review findings

* Test incremental diff tree source reuse

* Make diff tree streaming linear

* Verify diff correctness and streaming performance

* Fix diff sidecar review regressions

* Harden diff sidecar stdio RPC

* Test bounded large diff rendering

* Bound large diff UI updates

* Harden large diff navigation

* Fix diff sidecar isolation warning

* Test mobile diff drawer close control

* Make mobile diff drawer opaque

* Harden mobile diff drawer dismissal

* Refactor diff viewer bridge ownership

* Preserve diff sidecar pipe ownership

* Load diff sessions lazily through Rust

* Keep Rust diff sessions alive while rendering

* Split diff sidecar helpers from legacy files

* Close diff sessions before page navigation

* Close diff sessions before navigating

* Track active diff sessions through navigation

* Refresh generated diff viewer bundle

* Keep diff source switching responsive

* Open typed diff sessions in place

* Update diff CLI file budget

* Extract typed diff viewer writer

* Build typed diff writer in CLI target

* Expose shared diff shortcut payload

* Share typed diff writer model types

* Allow typed diff fallback input replacement

* Open diff loading shell before asset setup

* Bound typed branch base resolution

* Avoid duplicate diff theme registration

* Test custom-scheme asset fetch decoding

* Decode deflated assets for diff scheme

* Test cancellation of stale diff streams

* Cancel stale diff sessions and cap patch writes

* test: cover diff sidecar review regressions

* fix: bound diff sidecar lifecycle

* test: cover sidecar cancellation cleanup

* fix: clean up cancelled sidecar process groups

* test: require race-free sidecar process groups

* fix: handshake sidecar process group startup

* test: cover cancellation after patch rename

* fix: retain cleanup ownership through registration

* fix: bound sidecar startup and shutdown

* test: cover branch picker repository switches

* fix: close final sidecar lifecycle gaps

* test: cover same-repo branch base changes

* fix: preserve process group identity through shutdown

* Make stale branch picker test state-driven

* Test Last Turn switching and abandoned sidecar sessions

* Keep typed diff sources and manifests recoverable

* Test typed diff selector composition

* Compose typed diff selector state

* Rebuild diff webview assets

* Test orphan cleanup and Last Turn repo switching

* Close typed diff lifecycle gaps

* Test pending cancellation and rotating orphan cleanup

* Bound pending and remote diff resources

* Cap sidecar queue and index temp cleanup

* Bound server sessions and retain patch ownership

* Make patch ownership and HTTP encoding durable

* Test empty branch base selection

* Keep empty branch and pending patch recovery available

* Test branch base survives source switching

* Preserve selected branch base across source switches

* Retain generated patch ownership until lifecycle cleanup

* Serialize token session publication

* Keep concurrent diff sessions independently owned

* Reconcile session cleanup with manifest lifecycle

* Make session publication cancellation safe

* Scope cancellation and close transactions correctly

* Authorize session close by manifest ownership

* Close discarded diff sessions safely

* Cancel superseded diff sessions safely

* Reserve diff session resources atomically

* Protect active diff session patches

* Preserve active typed diff sessions

* Lease active diff sidecar sessions

* Journal diff session resource ownership

* Bound diff session recovery artifacts

* Harden diff sidecar production artifact

* Fix POSIX lock calls on Xcode 26.5

* Fix app-side lease locking on Xcode 26.5

* test: cover typed diff direct page lifecycle

* fix: open typed diff session page directly

* Fix sidecar verification for spaced paths
This commit is contained in:
Lawrence Chen
2026-07-15 22:00:34 -07:00
committed by GitHub
parent 760c6f7b2e
commit b129a8669f
71 changed files with 11121 additions and 796 deletions
+46 -1
View File
@@ -323,6 +323,44 @@ jobs:
working-directory: webviews
run: bun run react-doctor:ci
diff-sidecar-check:
needs: changes
# Generated protocol and streaming benchmarks also cover webview-only diffs.
if: ${{ needs.changes.outputs.macos == 'true' || needs.changes.outputs.web == 'true' }}
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Rust
run: ./scripts/install-rust-ci.sh
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install webview dependencies
working-directory: webviews
run: bun install --frozen-lockfile
- name: Check Rust sidecar
run: |
./scripts/run-diff-sidecar-cargo.sh fmt --manifest-path Native/DiffSidecar/Cargo.toml --all --check
./scripts/run-diff-sidecar-cargo.sh clippy --manifest-path Native/DiffSidecar/Cargo.toml --all-targets --locked --no-default-features -- -D warnings
./scripts/run-diff-sidecar-cargo.sh clippy --manifest-path Native/DiffSidecar/Cargo.toml --all-targets --all-features --locked -- -D warnings
./scripts/run-diff-sidecar-cargo.sh test --manifest-path Native/DiffSidecar/Cargo.toml --all-targets --locked --no-default-features
./scripts/run-diff-sidecar-cargo.sh test --manifest-path Native/DiffSidecar/Cargo.toml --all-targets --all-features --locked
- name: Verify generated protocol types
run: ./scripts/generate-diff-sidecar-types.sh --check
- name: Enforce diff-viewer performance budgets
env:
CMUX_DIFF_WEB_BENCH_ITERATIONS: "10"
run: ./scripts/benchmark-diff-viewer.sh
web-db-migrations:
needs: changes
if: ${{ needs.changes.outputs.web == 'true' }}
@@ -1211,6 +1249,7 @@ jobs:
- remote-daemon-tests
- web-typecheck
- react-apps-check
- diff-sidecar-check
- web-db-migrations
- agent-session-web-resources
if: ${{ always() }}
@@ -1232,6 +1271,7 @@ jobs:
"remote-daemon-tests",
"web-typecheck",
"react-apps-check",
"diff-sidecar-check",
"web-db-migrations",
"agent-session-web-resources",
}
@@ -1247,6 +1287,7 @@ jobs:
"remote-daemon-tests": "go",
"web-typecheck": "web",
"react-apps-check": "web",
"diff-sidecar-check": "macos",
"web-db-migrations": "web",
"agent-session-web-resources": "agent_session_web",
}
@@ -1772,15 +1813,18 @@ jobs:
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty"
DIFF_SIDECAR="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux-diff-sidecar"
test -x "$APP_BINARY"
test -x "$CLI_BINARY"
test -x "$HELPER_BINARY"
file "$APP_BINARY" "$CLI_BINARY" "$HELPER_BINARY"
test -x "$DIFF_SIDECAR"
file "$APP_BINARY" "$CLI_BINARY" "$HELPER_BINARY" "$DIFF_SIDECAR"
SDK_VERSION="$(otool -l "$APP_BINARY" | awk '/LC_BUILD_VERSION/ { in_version=1; next } in_version && /sdk / { print $2; exit }')"
echo "App SDK version: $SDK_VERSION"
lipo "$APP_BINARY" -verify_arch arm64 x86_64
lipo "$CLI_BINARY" -verify_arch arm64 x86_64
lipo "$HELPER_BINARY" -verify_arch arm64 x86_64
./scripts/verify-diff-sidecar-artifact.sh "$DIFF_SIDECAR"
[[ "$SDK_VERSION" == 26.* ]]
ci-status:
@@ -1790,6 +1834,7 @@ jobs:
- remote-daemon-tests
- web-typecheck
- react-apps-check
- diff-sidecar-check
- web-db-migrations
- linux-preflight
- app-host-unit-tests
+2
View File
@@ -436,6 +436,7 @@ jobs:
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty"
DIFF_SIDECAR="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux-diff-sidecar"
APP_ARCHS="$(lipo -archs "$APP_BINARY")"
CLI_ARCHS="$(lipo -archs "$CLI_BINARY")"
HELPER_ARCHS="$(lipo -archs "$HELPER_BINARY")"
@@ -445,6 +446,7 @@ jobs:
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
[[ "$HELPER_ARCHS" == *arm64* && "$HELPER_ARCHS" == *x86_64* ]]
./scripts/verify-diff-sidecar-artifact.sh "$DIFF_SIDECAR"
- name: Run CLI version memory guard regression
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
+3 -13
View File
@@ -190,19 +190,7 @@ jobs:
- name: Install build deps
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
export PATH="$HOME/.cargo/bin:$PATH"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
if ! command -v rustup >/dev/null 2>&1; then
export HOMEBREW_NO_AUTO_UPDATE=1
export HOMEBREW_NO_INSTALL_CLEANUP=1
export HOMEBREW_NO_ENV_HINTS=1
brew install rustup-init
rustup-init -y --profile minimal --default-toolchain stable
fi
if ! rustup show active-toolchain >/dev/null 2>&1; then
rustup default stable
fi
rustup target add aarch64-apple-darwin x86_64-apple-darwin
./scripts/install-rust-ci.sh
CMUX_NODE_BIN="$(command -v node)"
export npm_config_prefix="$RUNNER_TEMP/npm-global"
mkdir -p "$npm_config_prefix"
@@ -285,6 +273,7 @@ jobs:
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty"
DIFF_SIDECAR="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux-diff-sidecar"
APP_ARCHS="$(lipo -archs "$APP_BINARY")"
CLI_ARCHS="$(lipo -archs "$CLI_BINARY")"
HELPER_ARCHS="$(lipo -archs "$HELPER_BINARY")"
@@ -296,6 +285,7 @@ jobs:
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
[[ "$HELPER_ARCHS" == *arm64* && "$HELPER_ARCHS" == *x86_64* ]]
./scripts/verify-diff-sidecar-artifact.sh "$DIFF_SIDECAR"
[[ "$SDK_VERSION" == 26.* ]]
- name: Build remote daemon release assets and inject manifest
+233
View File
@@ -0,0 +1,233 @@
import Foundation
/// Process selection and launch for the portable diff-viewer backend.
extension CMUXCLI {
func fetchDiffURLToFile(_ url: URL, directory: URL) throws -> URL {
let maximumBytes = 512 * 1024 * 1024
let outputURL = directory.appendingPathComponent("download-\(UUID().uuidString).patch")
var keepOutput = false
defer {
if !keepOutput {
try? FileManager.default.removeItem(at: outputURL)
}
}
let result = CLIProcessRunner.runProcess(
executablePath: "/usr/bin/env",
arguments: [
"curl", "-fL", "--silent", "--show-error", "--max-time", "120",
"--max-filesize", String(maximumBytes),
"--output", outputURL.path, url.absoluteString,
],
timeout: 130
)
guard !result.timedOut, result.status == 0 else {
let reason = result.timedOut ? "Timed out fetching" : "Failed to fetch"
throw CLIError(message: "\(reason) diff URL: \(url.absoluteString)")
}
guard let fileSize = try? outputURL.resourceValues(forKeys: [.fileSizeKey]).fileSize,
fileSize > 0 else {
throw CLIError(message: "Diff input is empty: \(url.absoluteString)")
}
guard fileSize <= maximumBytes else {
throw CLIError(message: "Diff input exceeds 512 MiB: \(url.absoluteString)")
}
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: outputURL.path)
keepOutput = true
return outputURL
}
/// Navigates a deferred custom-scheme viewer after Git work replaces its placeholder.
func navigateCompletedDiffViewerIfNeeded(
_ wasDeferred: Bool,
_ scheme: String?,
_ payload: [String: Any],
_ expectedURL: URL,
_ completedURL: URL,
_ socketPath: String,
_ explicitPassword: String?
) throws {
guard wasDeferred, scheme == "cmux-diff-viewer" else { return }
guard let surface = (payload["surface_id"] as? String) ?? (payload["surface_ref"] as? String) else {
throw CLIError(message: "Deferred diff viewer response is missing its surface")
}
let client = try connectClient(
socketPath: socketPath,
explicitPassword: explicitPassword,
launchIfNeeded: false
)
defer { client.close() }
_ = try client.sendV2(
method: "browser.navigate",
params: [
"surface_id": surface,
"url": completedURL.absoluteString,
"expected_url": expectedURL.absoluteString,
]
)
}
func startDiffViewerHTTPServer(rootDirectory: URL, runtime: URL? = nil) throws -> URL {
guard let cmuxExecutableURL = diffViewerExecutableURL(for: runtime),
let executableURL = diffViewerServerExecutableURL(for: runtime) else {
throw CLIError(message: "Failed to resolve cmux executable for diff viewer server")
}
let process = Process()
process.executableURL = executableURL
if executableURL == cmuxExecutableURL {
process.arguments = ["diff-viewer-server", "--root", rootDirectory.path]
} else {
process.arguments = [
"serve",
"--root", rootDirectory.path,
"--cmux", cmuxExecutableURL.path,
]
}
process.environment = ProcessInfo.processInfo.environment
let stdoutPipe = Pipe()
process.standardOutput = stdoutPipe
if let nullInput = FileHandle(forReadingAtPath: "/dev/null") {
process.standardInput = nullInput
}
if let nullOutput = FileHandle(forWritingAtPath: "/dev/null") {
process.standardError = nullOutput
}
do {
try process.run()
} catch {
throw CLIError(message: "Failed to start diff viewer server: \(error.localizedDescription)")
}
let port = try readDiffViewerHTTPServerPort(
from: stdoutPipe.fileHandleForReading,
process: process
)
guard diffViewerHTTPServerIsReachable(port: port) else {
process.terminate()
throw CLIError(message: "Diff viewer server did not become reachable")
}
guard let url = URL(string: "http://127.0.0.1:\(port)") else {
throw CLIError(message: "Failed to build diff viewer server URL")
}
return url
}
func diffViewerServerExecutableURL(for runtime: URL?) -> URL? {
guard let cmuxExecutable = diffViewerExecutableURL(for: runtime) else { return nil }
let sidecar = cmuxExecutable.deletingLastPathComponent()
.appendingPathComponent("cmux-diff-sidecar", isDirectory: false)
if FileManager.default.isExecutableFile(atPath: sidecar.path) {
return sidecar.standardizedFileURL.resolvingSymlinksInPath()
}
return cmuxExecutable
}
func diffViewerUsesTypedSidecar(runtime: URL?) -> Bool {
guard let selected = diffViewerServerExecutableURL(for: runtime),
let legacy = diffViewerExecutableURL(for: runtime) else {
return false
}
return selected.standardizedFileURL.resolvingSymlinksInPath().path
!= legacy.standardizedFileURL.resolvingSymlinksInPath().path
}
func diffSessionSourcePayload(
source: DiffSource,
context: DiffSourceContext
) -> [String: Any]? {
guard let repoRoot = context.repoRoot else { return nil }
switch source {
case .unstaged:
return ["kind": "unstaged", "repoRoot": repoRoot]
case .staged:
return ["kind": "staged", "repoRoot": repoRoot]
case .branch:
guard let baseRef = context.branchBaseRef, !baseRef.isEmpty else { return nil }
return ["kind": "branch", "repoRoot": repoRoot, "baseRef": baseRef]
case .lastTurn:
return nil
}
}
func diffViewerBundledAssetDirectory(runtime: URL? = nil) throws -> URL {
if let directory = diffViewerBundledAssetDirectoryCandidates(runtime: runtime).first {
return directory
}
throw CLIError(message: "Bundled diff viewer assets not found")
}
private func diffViewerBundledAssetDirectoryCandidates(runtime: URL? = nil) -> [URL] {
let fileManager = FileManager.default
var candidates: [URL] = []
var seen: Set<String> = []
func appendIfExisting(_ url: URL?) {
guard let url else { return }
let standardized = url.standardizedFileURL
guard seen.insert(standardized.path).inserted else { return }
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: standardized.path, isDirectory: &isDirectory),
isDirectory.boolValue else {
return
}
guard (try? diffViewerBundledAssetFileURL(relativePath: "diffs.mjs", in: standardized)) != nil,
(try? diffViewerBundledAssetFileURL(relativePath: "trees.mjs", in: standardized)) != nil else {
return
}
candidates.append(standardized)
}
if let executableURL = diffViewerExecutableURL(for: runtime) {
let execDir = executableURL.deletingLastPathComponent().standardizedFileURL
for relativePath in [
"markdown-viewer/diff-viewer",
"../markdown-viewer/diff-viewer",
"../../Resources/markdown-viewer/diff-viewer",
"../../../Contents/Resources/markdown-viewer/diff-viewer"
] {
appendIfExisting(execDir.appendingPathComponent(relativePath, isDirectory: true).standardizedFileURL)
}
var current = execDir
for _ in 0..<6 {
if current.pathExtension == "app" {
appendIfExisting(
current
.appendingPathComponent("Contents", isDirectory: true)
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("markdown-viewer", isDirectory: true)
.appendingPathComponent("diff-viewer", isDirectory: true)
)
break
}
let projectMarker = current.appendingPathComponent("cmux.xcodeproj/project.pbxproj", isDirectory: false)
let repoAssetDirectory = current
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("markdown-viewer", isDirectory: true)
.appendingPathComponent("diff-viewer", isDirectory: true)
if fileManager.fileExists(atPath: projectMarker.path) {
appendIfExisting(repoAssetDirectory)
break
}
current = current.deletingLastPathComponent().standardizedFileURL
}
}
appendIfExisting(
Bundle.main.resourceURL?
.appendingPathComponent("markdown-viewer", isDirectory: true)
.appendingPathComponent("diff-viewer", isDirectory: true)
)
let devRelative = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("markdown-viewer", isDirectory: true)
.appendingPathComponent("diff-viewer", isDirectory: true)
appendIfExisting(devRelative)
return candidates
}
}
+316
View File
@@ -0,0 +1,316 @@
import Foundation
extension CMUXCLI {
/// Emits either the full branch picker or one bounded smart-base row. Rust
/// requests the bounded form when opening a branch session so repositories
/// with thousands of refs do not enter the initial diff-generation path.
func runDiffViewerRefsCommand(commandArgs: [String]) throws {
var repo: String?
var base: String?
var token: String?
var suggestedOnly = false
var index = 0
while index < commandArgs.count {
switch commandArgs[index] {
case "--repo":
guard index + 1 < commandArgs.count else { throw CLIError(message: "__diff-viewer-refs --repo requires a path") }
repo = commandArgs[index + 1]; index += 2
case "--base":
guard index + 1 < commandArgs.count else { throw CLIError(message: "__diff-viewer-refs --base requires a ref") }
base = commandArgs[index + 1]; index += 2
case "--token":
guard index + 1 < commandArgs.count else { throw CLIError(message: "__diff-viewer-refs --token requires a value") }
token = commandArgs[index + 1]; index += 2
case "--suggested-only":
suggestedOnly = true; index += 1
default:
throw CLIError(message: "Unexpected __diff-viewer-refs argument: \(commandArgs[index])")
}
}
guard let repo, !repo.isEmpty else {
throw CLIError(message: "__diff-viewer-refs requires --repo")
}
let rootDirectory = try diffViewerDirectory()
let repoAuthorized = if let token, !token.isEmpty {
diffViewerTokenAllowsRepo(token, repoRoot: repo, rootDirectory: rootDirectory)
} else {
diffViewerRepoIsAllowed(repo, rootDirectory: rootDirectory)
}
guard repoAuthorized else {
throw CLIError(message: "Repository is not in the diff viewer allow-list")
}
let data: Data
if suggestedOnly {
let groups: [[String: Any]]
if let resolved = try? resolvedDiffBranchBase(base, in: repo) {
groups = [[
"id": "suggested",
"label": CMUXDiffViewerLocalization.string(
"diffViewer.refGroup.suggested",
defaultValue: "Suggested"
),
"rows": [[
"ref": resolved.ref,
"label": resolved.ref,
"secondary": diffBranchBaseReasonLabel(resolved.reason),
"reason": resolved.reason,
"confidence": resolved.confidence,
]],
]]
} else {
groups = []
}
data = try JSONSerialization.data(withJSONObject: ["groups": groups], options: [.sortedKeys])
} else {
data = cachedDiffBranchRefGroupsPayloadForCLI(
repoRoot: repo,
selectedBaseRef: base,
rootDirectory: rootDirectory
)
}
cliWriteStdout(data)
cliWriteStdout(Data("\n".utf8))
}
/// Writes one viewer document for the typed sidecar path. Source and repo
/// changes open a new Rust session inside that document, so the modern path
/// does not prebuild the legacy source x repository x base page matrix.
func writeTypedGitDiffViewerPage(
selectedSource: DiffSource,
titleOverride: String?,
layout: String,
layoutSource: String,
appearance: DiffViewerAppearance,
context: DiffSourceContext,
target: DiffViewerGitHTMLSetTarget,
extraAllowedPageURL: URL?
) throws -> DiffViewerWriteResult {
let repoRoot = try gitRepoRootForDiff(context)
let fileURL = target.directory.appendingPathComponent(
"diff-\(target.groupID)-viewer.html",
isDirectory: false
)
let viewerURL = try target.mapper.viewerURL(for: fileURL)
let assets = try ensureDiffViewerAssets(nextTo: fileURL, runtime: target.runtime)
let sharedPayload = DiffViewerSharedPayload(
labels: DiffViewerLabels.localized().jsonObject,
shortcuts: diffViewerShortcutPayload(),
generatedAt: ISO8601DateFormatter().string(from: Date())
)
let repoCandidates = gitDiffViewerRepoOptions(selectedRepoRoot: repoRoot, context: context)
let session = DiffViewerBranchSession(
token: target.mapper.token,
groupID: target.groupID,
repoRoot: repoRoot,
allowedRepoRoots: repoCandidates.map(\.repoRoot),
layout: layout,
layoutSource: layoutSource,
appearance: appearance,
titleOverride: titleOverride,
workspaceId: context.workspaceId,
surfaceId: context.surfaceId
)
try writeDiffViewerBranchSession(session, rootDirectory: target.directory)
let lastTurnInput = try? readGitDiffInput(source: .lastTurn, context: context)
func sessionSource(_ source: DiffSource, repo: String) -> [String: Any]? {
switch source {
case .unstaged:
return ["kind": "unstaged", "repoRoot": repo]
case .staged:
return ["kind": "staged", "repoRoot": repo]
case .branch:
var payload: [String: Any] = ["kind": "branch", "repoRoot": repo]
if repo == repoRoot,
let base = normalizedDiffSourceValue(context.branchBaseRef) {
payload["baseRef"] = base
}
return payload
case .lastTurn:
guard lastTurnInput != nil else { return nil }
return [
"kind": "patch",
"path": "/\(diffViewerPatchFileURL(for: fileURL).lastPathComponent)",
]
}
}
let sourceOptions = DiffSource.allCases.map { source in
let typedSource = sessionSource(source, repo: repoRoot)
return DiffViewerSourceOption(
value: source.slug,
label: source.menuLabel,
selected: source == selectedSource,
url: nil,
disabled: typedSource == nil && source != selectedSource,
message: nil,
sourceLabel: nil,
sessionSource: typedSource
)
}
let repoOptions: [DiffViewerSourceOption]
if repoCandidates.count > 1 {
repoOptions = repoCandidates.map { option in
DiffViewerSourceOption(
value: option.repoRoot,
label: option.label,
selected: option.repoRoot == repoRoot,
url: nil,
disabled: false,
message: option.repoRoot,
sourceLabel: nil,
sessionSource: sessionSource(selectedSource, repo: option.repoRoot)
)
}
} else {
repoOptions = []
}
var responseInput: DiffInput
if selectedSource == .lastTurn {
do {
responseInput = try nonEmptyGitDiffInput(source: selectedSource, context: context)
try writeDiffViewerHTML(
to: fileURL,
patch: responseInput.patch,
title: titleOverride ?? responseInput.defaultTitle,
sourceLabel: responseInput.sourceLabel,
externalURL: responseInput.externalURL,
remotePatchURL: responseInput.remotePatchURL,
layout: layout,
layoutSource: layoutSource,
appearance: appearance,
sourceOptions: sourceOptions,
repoOptions: repoOptions,
repoRoot: repoRoot,
sessionSource: sessionSource(.lastTurn, repo: repoRoot),
capabilityToken: target.mapper.token,
assets: assets,
sharedPayload: sharedPayload,
runtime: target.runtime
)
} catch let error as EmptyDiffSourceError {
responseInput = DiffInput(
patch: "",
sourceLabel: "git \(selectedSource.slug)",
defaultTitle: selectedSource.title,
emptyMessage: error.message,
externalURL: nil
)
try writeDiffViewerStatusHTML(
to: fileURL,
title: titleOverride ?? selectedSource.title,
sourceLabel: responseInput.sourceLabel,
message: error.message,
isError: false,
pollForReplacement: false,
layout: layout,
layoutSource: layoutSource,
appearance: appearance,
sourceOptions: sourceOptions,
repoOptions: repoOptions,
repoRoot: repoRoot,
sessionSource: sessionSource(.lastTurn, repo: repoRoot),
capabilityToken: target.mapper.token,
assets: assets,
sharedPayload: sharedPayload,
runtime: target.runtime
)
}
} else {
let selectedSessionSource = sessionSource(selectedSource, repo: repoRoot)
responseInput = DiffInput(
patch: "",
sourceLabel: "git \(selectedSource.slug)",
defaultTitle: selectedSource.title,
emptyMessage: selectedSource.emptyMessage,
externalURL: nil
)
try writeDiffViewerStatusHTML(
to: fileURL,
title: titleOverride ?? selectedSource.title,
sourceLabel: responseInput.sourceLabel,
message: diffViewerLoadingDiffMessage(selectedSource.menuLabel),
emptyMessage: selectedSource.emptyMessage,
isError: false,
pollForReplacement: true,
layout: layout,
layoutSource: layoutSource,
appearance: appearance,
sourceOptions: sourceOptions,
repoOptions: repoOptions,
repoRoot: repoRoot,
branchBaseRef: context.branchBaseRef,
sessionSource: selectedSessionSource,
capabilityToken: target.mapper.token,
assets: assets,
sharedPayload: sharedPayload,
runtime: target.runtime
)
if let lastTurnInput {
try lastTurnInput.patch.write(
to: diffViewerPatchFileURL(for: fileURL),
atomically: true,
encoding: .utf8
)
}
}
var pageURLs = [fileURL]
if let extraAllowedPageURL { pageURLs.append(extraAllowedPageURL) }
let allowedFiles = try diffViewerAllowedFiles(
pageURLs: pageURLs,
assets: assets,
mapper: target.mapper
)
try writeDiffViewerHTTPManifest(
token: target.mapper.token,
files: allowedFiles,
rootDirectory: target.directory
)
return DiffViewerWriteResult(
fileURL: fileURL,
url: viewerURL,
title: titleOverride ?? responseInput.defaultTitle,
input: responseInput,
allowedFiles: allowedFiles
)
}
/// Writes the first paint without loading or hashing the web application.
/// The host can register and open this document immediately, then navigate
/// the same surface after the typed session document is ready.
func writeDiffViewerOpeningHTML(
to viewerURL: URL,
title: String,
message: String,
appearance: DiffViewerAppearance
) throws {
let escapedTitle = htmlEscaped(title)
let escapedMessage = htmlEscaped(message)
let html = """
<!doctype html>
<html data-cmux-diff-pending="true">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>\(escapedTitle)</title>
\(diffViewerPrepaintStyle(appearance: appearance))
<style>
body { margin: 0; color: var(--cmux-diff-fg); font: 13px -apple-system, BlinkMacSystemFont, sans-serif; }
.loading { display: flex; align-items: center; gap: 10px; margin: 20px 16px; opacity: .72; }
.spinner { width: 16px; height: 16px; border: 3px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
.skeleton { margin: 38px 20px; display: grid; gap: 20px; opacity: .12; }
.skeleton i { display: block; height: 14px; border-radius: 6px; background: currentColor; }
.skeleton i:nth-child(2n) { width: 72%; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="loading"><span class="spinner"></span><span>\(escapedMessage)</span></div>
<div class="skeleton"><i></i><i></i><i></i><i></i><i></i><i></i></div>
</body>
</html>
"""
try html.write(to: viewerURL, atomically: true, encoding: .utf8)
}
}
+351 -364
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
/target/
+1933
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
[package]
name = "cmux-diff-sidecar"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false
[features]
default = []
benchmark = []
http-server = [
"dep:axum",
"dep:futures-util",
"dep:notify",
"dep:reqwest",
"dep:rustls",
"dep:tokio-util",
"tokio/net",
]
[dependencies]
axum = { version = "0.8.9", features = ["ws"], optional = true }
futures-util = { version = "0.3.31", optional = true }
fs2 = "0.4.3"
notify = { version = "8.2.0", optional = true }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "stream"], optional = true }
rustls = { version = "0.23.41", default-features = false, features = ["ring", "std", "tls12"], optional = true }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.52.3", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio-util = { version = "0.7.18", features = ["io"], optional = true }
ts-rs = { version = "12.0.1", features = ["serde-json-impl"] }
uuid = { version = "1.22.0", features = ["v4"] }
[dev-dependencies]
tokio-tungstenite = "0.29.0"
[[bin]]
name = "diff-sidecar-test-host"
path = "tests/support/test_host.rs"
required-features = ["http-server"]
[[test]]
name = "server_integration"
path = "tests/server_integration.rs"
required-features = ["http-server"]
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1.4", features = ["process"] }
[profile.release]
codegen-units = 1
lto = "fat"
opt-level = "z"
panic = "abort"
strip = "symbols"
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
all = "warn"
pedantic = "warn"
+15
View File
@@ -0,0 +1,15 @@
# Diff sidecar
`cmux-diff-sidecar` is the portable command boundary for the diff viewer. The macOS app sends one typed request over stdin/stdout when the branch picker needs backend work, then the sidecar exits. Diff HTML, modules, and patch files use the app-owned `cmux-diff-viewer://` allowlist, so opening a viewer creates no TCP listener or idle backend process. Rust delegates cmux-specific Git semantics to hidden CLI commands while that behavior moves behind the portable boundary.
The bundled production binary has no default Cargo features. It includes only `rpc` and `handshake`; HTTP, WebSocket, remote TLS streaming, file watching, and benchmarks are opt-in development code. Run HTTP integration tests with `--all-features`. Run benchmarks through `scripts/benchmark-diff-viewer.sh`, which enables only the `benchmark` feature.
`src/protocol.rs` is the protocol source of truth. `scripts/generate-diff-sidecar-types.sh` generates `webviews/src/diff/generated/protocol.ts`; CI rejects stale generated types. React selects a `fetch`, `webSocket`, or `webKit` frontend transport from the payload. macOS uses WebKit reply messages backed by sidecar stdio. Future browser hosts can select Fetch or WebSocket without changing commands or result types. Patch bodies stay outside command replies and are served by each host's resource transport.
The stdio transport accepts one request of at most 1 MiB and requires EOF within 10 seconds. Timeout, oversized, and malformed envelopes return a typed failure with ID `__cmux_untrusted_request__`, because no caller-supplied request ID is trusted until the complete envelope parses.
Rust is a required macOS build dependency. `rust-toolchain.toml` pins Rust 1.88.0, including both Apple targets. Every setup, CI, generation, benchmark, and Xcode command runs Cargo through that exact rustup toolchain with `--locked`. Release builds use size optimization, fat LTO, one codegen unit, symbol stripping, isolated per-architecture target directories, and `MACOSX_DEPLOYMENT_TARGET=14.0`.
`scripts/build-diff-sidecar.sh` creates the requested slices and combines them with `lipo`. `scripts/verify-diff-sidecar-artifact.sh` executes the handshake and rejects missing slices, non-system dynamic dependencies, a deployment target other than macOS 14.0, linkable symbols, invalid executable permissions, or a binary larger than 5 MiB. Release signing runs the same check again and requires a valid code signature.
Run `scripts/benchmark-diff-viewer.sh` from the repository root. It measures Rust manifest decoding and patch reads, then exercises the real Pierre parser and streaming batcher with 2,000 files. CI enforces conservative p95 and throughput budgets to catch large regressions without treating shared-runner noise as a failure.
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
channel = "1.88.0"
profile = "minimal"
components = ["clippy", "rustfmt"]
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"]
+121
View File
@@ -0,0 +1,121 @@
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BenchmarkReport {
pub sample_bytes: usize,
pub iterations: usize,
pub manifest_decode_median_micros: u128,
pub manifest_decode_p95_micros: u128,
pub sequential_read_median_micros: u128,
pub sequential_read_p95_micros: u128,
pub sequential_read_mib_per_second: f64,
}
struct TemporaryBenchmarkDirectory(PathBuf);
impl Drop for TemporaryBenchmarkDirectory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Measures manifest decoding and sequential patch reads.
///
/// # Errors
///
/// Returns an error when the fixture cannot be created, decoded, or read.
pub fn run(sample_bytes: usize, iterations: usize) -> Result<BenchmarkReport, String> {
if iterations == 0 {
return Err("benchmark requires at least one iteration".to_owned());
}
let root = std::env::temp_dir().join(format!("cmux-diff-benchmark-{}", std::process::id()));
std::fs::create_dir_all(&root).map_err(|error| error.to_string())?;
let _temporary_directory = TemporaryBenchmarkDirectory(root.clone());
let patch_path = root.join("sample.patch");
let chunk = b"diff --git a/src/file.rs b/src/file.rs\n@@ -1 +1 @@\n-old\n+new\n";
let mut patch = Vec::with_capacity(sample_bytes);
while patch.len() < sample_bytes {
patch.extend_from_slice(chunk);
}
patch.truncate(sample_bytes);
std::fs::write(&patch_path, &patch).map_err(|error| error.to_string())?;
let manifest = serde_json::json!({
"token": "0123456789abcdef",
"files": [{
"request_path": "/sample.patch",
"file_path": patch_path,
"mime_type": "text/x-diff",
"remote_url": null
}]
});
let manifest_bytes = serde_json::to_vec(&manifest).map_err(|error| error.to_string())?;
let mut decode_samples = Vec::with_capacity(iterations);
let mut read_samples = Vec::with_capacity(iterations);
for _ in 0..iterations {
let started = Instant::now();
let _: crate::manifest::Manifest =
serde_json::from_slice(&manifest_bytes).map_err(|error| error.to_string())?;
decode_samples.push(started.elapsed());
let started = Instant::now();
let bytes = std::fs::read(&patch_path).map_err(|error| error.to_string())?;
if bytes.len() != sample_bytes {
return Err("benchmark read returned wrong byte count".to_owned());
}
read_samples.push(started.elapsed());
}
let decode_median = percentile(&mut decode_samples, 50);
let decode_p95 = percentile(&mut decode_samples, 95);
let read_median = percentile(&mut read_samples, 50);
let read_p95 = percentile(&mut read_samples, 95);
let seconds = read_median.as_secs_f64().max(f64::EPSILON);
let sample_bytes_f64 = f64::from(
u32::try_from(sample_bytes).map_err(|_| "benchmark sample exceeds 4 GiB".to_owned())?,
);
let mib_per_second = (sample_bytes_f64 / (1024.0 * 1024.0)) / seconds;
Ok(BenchmarkReport {
sample_bytes,
iterations,
manifest_decode_median_micros: decode_median.as_micros(),
manifest_decode_p95_micros: decode_p95.as_micros(),
sequential_read_median_micros: read_median.as_micros(),
sequential_read_p95_micros: read_p95.as_micros(),
sequential_read_mib_per_second: mib_per_second,
})
}
fn percentile(samples: &mut [Duration], percentile: usize) -> Duration {
samples.sort_unstable();
let rank = samples.len().saturating_mul(percentile).div_ceil(100);
let index = rank.saturating_sub(1).min(samples.len() - 1);
samples[index]
}
#[cfg(test)]
mod tests {
use std::time::Duration;
#[test]
fn zero_iterations_return_an_error() {
assert!(super::run(1024, 0).is_err());
}
#[test]
fn small_sample_runs_successfully() {
assert!(super::run(1024, 1).is_ok());
}
#[test]
fn percentile_uses_nearest_rank_for_small_samples() {
let mut samples = [1, 2, 3, 4, 5].map(Duration::from_millis);
assert_eq!(
super::percentile(&mut samples, 95),
Duration::from_millis(5)
);
}
}
@@ -0,0 +1,10 @@
use cmux_diff_sidecar::protocol::{DiffEvent, DiffRequest, DiffResponse, DiffTransportConfig};
use ts_rs::{Config, TS};
fn main() {
let config = Config::from_env();
DiffRequest::export_all(&config).expect("export DiffRequest");
DiffResponse::export_all(&config).expect("export DiffResponse");
DiffEvent::export_all(&config).expect("export DiffEvent");
DiffTransportConfig::export_all(&config).expect("export DiffTransportConfig");
}
+18
View File
@@ -0,0 +1,18 @@
//! Cross-platform diff-session protocol and sidecar runtime.
#[cfg(feature = "benchmark")]
pub mod benchmark;
pub mod manifest;
pub mod protocol;
pub mod server;
pub const PROTOCOL_VERSION: u32 = 1;
#[cfg(feature = "http-server")]
pub const HTTP_PROTOCOL_VERSION: &str =
"wait-v2 remote-stream manifest-refresh react-app-v2 executable-bound branch-picker-v1";
#[must_use]
#[cfg(feature = "http-server")]
pub fn health_response() -> String {
format!("ok {HTTP_PROTOCOL_VERSION}\n")
}
+153
View File
@@ -0,0 +1,153 @@
use std::path::PathBuf;
#[cfg(feature = "benchmark")]
use cmux_diff_sidecar::benchmark;
use cmux_diff_sidecar::server::{self, ServerConfig};
#[tokio::main]
async fn main() {
if let Err(message) = run().await {
eprintln!("cmux-diff-sidecar: {message}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
#[cfg(feature = "http-server")]
Some("serve") => {
let mut root = None;
let mut cmux = None;
while let Some(argument) = args.next() {
match argument.as_str() {
"--root" => root = args.next().map(PathBuf::from),
"--cmux" => cmux = args.next().map(PathBuf::from),
_ => return Err(format!("unexpected argument: {argument}")),
}
}
let root = root.ok_or_else(|| "serve requires --root".to_owned())?;
let cmux_executable = cmux.ok_or_else(|| "serve requires --cmux".to_owned())?;
let executable_path = std::env::current_exe().map_err(|error| error.to_string())?;
server::run(ServerConfig {
root,
cmux_executable,
executable_path,
})
.await
}
Some("rpc") => {
let mut root = None;
let mut cmux = None;
let mut process_group_ready = false;
while let Some(argument) = args.next() {
match argument.as_str() {
"--root" => root = args.next().map(PathBuf::from),
"--cmux" => cmux = args.next().map(PathBuf::from),
"--process-group-ready" => process_group_ready = true,
_ => return Err(format!("unexpected argument: {argument}")),
}
}
let root = root.ok_or_else(|| "rpc requires --root".to_owned())?;
let cmux_executable = cmux.ok_or_else(|| "rpc requires --cmux".to_owned())?;
let executable_path = std::env::current_exe().map_err(|error| error.to_string())?;
if process_group_ready {
establish_rpc_process_group()?;
}
server::run_rpc(ServerConfig {
root,
cmux_executable,
executable_path,
})
.await
}
Some("handshake") => {
reject_remaining_arguments(&mut args)?;
server::write_handshake_to_stdout().await
}
#[cfg(feature = "benchmark")]
Some("benchmark") => {
let sample_bytes = args
.next()
.map(|value| value.parse::<usize>())
.transpose()
.map_err(|error| error.to_string())?
.unwrap_or(16 * 1024 * 1024);
let iterations = args
.next()
.map(|value| value.parse::<usize>())
.transpose()
.map_err(|error| error.to_string())?
.unwrap_or(20);
reject_remaining_arguments(&mut args)?;
let report = benchmark::run(sample_bytes, iterations)?;
println!(
"{}",
serde_json::to_string_pretty(&report).map_err(|error| error.to_string())?
);
enforce_benchmark_budget(&report)?;
Ok(())
}
_ => Err("usage: cmux-diff-sidecar <rpc|handshake>".to_owned()),
}
}
fn establish_rpc_process_group() -> Result<(), String> {
#[cfg(unix)]
{
rustix::process::setpgid(None, None).map_err(|error| error.to_string())?;
eprintln!("cmux-diff-sidecar-process-group-ready");
Ok(())
}
#[cfg(not(unix))]
Err("process groups are unavailable on this platform".to_owned())
}
fn reject_remaining_arguments(args: &mut impl Iterator<Item = String>) -> Result<(), String> {
if let Some(argument) = args.next() {
return Err(format!("unexpected argument: {argument}"));
}
Ok(())
}
#[cfg(feature = "benchmark")]
fn enforce_benchmark_budget(report: &benchmark::BenchmarkReport) -> Result<(), String> {
if let Some(maximum) = environment_number::<u128>("CMUX_DIFF_BENCH_MAX_MANIFEST_P95_US")?
&& report.manifest_decode_p95_micros > maximum
{
return Err(format!(
"manifest decode p95 was {} us, budget is {maximum} us",
report.manifest_decode_p95_micros
));
}
if let Some(minimum) = environment_number::<f64>("CMUX_DIFF_BENCH_MIN_READ_MIBPS")? {
if !minimum.is_finite() || minimum < 0.0 {
return Err(
"CMUX_DIFF_BENCH_MIN_READ_MIBPS must be finite and non-negative".to_owned(),
);
}
if report.sequential_read_mib_per_second < minimum {
return Err(format!(
"sequential read throughput was {:.1} MiB/s, budget is {minimum:.1} MiB/s",
report.sequential_read_mib_per_second
));
}
}
Ok(())
}
#[cfg(feature = "benchmark")]
fn environment_number<T>(name: &str) -> Result<Option<T>, String>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
std::env::var(name)
.ok()
.map(|value| {
value
.parse::<T>()
.map_err(|error| format!("invalid {name}: {error}"))
})
.transpose()
}
+169
View File
@@ -0,0 +1,169 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AllowedFile {
pub request_path: String,
pub file_path: String,
pub mime_type: String,
pub remote_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Manifest {
pub token: String,
pub files: Vec<AllowedFile>,
}
impl Manifest {
/// Loads and validates a token-bound manifest from the sidecar root.
///
/// # Errors
///
/// Returns an error for invalid tokens, unreadable manifests, or invalid contents.
pub async fn load(root: &Path, token: &str) -> Result<Self, String> {
if !valid_token(token) {
return Err("invalid token".to_owned());
}
let path = root.join(format!(".manifest-{token}.json"));
let bytes = tokio::fs::read(path)
.await
.map_err(|error| error.to_string())?;
let manifest: Self = serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
if manifest.token != token || manifest.files.is_empty() || manifest.files.len() > 4096 {
return Err("invalid manifest".to_owned());
}
Ok(manifest)
}
/// Validates entries and indexes them by request path.
///
/// # Errors
///
/// Returns an error for invalid or duplicate manifest entries.
pub fn files_by_path(&self) -> Result<HashMap<String, AllowedFile>, String> {
let mut result = HashMap::with_capacity(self.files.len());
for file in &self.files {
file.validate()?;
if result
.insert(file.request_path.clone(), file.clone())
.is_some()
{
return Err("duplicate manifest path".to_owned());
}
}
Ok(result)
}
}
impl AllowedFile {
/// Validates the request path, MIME type, and remote-resource constraints.
///
/// # Errors
///
/// Returns an error when any manifest entry constraint is violated.
pub fn validate(&self) -> Result<(), String> {
if !valid_request_path(&self.request_path) || !valid_mime_type(&self.mime_type) {
return Err("invalid manifest entry".to_owned());
}
if !path_matches_mime(&self.request_path, &self.mime_type) {
return Err("manifest MIME mismatch".to_owned());
}
if self.remote_url.is_some()
&& (self.mime_type != "text/x-diff" || !self.file_path.is_empty())
{
return Err("invalid remote manifest entry".to_owned());
}
Ok(())
}
/// Resolves a local manifest entry without permitting escapes from `root`.
///
/// # Errors
///
/// Returns an error for remote entries, missing files, or paths outside `root`.
pub async fn canonical_local_path(&self, root: &Path) -> Result<PathBuf, String> {
if self.remote_url.is_some() || self.file_path.is_empty() {
return Err("not a local file".to_owned());
}
let canonical_root = tokio::fs::canonicalize(root)
.await
.map_err(|error| error.to_string())?;
let canonical_file = tokio::fs::canonicalize(&self.file_path)
.await
.map_err(|error| error.to_string())?;
if !canonical_file.starts_with(&canonical_root) {
return Err("manifest file escapes root".to_owned());
}
let metadata = tokio::fs::metadata(&canonical_file)
.await
.map_err(|error| error.to_string())?;
if !metadata.is_file() {
return Err("manifest entry is not a file".to_owned());
}
Ok(canonical_file)
}
}
#[must_use]
pub fn split_resource_path(path: &str) -> Option<(&str, String)> {
let trimmed = path.trim_start_matches('/');
let (token, tail) = trimmed.split_once('/')?;
if !valid_token(token) {
return None;
}
let request_path = format!("/{tail}");
valid_request_path(&request_path).then_some((token, request_path))
}
#[must_use]
pub fn valid_token(token: &str) -> bool {
(16..=80).contains(&token.len())
&& token
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
#[must_use]
pub fn valid_request_path(path: &str) -> bool {
path.starts_with('/')
&& !path.contains('\\')
&& !path.contains("//")
&& path
.split('/')
.skip(1)
.all(|component| !component.is_empty() && component != "." && component != "..")
}
fn valid_mime_type(mime_type: &str) -> bool {
matches!(mime_type, "text/html" | "text/javascript" | "text/x-diff")
}
fn path_matches_mime(path: &str, mime_type: &str) -> bool {
let extension = Path::new(path).extension().and_then(|value| value.to_str());
match mime_type {
"text/html" => extension.is_some_and(|value| value.eq_ignore_ascii_case("html")),
"text/javascript" => extension.is_some_and(|value| {
value.eq_ignore_ascii_case("js") || value.eq_ignore_ascii_case("mjs")
}),
"text/x-diff" => extension.is_some_and(|value| value.eq_ignore_ascii_case("patch")),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::{split_resource_path, valid_request_path, valid_token};
#[test]
fn token_and_path_validation_reject_traversal() {
assert!(valid_token("0123456789abcdef"));
assert!(!valid_token("short"));
assert!(valid_request_path("/viewer/main.mjs"));
assert!(!valid_request_path("/../secret"));
assert!(!valid_request_path("/viewer//main.mjs"));
assert!(split_resource_path("/0123456789abcdef/viewer.patch").is_some());
}
}
+297
View File
@@ -0,0 +1,297 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::PROTOCOL_VERSION;
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct DiffRequest {
pub id: String,
pub version: u32,
#[serde(flatten)]
pub command: DiffCommand,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub enum DiffCommand {
ProtocolHandshake,
SessionOpen(OpenSessionRequest),
SessionClose(SessionRequest),
BranchList(BranchListRequest),
BranchChange(BranchChangeRequest),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct OpenSessionRequest {
pub source: DiffSource,
pub capability_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub session_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export_to = "protocol.ts")]
pub enum DiffSource {
Patch {
path: String,
},
Unstaged {
repo_root: String,
},
Staged {
repo_root: String,
},
Branch {
repo_root: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
base_ref: Option<String>,
},
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct SessionRequest {
pub session_id: String,
pub capability_token: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct BranchListRequest {
pub repo_root: String,
pub capability_token: String,
pub selected_base: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct BranchChangeRequest {
pub group_id: String,
pub repo_root: String,
pub base_ref: String,
pub capability_token: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct BranchListResult {
pub groups: Vec<BranchPickerGroup>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct BranchPickerGroup {
pub id: String,
pub label: String,
pub rows: Vec<BranchPickerRow>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct BranchPickerRow {
pub r#ref: String,
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub secondary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub confidence: Option<BranchPickerConfidence>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub current: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub worktree_dir: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub enum BranchPickerConfidence {
High,
Low,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct DiffResourceRef {
pub id: String,
pub media_type: String,
pub byte_length: Option<u64>,
pub revision: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct DiffTransportConfig {
pub kind: DiffTransportKind,
pub endpoint: String,
pub protocol_version: u32,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub enum DiffTransportKind {
Fetch,
WebSocket,
WebKit,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct DiffResponse {
pub id: String,
pub version: u32,
pub result: Option<DiffResult>,
pub error: Option<DiffProtocolError>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub enum DiffResult {
Handshake(HandshakeResult),
SessionOpened(SessionOpened),
SessionClosed,
Branches(BranchListResult),
Navigation(NavigationResult),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct HandshakeResult {
pub protocol_version: u32,
pub capabilities: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct SessionOpened {
pub session_id: String,
pub patch: DiffResourceRef,
pub source: DiffSource,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct NavigationResult {
pub url: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub struct DiffProtocolError {
pub code: String,
pub message: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export_to = "protocol.ts")]
pub enum DiffEvent {
SessionStatus {
session_id: String,
status: DiffSessionStatus,
},
PatchReady {
session_id: String,
patch: DiffResourceRef,
},
SessionFailed {
session_id: String,
error: DiffProtocolError,
},
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "protocol.ts")]
pub enum DiffSessionStatus {
Opening,
Ready,
Closed,
}
impl DiffResponse {
#[must_use]
pub fn success(id: String, result: DiffResult) -> Self {
Self {
id,
version: PROTOCOL_VERSION,
result: Some(result),
error: None,
}
}
#[must_use]
pub fn failure(id: String, code: &str, message: &str) -> Self {
Self {
id,
version: PROTOCOL_VERSION,
result: None,
error: Some(DiffProtocolError {
code: code.to_owned(),
message: message.to_owned(),
}),
}
}
}
#[must_use]
pub fn handshake(id: String) -> DiffResponse {
let capabilities = vec![
"resource.stream".to_owned(),
"transport.webkit".to_owned(),
"transport.stdio".to_owned(),
];
#[cfg(feature = "http-server")]
let capabilities = {
let mut capabilities = capabilities;
capabilities.extend([
"transport.fetch".to_owned(),
"transport.websocket".to_owned(),
]);
capabilities
};
DiffResponse::success(
id,
DiffResult::Handshake(HandshakeResult {
protocol_version: PROTOCOL_VERSION,
capabilities,
}),
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,832 @@
use std::io::{BufRead, BufReader, Write};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::{Command, Output, Stdio};
use futures_util::{SinkExt, StreamExt};
#[test]
fn rpc_uses_stdio_without_server_state() {
let output = run_stdio_rpc(br#"{"id":"probe","version":1,"method":"protocolHandshake"}"#);
assert!(output.status.success());
let response: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("decode response");
assert_eq!(response["id"], "probe");
assert_eq!(response["result"]["type"], "handshake");
}
#[test]
fn rpc_returns_typed_failure_for_malformed_request() {
let output = run_stdio_rpc(br#"{"id": "unclosed"#);
assert!(output.status.success());
assert_rpc_failure(&output, "invalidRequest");
}
#[test]
fn rpc_returns_typed_failure_for_oversized_request() {
let output = run_stdio_rpc(&vec![b' '; 1024 * 1024 + 1]);
assert!(output.status.success());
assert_rpc_failure(&output, "requestTooLarge");
}
#[test]
fn rpc_accepts_request_at_one_mib_limit() {
let mut request = br#"{"id":"limit","version":1,"method":"protocolHandshake"}"#.to_vec();
request.resize(1024 * 1024, b' ');
let output = run_stdio_rpc(&request);
assert!(output.status.success());
let response: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("decode limit response");
assert_eq!(response["id"], "limit");
assert_eq!(response["result"]["type"], "handshake");
}
#[cfg(unix)]
#[test]
fn cancelling_rpc_terminates_its_process_group_and_removes_partial_patch() {
let root = std::env::temp_dir().join(format!(
"cmux-diff-sidecar-cancel-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let repo = create_large_changed_repo(&root);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))
.expect("secure root permissions");
let token = "0123456789abcdef";
write_cancellation_test_authorization(&root, &repo, token);
let request = serde_json::to_vec(&serde_json::json!({
"id": "cancel-session",
"version": 1,
"method": "sessionOpen",
"params": {
"source": {"kind": "unstaged", "repoRoot": repo},
"capabilityToken": token
}
}))
.expect("encode request");
let mut child = Command::new(env!("CARGO_BIN_EXE_cmux-diff-sidecar"))
.arg("rpc")
.arg("--root")
.arg(&root)
.arg("--cmux")
.arg(env!("CARGO_BIN_EXE_diff-sidecar-test-host"))
.arg("--process-group-ready")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("start cancellable sidecar");
let mut ready = String::new();
BufReader::new(child.stderr.take().expect("sidecar stderr"))
.read_line(&mut ready)
.expect("read process-group readiness");
assert_eq!(ready, "cmux-diff-sidecar-process-group-ready\n");
child
.stdin
.take()
.expect("sidecar stdin")
.write_all(&request)
.expect("write request");
let sidecar_pid =
rustix::process::Pid::from_raw(child.id().cast_signed()).expect("sidecar pid");
let git_pid = wait_for_direct_child(child.id());
assert_eq!(
rustix::process::getpgid(Some(git_pid)).expect("git process group"),
sidecar_pid
);
rustix::process::kill_process_group(sidecar_pid, rustix::process::Signal::TERM)
.expect("terminate process group");
let _ = child.wait().expect("reap sidecar");
let _ = rustix::process::kill_process_group(sidecar_pid, rustix::process::Signal::KILL);
assert_process_stopped(git_pid);
assert!(
std::fs::read_dir(&root)
.expect("read sidecar root")
.flatten()
.all(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
!(name.contains("diff-session-") && name.ends_with(".patch"))
})
);
let _ = std::fs::remove_dir_all(root);
}
#[cfg(unix)]
fn assert_process_stopped(pid: rustix::process::Pid) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if rustix::process::test_kill_process(pid).is_err() {
return;
}
let status = Command::new("/bin/ps")
.args(["-o", "stat=", "-p", &pid.as_raw_nonzero().to_string()])
.output()
.expect("inspect terminated git");
if String::from_utf8_lossy(&status.stdout)
.trim()
.starts_with('Z')
{
return;
}
assert!(
std::time::Instant::now() < deadline,
"git descendant remained live"
);
std::thread::yield_now();
}
}
#[cfg(unix)]
fn create_large_changed_repo(root: &Path) -> std::path::PathBuf {
let repo = root.join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
run_git(&repo, &["init"]);
run_git(&repo, &["config", "user.name", "cmux tests"]);
run_git(&repo, &["config", "user.email", "[email protected]"]);
let mut contents = vec![b'a'; 32 * 1024 * 1024];
std::fs::write(repo.join("large.txt"), &contents).expect("write initial file");
run_git(&repo, &["add", "large.txt"]);
run_git(&repo, &["commit", "-m", "initial"]);
let last_index = contents.len() - 1;
contents[last_index] = b'b';
std::fs::write(repo.join("large.txt"), contents).expect("write changed file");
repo
}
#[cfg(unix)]
fn write_cancellation_test_authorization(root: &Path, repo: &Path, token: &str) {
std::fs::write(
root.join(format!(".manifest-{token}.json")),
serde_json::to_vec(&serde_json::json!({"token": token, "files": []}))
.expect("encode manifest"),
)
.expect("write manifest");
std::fs::write(
root.join(".branch-session-cancel-test.json"),
serde_json::to_vec(&serde_json::json!({
"token": token,
"groupID": "cancel-test",
"allowedRepoRoots": [repo]
}))
.expect("encode session"),
)
.expect("write session");
}
#[cfg(unix)]
fn wait_for_direct_child(parent_pid: u32) -> rustix::process::Pid {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let output = Command::new("/usr/bin/pgrep")
.arg("-P")
.arg(parent_pid.to_string())
.output()
.expect("inspect sidecar children");
if let Some(pid) = String::from_utf8_lossy(&output.stdout)
.lines()
.find_map(|line| line.trim().parse::<i32>().ok())
.and_then(rustix::process::Pid::from_raw)
{
return pid;
}
assert!(
std::time::Instant::now() < deadline,
"git child did not start"
);
std::thread::yield_now();
}
}
fn run_stdio_rpc(input: &[u8]) -> Output {
let root = std::env::temp_dir().join(format!(
"cmux-diff-sidecar-rpc-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).expect("create root");
#[cfg(unix)]
{
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))
.expect("secure root permissions");
}
let output = run_stdio_rpc_in_root(input, &root);
assert!(!root.join(".server.json").exists());
let _ = std::fs::remove_dir_all(root);
output
}
fn run_stdio_rpc_in_root(input: &[u8], root: &Path) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_cmux-diff-sidecar"))
.arg("rpc")
.arg("--root")
.arg(root)
.arg("--cmux")
.arg(env!("CARGO_BIN_EXE_diff-sidecar-test-host"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("start stdio sidecar");
child
.stdin
.take()
.expect("sidecar stdin")
.write_all(input)
.expect("write request");
child.wait_with_output().expect("wait for sidecar")
}
fn assert_rpc_failure(output: &Output, code: &str) {
let response: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("decode typed failure");
assert_eq!(response["id"], "__cmux_untrusted_request__");
assert_eq!(response["version"], 1);
assert!(response["result"].is_null());
assert_eq!(response["error"]["code"], code);
}
#[test]
fn rpc_git_sessions_match_git_without_starting_a_server() {
let root = std::env::temp_dir().join(format!(
"cmux-diff-sidecar-session-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let repo = root.join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
#[cfg(unix)]
{
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))
.expect("secure root permissions");
}
run_git(&repo, &["init"]);
run_git(&repo, &["config", "user.name", "cmux tests"]);
run_git(&repo, &["config", "user.email", "[email protected]"]);
std::fs::write(repo.join("story.txt"), b"one\n").expect("write initial file");
run_git(&repo, &["add", "story.txt"]);
run_git(&repo, &["commit", "-m", "initial"]);
std::fs::write(repo.join("story.txt"), b"one\ntwo\n").expect("write changed file");
let token = "0123456789abcdef";
let shell = root.join("viewer.html");
std::fs::write(&shell, b"<!doctype html>").expect("write shell");
std::fs::write(
root.join(format!(".manifest-{token}.json")),
serde_json::to_vec(&serde_json::json!({
"token": token,
"files": [{
"request_path": "/viewer.html",
"file_path": shell,
"mime_type": "text/html"
}]
}))
.expect("encode manifest"),
)
.expect("write manifest");
std::fs::write(
root.join(".branch-session-session-test.json"),
serde_json::to_vec(&serde_json::json!({
"token": token,
"groupID": "session-test",
"allowedRepoRoots": [&repo]
}))
.expect("encode session"),
)
.expect("write session");
assert_overlapping_sessions_remain_independently_closable(&root, &repo, token);
assert_session_matches_git(
&root,
&repo,
token,
&serde_json::json!({"kind": "unstaged", "repoRoot": repo}),
&["diff", "--no-ext-diff", "--no-color", "--binary", "--"],
);
run_git(&repo, &["add", "story.txt"]);
assert_session_matches_git(
&root,
&repo,
token,
&serde_json::json!({"kind": "staged", "repoRoot": repo}),
&[
"diff",
"--no-ext-diff",
"--no-color",
"--binary",
"--cached",
"--",
],
);
assert_session_matches_git(
&root,
&repo,
token,
&serde_json::json!({"kind": "branch", "repoRoot": repo, "baseRef": "HEAD"}),
&[
"diff",
"--no-ext-diff",
"--no-color",
"--binary",
"HEAD",
"--",
],
);
assert_session_matches_git(
&root,
&repo,
token,
&serde_json::json!({"kind": "branch", "repoRoot": repo}),
&[
"diff",
"--no-ext-diff",
"--no-color",
"--binary",
"HEAD",
"--",
],
);
assert!(!root.join(".server.json").exists());
let _ = std::fs::remove_dir_all(root);
}
fn assert_overlapping_sessions_remain_independently_closable(
root: &Path,
repo: &Path,
token: &str,
) {
let source = serde_json::json!({"kind": "unstaged", "repoRoot": repo});
let git_arguments = ["diff", "--no-ext-diff", "--no-color", "--binary", "--"];
let (abandoned_session, abandoned_path) =
open_session_matches_git(root, repo, token, &source, &git_arguments);
let (replacement_session, replacement_path) =
open_session_matches_git(root, repo, token, &source, &git_arguments);
assert!(root.join(abandoned_path.trim_start_matches('/')).exists());
let manifest: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.join(format!(".manifest-{token}.json"))).expect("read manifest"),
)
.expect("decode manifest");
let session_paths: Vec<&str> = manifest["files"]
.as_array()
.expect("manifest files")
.iter()
.filter_map(|entry| entry["request_path"].as_str())
.filter(|path| path.starts_with("/diff-session-"))
.collect();
assert_eq!(
session_paths,
[abandoned_path.as_str(), replacement_path.as_str()]
);
let attacker_token = "fedcba9876543210";
std::fs::write(
root.join(format!(".manifest-{attacker_token}.json")),
serde_json::to_vec(&serde_json::json!({
"token": attacker_token,
"files": [{
"request_path": "/viewer.html",
"file_path": root.join("viewer.html"),
"mime_type": "text/html"
}]
}))
.expect("encode attacker manifest"),
)
.expect("write attacker manifest");
let attacker_close = serde_json::to_vec(&serde_json::json!({
"id": "attacker-close",
"version": 1,
"method": "sessionClose",
"params": {"sessionId": abandoned_session, "capabilityToken": attacker_token}
}))
.expect("encode attacker close");
assert!(
run_stdio_rpc_in_root(&attacker_close, root)
.status
.success()
);
assert!(root.join(abandoned_path.trim_start_matches('/')).exists());
close_session(root, token, &replacement_session, &replacement_path);
assert!(root.join(abandoned_path.trim_start_matches('/')).exists());
close_session(root, token, &abandoned_session, &abandoned_path);
}
fn assert_session_matches_git(
root: &Path,
repo: &Path,
token: &str,
source: &serde_json::Value,
git_arguments: &[&str],
) {
let (session_id, request_path) =
open_session_matches_git(root, repo, token, source, git_arguments);
close_session(root, token, &session_id, &request_path);
}
fn open_session_matches_git(
root: &Path,
repo: &Path,
token: &str,
source: &serde_json::Value,
git_arguments: &[&str],
) -> (String, String) {
let requested_session_id = uuid::Uuid::new_v4().to_string();
let request = serde_json::to_vec(&serde_json::json!({
"id": "open-session",
"version": 1,
"method": "sessionOpen",
"params": {
"source": source,
"capabilityToken": token,
"sessionId": requested_session_id,
}
}))
.expect("encode request");
let output = run_stdio_rpc_in_root(&request, root);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let response: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("decode response");
assert_eq!(response["result"]["type"], "sessionOpened", "{response}");
if source["kind"] == "branch" && source.get("baseRef").is_none() {
assert_eq!(response["result"]["value"]["source"]["baseRef"], "HEAD");
}
let session_id = response["result"]["value"]["sessionId"]
.as_str()
.expect("session id")
.to_owned();
assert_eq!(session_id, requested_session_id);
let id = response["result"]["value"]["patch"]["id"]
.as_str()
.expect("patch id");
assert!(id.starts_with(&format!("cmux-diff-viewer://{token}/diff-session-")));
let request_path = id.split_once(token).expect("token in id").1.to_owned();
let generated = std::fs::read(root.join(request_path.trim_start_matches('/')))
.expect("read generated patch");
let expected = Command::new("/usr/bin/git")
.arg("-C")
.arg(repo)
.args(git_arguments)
.output()
.expect("run expected git");
assert!(expected.status.success());
assert_eq!(generated, expected.stdout);
(session_id, request_path)
}
fn close_session(root: &Path, token: &str, session_id: &str, request_path: &str) {
let close = serde_json::to_vec(&serde_json::json!({
"id": "close-session",
"version": 1,
"method": "sessionClose",
"params": {"sessionId": session_id, "capabilityToken": token}
}))
.expect("encode close request");
let close_output = run_stdio_rpc_in_root(&close, root);
assert!(close_output.status.success());
let close_response: serde_json::Value =
serde_json::from_slice(&close_output.stdout).expect("decode close response");
assert_eq!(close_response["result"]["type"], "sessionClosed");
assert!(!root.join(request_path.trim_start_matches('/')).exists());
}
fn run_git(repo: &Path, arguments: &[&str]) {
let output = Command::new("/usr/bin/git")
.arg("-C")
.arg(repo)
.args(arguments)
.output()
.expect("run git");
assert!(
output.status.success(),
"git failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn serves_only_manifest_allowlisted_files() {
let _ = rustls::crypto::ring::default_provider().install_default();
let root = std::env::temp_dir().join(format!(
"cmux-diff-sidecar-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).expect("create root");
#[cfg(unix)]
{
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))
.expect("secure root permissions");
}
let token = "0123456789abcdef";
let group = "short-group";
let patch_path = root.join("sample.patch");
let generated_path = root.join("generated.html");
std::fs::write(&patch_path, b"diff --git a/a b/a\n").expect("write patch");
std::fs::write(&generated_path, b"<!doctype html>").expect("write generated page");
let manifest = serde_json::json!({
"token": token,
"files": [
{
"request_path": "/sample.patch",
"file_path": patch_path,
"mime_type": "text/x-diff"
},
{
"request_path": "/generated.html",
"file_path": generated_path,
"mime_type": "text/html"
}
]
});
std::fs::write(
root.join(format!(".manifest-{token}.json")),
serde_json::to_vec(&manifest).expect("encode manifest"),
)
.expect("write manifest");
let branch_session = serde_json::json!({
"token": token,
"groupID": group,
"allowedRepoRoots": [&root]
});
std::fs::write(
root.join(format!(".branch-session-{group}.json")),
serde_json::to_vec(&branch_session).expect("encode branch session"),
)
.expect("write branch session");
let mut child = Command::new(env!("CARGO_BIN_EXE_cmux-diff-sidecar"))
.arg("serve")
.arg("--root")
.arg(&root)
.arg("--cmux")
.arg(env!("CARGO_BIN_EXE_diff-sidecar-test-host"))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("start sidecar");
let stdout = child.stdout.take().expect("sidecar stdout");
let mut reader = BufReader::new(stdout);
let mut port = String::new();
reader.read_line(&mut port).expect("read port");
let port = port.trim().parse::<u16>().expect("valid port");
let runtime = tokio::runtime::Runtime::new().expect("runtime");
runtime.block_on(async {
let client = reqwest::Client::new();
verify_resources(&client, port, token, &root).await;
verify_rpc(&client, port, token, group, &root).await;
verify_websocket(port).await;
});
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(root);
}
async fn verify_resources(client: &reqwest::Client, port: u16, token: &str, root: &Path) {
let health = client
.get(format!(
"http://127.0.0.1:{port}/__cmux_diff_viewer_healthz"
))
.send()
.await
.expect("health request");
assert_eq!(health.status(), reqwest::StatusCode::OK);
assert_eq!(
health.text().await.expect("health body"),
cmux_diff_sidecar::health_response()
);
let patch = client
.get(format!("http://127.0.0.1:{port}/{token}/sample.patch"))
.send()
.await
.expect("patch request");
assert_eq!(patch.status(), reqwest::StatusCode::OK);
assert_eq!(
patch.bytes().await.expect("patch body").as_ref(),
b"diff --git a/a b/a\n"
);
let denied = client
.get(format!("http://127.0.0.1:{port}/{token}/not-allowed.patch"))
.send()
.await
.expect("denied request");
assert_eq!(denied.status(), reqwest::StatusCode::NOT_FOUND);
let second_path = root.join("second.patch");
tokio::fs::write(&second_path, b"diff --git a/b b/b\n")
.await
.expect("write second patch");
let refreshed_manifest = serde_json::json!({
"token": token,
"files": [
{
"request_path": "/sample.patch",
"file_path": root.join("sample.patch"),
"mime_type": "text/x-diff"
},
{
"request_path": "/second.patch",
"file_path": second_path,
"mime_type": "text/x-diff"
},
{
"request_path": "/generated.html",
"file_path": root.join("generated.html"),
"mime_type": "text/html"
}
]
});
tokio::fs::write(
root.join(format!(".manifest-{token}.json")),
serde_json::to_vec(&refreshed_manifest).expect("encode refreshed manifest"),
)
.await
.expect("refresh manifest");
let refreshed = client
.get(format!("http://127.0.0.1:{port}/{token}/second.patch"))
.send()
.await
.expect("refreshed manifest request");
assert_eq!(refreshed.status(), reqwest::StatusCode::OK);
}
async fn verify_rpc(client: &reqwest::Client, port: u16, token: &str, group: &str, root: &Path) {
let endpoint = format!("http://127.0.0.1:{port}/__cmux_diff_rpc");
let origin = format!("http://127.0.0.1:{port}");
let branch_request = serde_json::json!({
"id": "branches",
"version": 1,
"method": "branchList",
"params": {
"repoRoot": root,
"capabilityToken": token,
"selectedBase": "main"
}
});
let branches = client
.post(&endpoint)
.header(reqwest::header::ORIGIN, &origin)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(branch_request.to_string())
.send()
.await
.expect("branch list request");
let branch_bytes = branches.bytes().await.expect("branch list response");
let branches: serde_json::Value =
serde_json::from_slice(&branch_bytes).expect("branch list JSON");
assert_eq!(branches["result"]["type"], "branches");
assert_eq!(
branches["result"]["value"]["groups"][0]["rows"][0]["ref"],
"HEAD"
);
let unauthorized_request = serde_json::json!({
"id": "unauthorized",
"version": 1,
"method": "branchList",
"params": {
"repoRoot": root,
"capabilityToken": "fedcba9876543210",
"selectedBase": "main"
}
});
let unauthorized: serde_json::Value = client
.post(&endpoint)
.header(reqwest::header::ORIGIN, &origin)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(unauthorized_request.to_string())
.send()
.await
.expect("unauthorized request")
.bytes()
.await
.map(|bytes| serde_json::from_slice(&bytes).expect("unauthorized response JSON"))
.expect("unauthorized response bytes");
assert_eq!(unauthorized["error"]["code"], "branchListFailed");
let untrusted = client
.post(&endpoint)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(branch_request.to_string())
.send()
.await
.expect("untrusted request");
assert_eq!(untrusted.status(), reqwest::StatusCode::NOT_FOUND);
verify_branch_change(client, &endpoint, &origin, token, group, root).await;
}
async fn verify_branch_change(
client: &reqwest::Client,
endpoint: &str,
origin: &str,
token: &str,
group: &str,
root: &Path,
) {
let branch_change = serde_json::json!({
"id": "branch-change",
"version": 1,
"method": "branchChange",
"params": {
"groupId": group,
"repoRoot": root,
"baseRef": "main",
"capabilityToken": token
}
});
let changed: serde_json::Value = client
.post(endpoint)
.header(reqwest::header::ORIGIN, origin)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(branch_change.to_string())
.send()
.await
.expect("branch change request")
.bytes()
.await
.map(|bytes| serde_json::from_slice(&bytes).expect("branch change response JSON"))
.expect("branch change response bytes");
assert_eq!(changed["result"]["type"], "navigation");
let malformed_change = serde_json::json!({
"id": "malformed-branch-change",
"version": 1,
"method": "branchChange",
"params": {
"groupId": group,
"repoRoot": root,
"baseRef": "malformed",
"capabilityToken": token
}
});
let malformed: serde_json::Value = client
.post(endpoint)
.header(reqwest::header::ORIGIN, origin)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(malformed_change.to_string())
.send()
.await
.expect("malformed branch change request")
.bytes()
.await
.map(|bytes| serde_json::from_slice(&bytes).expect("malformed response JSON"))
.expect("malformed response bytes");
assert_eq!(malformed["error"]["code"], "branchChangeFailed");
}
async fn verify_websocket(port: u16) {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = format!("ws://127.0.0.1:{port}/__cmux_diff_ws")
.into_client_request()
.expect("WebSocket request");
request.headers_mut().insert(
"origin",
format!("http://127.0.0.1:{port}")
.parse()
.expect("origin header"),
);
let (mut socket, _) = tokio_tungstenite::connect_async(request)
.await
.expect("WebSocket connect");
socket
.send(tokio_tungstenite::tungstenite::Message::Text(
r#"{"id":"hello","version":1,"method":"protocolHandshake"}"#.into(),
))
.await
.expect("WebSocket handshake request");
let response = socket
.next()
.await
.expect("WebSocket response")
.expect("valid WebSocket response")
.into_text()
.expect("text response");
let response: serde_json::Value = serde_json::from_str(&response).expect("JSON response");
assert_eq!(response["id"], "hello");
assert_eq!(response["result"]["value"]["protocolVersion"], 1);
socket
.send(tokio_tungstenite::tungstenite::Message::Text(
"not-json".into(),
))
.await
.expect("invalid WebSocket request");
let close = socket
.next()
.await
.expect("WebSocket close")
.expect("valid WebSocket close");
assert!(close.is_close());
}
@@ -0,0 +1,33 @@
fn main() {
let arguments = std::env::args().collect::<Vec<_>>();
let command = arguments.get(1).map(String::as_str).unwrap_or_default();
match command {
"__diff-viewer-refs"
if arguments
.iter()
.any(|argument| argument == "--suggested-only") =>
{
println!(
r#"{{"groups":[{{"id":"suggested","label":"Suggested","rows":[{{"ref":"HEAD","label":"HEAD","current":true}}]}}]}}"#
);
}
// Deliberately exceeds the sidecar's bounded smart-base response. The
// branch-session integration test fails if it regresses to requesting
// the complete picker payload for initial base resolution.
"__diff-viewer-refs" => println!(
"{{\"groups\":[{{\"id\":\"suggested\",\"label\":\"Suggested\",\"rows\":[{{\"ref\":\"HEAD\",\"label\":\"HEAD\"}}]}},{{\"id\":\"branches\",\"label\":\"Branches\",\"rows\":[{{\"ref\":\"{}\",\"label\":\"oversized\"}}]}}]}}",
"x".repeat(8192)
),
"__diff-viewer-branch" => {
let base = arguments
.windows(2)
.find_map(|pair| (pair[0] == "--base").then_some(pair[1].as_str()));
if base == Some("malformed") {
println!("cmux-diff-viewer://0123456789abcdef/../not-allowed.html");
} else {
println!("cmux-diff-viewer://0123456789abcdef/generated.html");
}
}
_ => std::process::exit(2),
}
}
+159
View File
@@ -86948,6 +86948,40 @@
}
}
},
"diffViewer.binaryFile": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Binary file"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "バイナリファイル"
}
}
}
},
"diffViewer.modeChange": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Mode {old} → {new}"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "モード {old} → {new}"
}
}
}
},
"diffViewer.additions": {
"extractionState": "manual",
"localizations": {
@@ -88448,6 +88482,131 @@
}
}
},
"diffViewer.branchPickerGenerateFailed": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "تعذّر إنشاء الفرق. اختر فرعًا لإعادة المحاولة."
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Nije moguće generisati razliku. Odaberite granu da pokušate ponovo."
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Kunne ikke generere diffen. Vælg en gren for at prøve igen."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Diff konnte nicht erstellt werden. Wähle einen Branch, um es erneut zu versuchen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Could not generate the diff. Choose a branch to retry."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "No se pudo generar la diferencia. Elige una rama para volver a intentarlo."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Impossible de générer le diff. Choisissez une branche pour réessayer."
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Impossibile generare il diff. Scegli un branch per riprovare."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "差分を生成できませんでした。ブランチを選択して再試行してください。"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "មិនអាចបង្កើតភាពខុសគ្នាបានទេ។ ជ្រើសរើសសាខាដើម្បីព្យាយាមម្តងទៀត។"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "차이를 생성할 수 없습니다. 다시 시도할 브랜치를 선택하세요."
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Kunne ikke generere diffen. Velg en gren for å prøve igjen."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Nie udało się wygenerować różnic. Wybierz gałąź, aby spróbować ponownie."
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Não foi possível gerar o diff. Escolha uma branch para tentar novamente."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Не удалось создать diff. Выберите ветку, чтобы повторить попытку."
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "สร้าง diff ไม่สำเร็จ เลือกสาขาเพื่อลองอีกครั้ง"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Fark oluşturulamadı. Yeniden denemek için bir dal seçin."
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Не вдалося створити diff. Виберіть гілку, щоб повторити спробу."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "无法生成差异。请选择一个分支重试。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "無法產生差異。請選擇分支後重試。"
}
}
}
},
"diffViewer.branchPickerGenerating": {
"extractionState": "manual",
"localizations": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+25 -6
View File
@@ -1856,6 +1856,25 @@ final class CmuxDiffViewerURLSchemeHandler: NSObject, WKURLSchemeHandler {
let token: String
let filesByPath: [String: RegisteredFile]
let createdAt: Date
let lease: SessionLease
}
private final class SessionLease {
let fileDescriptor: Int32
init(root: URL, token: String) throws {
let path = root.appendingPathComponent(".session-lease-\(token).lock").path
fileDescriptor = Darwin.open(path, O_CREAT | O_RDWR, mode_t(0o600))
guard fileDescriptor >= 0, flock(fileDescriptor, LOCK_SH | LOCK_NB) == 0 else {
if fileDescriptor >= 0 { Darwin.close(fileDescriptor) }
throw POSIXError(.EWOULDBLOCK)
}
}
deinit {
_ = flock(fileDescriptor, LOCK_UN)
Darwin.close(fileDescriptor)
}
}
private final class SchemeTaskState: @unchecked Sendable {
@@ -1929,9 +1948,10 @@ final class CmuxDiffViewerURLSchemeHandler: NSObject, WKURLSchemeHandler {
)
}
let lease = try SessionLease(root: trustedRootURL, token: token)
lock.lock()
pruneExpiredSessionsLocked(now: now)
sessions[token] = Session(token: token, filesByPath: byPath, createdAt: now)
sessions[token] = Session(token: token, filesByPath: byPath, createdAt: now, lease: lease)
lock.unlock()
}
@@ -2469,13 +2489,13 @@ final class CmuxDiffViewerURLSchemeHandler: NSObject, WKURLSchemeHandler {
urlSchemeTask.didReceive(response)
}) else { return }
let handle = try FileHandle(forReadingFrom: file.fileURL)
let reader = try DiffViewerAssetReader(fileURL: file.fileURL)
defer {
try? handle.close()
try? reader.close()
}
while self.isSchemeTaskActive(taskID) {
let data = try handle.read(upToCount: 64 * 1024) ?? Data()
let data = try reader.read(upToCount: 64 * 1024)
if data.isEmpty {
break
}
@@ -2578,7 +2598,6 @@ final class CmuxDiffViewerURLSchemeHandler: NSObject, WKURLSchemeHandler {
"X-Content-Type-Options": "nosniff",
"Cross-Origin-Resource-Policy": "same-origin"
]
if file.fileURL.lastPathComponent.hasSuffix(".deflate") { headers["Content-Encoding"] = "deflate" }
if file.mimeType == "text/html" {
headers["Content-Security-Policy"] = [
"default-src 'none'",
@@ -3569,7 +3588,7 @@ final class BrowserPanel: Panel, ObservableObject {
// Review-comment persistence + TextBox attach for diff viewer pages.
// The handler itself rejects every frame that is not a registered diff
// viewer session, so installing it on all browser webviews is safe.
DiffCommentsBridge.installIfNeeded(on: configuration.userContentController)
DiffSidecarBridge.installViewerBridges(on: configuration.userContentController)
// Enable developer extras (DevTools)
configuration.preferences.setValue(true, forKey: "developerExtrasEnabled")
+33
View File
@@ -269,3 +269,36 @@ final class DiffCommentsBridge: NSObject, WKScriptMessageHandlerWithReply {
)
}
}
extension BrowserPanel {
func hasCurrentURL(_ expectedURL: String) -> Bool {
(webView.url ?? currentURL)?.absoluteString == expectedURL
}
@discardableResult
func navigateFromCLI(_ url: String, expectedURL: String? = nil) -> Bool {
guard expectedURL.map(hasCurrentURL) != false else { return false }
if let internalURL = URL(string: url),
internalURL.scheme == CmuxDiffViewerURLSchemeHandler.scheme {
guard CmuxDiffViewerURLSchemeHandler.shared.allowsNavigation(to: internalURL) else { return false }
navigate(to: internalURL)
} else {
navigateSmart(url)
}
return true
}
}
extension CmuxDiffViewerURLSchemeHandler {
func allowsNavigation(to url: URL) -> Bool {
guard url.scheme == Self.scheme,
url.user == nil,
url.password == nil,
url.port == nil,
url.query == nil,
url.fragment == nil else {
return false
}
return registeredFile(for: url) != nil
}
}
+549
View File
@@ -0,0 +1,549 @@
import Foundation
import WebKit
/// Reply-capable transport for the Rust diff sidecar. Each request is a bounded
/// stdin/stdout exchange with a short-lived child process. The sidecar never
/// opens a socket, and WebKit never receives filesystem paths or process access.
@MainActor
final class DiffSidecarBridge: NSObject, WKScriptMessageHandlerWithReply {
static let handlerName = "cmuxDiff"
static let shared = DiffSidecarBridge()
nonisolated private enum InvocationCompletion: Sendable {
case ready(Bool)
case terminated(Int32)
case timedOut
case missingTermination
case cancelled
}
nonisolated private enum InvocationError: Error {
case timedOut
case missingTermination
}
private static var handlerInstalledKey: UInt8 = 0
private static let maximumRequestBytes = 1024 * 1024
private nonisolated static let maximumResponseBytes = 32 * 1024 * 1024
private nonisolated static let processPool = DiffSidecarProcessPool(limit: 4)
private nonisolated static let processGroupReadyMarker = Data("cmux-diff-sidecar-process-group-ready\n".utf8)
private nonisolated static let startupTimeout: TimeInterval = 5
private nonisolated static let terminationGrace: TimeInterval = 0.25
private static let pendingSessionID = "00000000-0000-0000-0000-000000000000"
// Longer than the sidecar's 120-second branch regeneration limit.
private nonisolated static let requestTimeout: TimeInterval = 130
private struct ViewerInvocationKey: Hashable {
let webView: ObjectIdentifier
let token: String
}
private var invocations: [UUID: Task<Void, Never>] = [:]
private var sessionInvocationByViewer: [ViewerInvocationKey: UUID] = [:]
private var discardedSessionInvocations: Set<UUID> = []
/// Faults the Rust executable and its dynamic dependencies into the OS cache
/// during app startup. The handshake uses stdio and exits; it never binds a
/// port or leaves a sidecar process running.
nonisolated static func prewarm() {
Task.detached(priority: .utility) {
let request = Data(#"{"id":"prewarm","version":1,"method":"protocolHandshake"}"#.utf8)
_ = try? await processPool.run(request: request)
}
}
static func installIfNeeded(on userContentController: WKUserContentController) {
guard objc_getAssociatedObject(userContentController, &handlerInstalledKey) == nil else {
return
}
userContentController.addScriptMessageHandler(
shared,
contentWorld: .page,
name: handlerName
)
objc_setAssociatedObject(
userContentController,
&handlerInstalledKey,
NSNumber(value: true),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
static func installViewerBridges(on userContentController: WKUserContentController) {
DiffCommentsBridge.installIfNeeded(on: userContentController)
installIfNeeded(on: userContentController)
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage,
replyHandler: @escaping (Any?, String?) -> Void
) {
guard Self.isTrustedSidecarFrame(message.frameInfo),
JSONSerialization.isValidJSONObject(message.body),
let body = message.body as? [String: Any] else {
replyHandler(Self.failureResponse(body: message.body, code: "notAllowed", message: "Diff sidecar request was rejected"), nil)
return
}
let invocationID = UUID()
let method = body["method"] as? String
var sidecarBody = body
var discardedSessionCloseRequest: Data?
if method == "sessionOpen",
var params = body["params"] as? [String: Any],
let capabilityToken = params["capabilityToken"] as? String {
let sessionID = UUID().uuidString
params["sessionId"] = sessionID
sidecarBody["params"] = params
discardedSessionCloseRequest = Self.sessionCloseRequest(
capabilityToken: capabilityToken,
sessionID: sessionID
)
}
guard let request = try? JSONSerialization.data(withJSONObject: sidecarBody),
request.count <= Self.maximumRequestBytes else {
replyHandler(Self.failureResponse(body: message.body, code: "notAllowed", message: "Diff sidecar request was rejected"), nil)
return
}
let viewerToken = DiffCommentsBridge.diffViewerToken(from: message.frameInfo.request.url)
let viewerKey = message.webView.flatMap { webView in
viewerToken.map { ViewerInvocationKey(webView: ObjectIdentifier(webView), token: $0) }
}
let closeSessionID = ((message.body as? [String: Any])?["params"] as? [String: Any])?["sessionId"] as? String
if method == "sessionClose",
closeSessionID == Self.pendingSessionID {
if let viewerKey, let pendingID = sessionInvocationByViewer[viewerKey] {
discardedSessionInvocations.insert(pendingID)
invocations[pendingID]?.cancel()
}
replyHandler([
"id": (message.body as? [String: Any])?["id"] as? String ?? "unknown",
"version": 1,
"result": ["type": "sessionClosed"],
"error": NSNull(),
], nil)
return
}
if method == "sessionOpen", let viewerKey,
let previousID = sessionInvocationByViewer[viewerKey] {
discardedSessionInvocations.insert(previousID)
invocations[previousID]?.cancel()
}
let task = Task { [weak self] in
let result: Result<Data, Error>
do {
result = .success(try await Self.processPool.run(request: request))
} catch {
result = .failure(error)
}
guard let self else { return }
if self.discardedSessionInvocations.remove(invocationID) != nil,
let discardedSessionCloseRequest {
await Self.closeDiscardedSession(request: discardedSessionCloseRequest)
}
switch result {
case .success(let responseData):
guard let response = try? JSONSerialization.jsonObject(with: responseData) else {
replyHandler(Self.failureResponse(body: message.body, code: "invalidResponse", message: "Diff sidecar returned invalid JSON"), nil)
self.finishInvocation(invocationID, viewerKey: viewerKey)
return
}
replyHandler(response, nil)
case .failure:
replyHandler(Self.failureResponse(body: message.body, code: "sidecarUnavailable", message: "Diff sidecar is unavailable"), nil)
}
self.finishInvocation(invocationID, viewerKey: viewerKey)
}
invocations[invocationID] = task
if method == "sessionOpen", let viewerKey {
sessionInvocationByViewer[viewerKey] = invocationID
}
}
private func finishInvocation(_ invocationID: UUID, viewerKey: ViewerInvocationKey?) {
invocations.removeValue(forKey: invocationID)
discardedSessionInvocations.remove(invocationID)
if let viewerKey, sessionInvocationByViewer[viewerKey] == invocationID {
sessionInvocationByViewer.removeValue(forKey: viewerKey)
}
}
nonisolated private static func sessionCloseRequest(
capabilityToken: String,
sessionID: String
) -> Data? {
let close: [String: Any] = [
"id": UUID().uuidString,
"version": 1,
"method": "sessionClose",
"params": [
"capabilityToken": capabilityToken,
"sessionId": sessionID,
],
]
return try? JSONSerialization.data(withJSONObject: close)
}
nonisolated private static func closeDiscardedSession(request: Data) async {
await Task.detached(priority: .utility) {
_ = try? await processPool.run(request: request)
}.value
}
static func isTrustedSidecarFrame(_ frameInfo: WKFrameInfo) -> Bool {
frameInfo.isMainFrame && isTrustedSidecarURL(frameInfo.request.url)
}
static func isTrustedSidecarURL(_ url: URL?) -> Bool {
guard let url else { return false }
return CmuxDiffViewerURLSchemeHandler.shared.allowsNavigation(to: url)
}
#if compiler(>=6.2)
@concurrent
#else
@Sendable
#endif
nonisolated fileprivate static func runSidecar(request: Data) async throws -> Data {
let resources = Bundle.main.bundleURL
.appendingPathComponent("Contents/Resources/bin", isDirectory: true)
let sidecar = resources.appendingPathComponent("cmux-diff-sidecar", isDirectory: false)
let cmux = resources.appendingPathComponent("cmux", isDirectory: false)
guard FileManager.default.isExecutableFile(atPath: sidecar.path),
FileManager.default.isExecutableFile(atPath: cmux.path) else {
throw CocoaError(.fileNoSuchFile)
}
let root = try prepareRootDirectory()
let process = Process()
process.executableURL = sidecar
process.arguments = ["rpc", "--root", root.path, "--cmux", cmux.path, "--process-group-ready"]
let input = Pipe()
let output = Pipe()
let readiness = Pipe()
process.standardInput = input
process.standardOutput = output
process.standardError = readiness
let termination = AsyncStream<Int32> { continuation in
process.terminationHandler = { process in
continuation.yield(process.terminationStatus)
continuation.finish()
}
}
return try await withTaskCancellationHandler {
try process.run()
let startup = await waitForProcessGroupReady(
process: process,
input: input,
output: output,
readiness: readiness
)
guard case .ready(true) = startup else {
await terminateAndReap(
process: process,
input: input,
output: output,
readiness: readiness,
termination: termination,
processGroupID: nil
)
if case .cancelled = startup {
throw CancellationError()
}
throw InvocationError.timedOut
}
do {
try input.fileHandleForWriting.write(contentsOf: request)
try input.fileHandleForWriting.close()
} catch {
await terminateAndReap(
process: process,
input: input,
output: output,
readiness: readiness,
termination: termination,
processGroupID: process.processIdentifier
)
throw error
}
let outputTask = Task.detached(priority: .userInitiated) {
output.fileHandleForReading.readDataToEndOfFile()
}
let completion = await withTaskGroup(of: InvocationCompletion.self) { group in
group.addTask {
for await status in termination {
return .terminated(status)
}
return Task.isCancelled ? .cancelled : .missingTermination
}
group.addTask {
do {
try await ContinuousClock().sleep(for: .seconds(requestTimeout))
return .timedOut
} catch {
return .cancelled
}
}
guard let completion = await group.next() else {
return InvocationCompletion.missingTermination
}
group.cancelAll()
return completion
}
switch completion {
case .timedOut, .cancelled:
await terminateAndReap(
process: process,
input: input,
output: output,
readiness: readiness,
termination: termination,
processGroupID: process.processIdentifier
)
case .ready, .terminated, .missingTermination:
break
}
let outputData = await outputTask.value
let status: Int32
switch completion {
case .ready:
throw InvocationError.missingTermination
case .terminated(let terminationStatus):
status = terminationStatus
case .timedOut:
throw InvocationError.timedOut
case .missingTermination:
throw InvocationError.missingTermination
case .cancelled:
throw CancellationError()
}
guard status == 0,
!outputData.isEmpty,
outputData.count <= maximumResponseBytes else {
throw CocoaError(.fileReadCorruptFile)
}
return outputData
} onCancel: {
requestTermination(process: process, input: input, output: output, readiness: readiness)
}
}
nonisolated private static func waitForProcessGroupReady(
process: Process,
input: Pipe,
output: Pipe,
readiness: Pipe
) async -> InvocationCompletion {
let readTask = Task.detached(priority: .userInitiated) {
(try? readProcessGroupReady(from: readiness.fileHandleForReading)) != nil
}
return await withTaskGroup(of: InvocationCompletion.self) { group in
group.addTask { .ready(await readTask.value) }
group.addTask {
do {
try await ContinuousClock().sleep(for: .seconds(startupTimeout))
return .timedOut
} catch {
return .cancelled
}
}
let completion = await group.next() ?? .missingTermination
if case .ready(true) = completion {
// The request can now be sent without racing process-group setup.
} else {
requestTermination(process: process, input: input, output: output, readiness: readiness)
}
group.cancelAll()
return completion
}
}
nonisolated private static func readProcessGroupReady(from handle: FileHandle) throws {
var received = Data()
while received.count < processGroupReadyMarker.count {
let remaining = processGroupReadyMarker.count - received.count
guard let chunk = try handle.read(upToCount: remaining), !chunk.isEmpty else {
throw CocoaError(.fileReadCorruptFile)
}
received.append(chunk)
}
guard received == processGroupReadyMarker else {
throw CocoaError(.fileReadCorruptFile)
}
}
nonisolated private static func requestTermination(
process: Process,
input: Pipe,
output: Pipe,
readiness: Pipe
) {
try? input.fileHandleForWriting.close()
try? output.fileHandleForReading.close()
try? readiness.fileHandleForReading.close()
if process.isRunning {
let processID = process.processIdentifier
if processID > 0, Darwin.getpgid(processID) == processID {
killProcessGroup(process, signal: SIGTERM)
} else {
process.terminate()
}
}
}
nonisolated private static func terminateAndReap(
process: Process,
input: Pipe,
output: Pipe,
readiness: Pipe,
termination: AsyncStream<Int32>,
processGroupID: pid_t?
) async {
let processID = process.processIdentifier
let confirmedProcessGroupID = processGroupID ?? (
processID > 0 && Darwin.getpgid(processID) == processID ? processID : nil
)
requestTermination(process: process, input: input, output: output, readiness: readiness)
let terminated = await withTaskGroup(of: Bool.self) { group in
group.addTask {
for await _ in termination {
return true
}
return !process.isRunning
}
group.addTask {
do {
try await ContinuousClock().sleep(for: .seconds(terminationGrace))
} catch {
return !process.isRunning
}
return false
}
let result = await group.next() ?? false
group.cancelAll()
return result
}
if let confirmedProcessGroupID {
_ = Darwin.kill(-confirmedProcessGroupID, SIGKILL)
} else if !terminated, process.isRunning {
forceTermination(process)
}
process.waitUntilExit()
}
nonisolated private static func forceTermination(_ process: Process) {
let processID = process.processIdentifier
guard processID > 0 else { return }
if Darwin.getpgid(processID) == processID {
killProcessGroup(process, signal: SIGKILL)
} else {
_ = Darwin.kill(processID, SIGKILL)
}
}
nonisolated private static func killProcessGroup(_ process: Process, signal: Int32) {
let processGroup = process.processIdentifier
guard processGroup > 0 else { return }
_ = Darwin.kill(-processGroup, signal)
}
nonisolated private static func prepareRootDirectory() throws -> URL {
let root = URL(fileURLWithPath: "/tmp", isDirectory: true)
.appendingPathComponent("cmux-diff-viewer-\(Darwin.getuid())", isDirectory: true)
try FileManager.default.createDirectory(
at: root,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
return root
}
private static func failureResponse(body: Any, code: String, message: String) -> [String: Any] {
let request = body as? [String: Any]
return [
"id": request?["id"] as? String ?? "unknown",
"version": request?["version"] as? Int ?? 1,
"result": NSNull(),
"error": ["code": code, "message": message],
]
}
}
actor DiffSidecarProcessPool {
private enum PoolError: Error { case queueFull }
private struct Waiter {
let id: UUID
let continuation: CheckedContinuation<Bool, Never>
}
private let limit: Int
private let queueLimit = 32
private var activeCount = 0
private var waiters: [Waiter] = []
init(limit: Int) {
precondition(limit > 0)
self.limit = limit
}
func run(request: Data) async throws -> Data {
try await withPermit {
try await DiffSidecarBridge.runSidecar(request: request)
}
}
func withPermit<T: Sendable>(
_ operation: @Sendable () async throws -> T
) async throws -> T {
try await acquire()
defer { release() }
return try await operation()
}
private func acquire() async throws {
try Task.checkCancellation()
if activeCount < limit {
activeCount += 1
return
}
guard waiters.count < queueLimit else { throw PoolError.queueFull }
let waiterID = UUID()
let granted = await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
waiters.append(Waiter(id: waiterID, continuation: continuation))
}
} onCancel: {
Task { await self.cancelWaiter(waiterID) }
}
guard granted else {
throw CancellationError()
}
if Task.isCancelled {
release()
throw CancellationError()
}
}
private func cancelWaiter(_ waiterID: UUID) {
guard let index = waiters.firstIndex(where: { $0.id == waiterID }) else { return }
let waiter = waiters.remove(at: index)
waiter.continuation.resume(returning: false)
}
private func release() {
if waiters.isEmpty {
activeCount -= 1
return
}
let waiter = waiters.removeFirst()
waiter.continuation.resume(returning: true)
}
}
@@ -0,0 +1,77 @@
import Foundation
import zlib
/// Reads an allowlisted diff-viewer asset in chunks suitable for a URL scheme task.
/// WebKit does not honor Content-Encoding for app-owned custom schemes, so `.deflate`
/// assets must be inflated before they cross the scheme-handler boundary.
final class DiffViewerAssetReader {
private static let maxInflatedSize = 32 * 1024 * 1024
private var decodedData: Data?
private var decodedOffset = 0
private var fileHandle: FileHandle?
init(fileURL: URL) throws {
if fileURL.lastPathComponent.hasSuffix(".deflate") {
decodedData = try Self.inflateZlib(Data(contentsOf: fileURL, options: .mappedIfSafe))
} else {
fileHandle = try FileHandle(forReadingFrom: fileURL)
}
}
func read(upToCount count: Int) throws -> Data {
if let decodedData {
guard decodedOffset < decodedData.count else { return Data() }
let end = min(decodedOffset + count, decodedData.count)
defer { decodedOffset = end }
return decodedData.subdata(in: decodedOffset..<end)
}
return try fileHandle?.read(upToCount: count) ?? Data()
}
func close() throws {
try fileHandle?.close()
fileHandle = nil
}
private static func inflateZlib(_ compressed: Data) throws -> Data {
var stream = z_stream()
guard inflateInit_(&stream, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) == Z_OK else {
throw CocoaError(.fileReadCorruptFile)
}
defer { inflateEnd(&stream) }
return try compressed.withUnsafeBytes { inputBuffer in
guard let inputBase = inputBuffer.bindMemory(to: Bytef.self).baseAddress else {
throw CocoaError(.fileReadCorruptFile)
}
stream.next_in = UnsafeMutablePointer<Bytef>(mutating: inputBase)
stream.avail_in = uInt(compressed.count)
var output = Data()
let chunkSize = 64 * 1024
var chunk = [UInt8](repeating: 0, count: chunkSize)
while true {
let result = chunk.withUnsafeMutableBytes { outputBuffer -> Int32 in
stream.next_out = outputBuffer.bindMemory(to: Bytef.self).baseAddress
stream.avail_out = uInt(chunkSize)
return inflate(&stream, Z_NO_FLUSH)
}
let produced = chunkSize - Int(stream.avail_out)
guard output.count <= maxInflatedSize - produced else {
throw CocoaError(.fileReadTooLarge)
}
output.append(chunk, count: produced)
if result == Z_STREAM_END {
return output
}
guard result == Z_OK, stream.avail_in > 0 || produced > 0 else {
throw CocoaError(.fileReadCorruptFile)
}
}
}
}
}
+1 -2
View File
@@ -6658,7 +6658,6 @@ class TerminalController {
guard let url = v2String(params, "url") else {
return .err(code: "invalid_params", message: "Missing url", data: nil)
}
var basePayload: [String: Any]?
var resolutionError: V2CallResult?
v2MainSync {
@@ -6669,7 +6668,7 @@ class TerminalController {
}
guard let context = resolvedContext.context,
context.surfaceId == surfaceId else { return }
context.browserPanel.navigateSmart(url)
if !context.browserPanel.navigateFromCLI(url, expectedURL: v2String(params, "expected_url")) { resolutionError = .err(code: "stale_state", message: "Browser URL changed before navigation", data: nil); return }
if AppDelegate.shared?.tabManagerForWindowDockOwner(context.workspaceId) != nil {
basePayload = v2WindowDockBrowserActionPayload(context)
} else {
+49
View File
@@ -478,6 +478,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */; };
B90000D2A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */; };
B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */; };
D1FF51000000000000000001 /* CMUXCLI+DiffSidecar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FF51000000000000000002 /* CMUXCLI+DiffSidecar.swift */; };
C0DE75890000000000000001 /* CMUXCLI+DiffViewerBundledAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE75890000000000000002 /* CMUXCLI+DiffViewerBundledAssets.swift */; };
C0DE79210000000000000001 /* CMUXCLI+DiffViewerShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE79210000000000000002 /* CMUXCLI+DiffViewerShortcuts.swift */; };
B9000035A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */; };
@@ -514,6 +515,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
7837A0017837A0017837A001 /* CMUXCLI+TmuxCompatResizePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7837A0027837A0027837A002 /* CMUXCLI+TmuxCompatResizePane.swift */; };
B9000044A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000045A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift */; };
B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */; };
D1FF52000000000000000001 /* CMUXCLI+TypedDiffViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */; };
06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */; };
C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; };
A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */; };
@@ -714,6 +716,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
D1FFC0DE000000000000A002 /* DiffCommentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC0DE000000000000A001 /* DiffCommentStore.swift */; };
D1FFC0DE000000000000C002 /* DiffCommentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC0DE000000000000C001 /* DiffCommentStoreTests.swift */; };
D1FFC0DE000000000000D002 /* DiffCommentSubmissionPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC0DE000000000000D001 /* DiffCommentSubmissionPool.swift */; };
D1FFC0DE000000000000E002 /* DiffSidecarBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC0DE000000000000E001 /* DiffSidecarBridge.swift */; };
D1FFC0DE000000000000F002 /* DiffViewerAssetReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC0DE000000000000F001 /* DiffViewerAssetReader.swift */; };
A28B087F0000000000000001 /* DiffViewerEditableFocusMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B087F0000000000000002 /* DiffViewerEditableFocusMessageHandler.swift */; };
A28B087F0000000000000019 /* DiffViewerLiveHTTPSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B087F0000000000000018 /* DiffViewerLiveHTTPSession.swift */; };
A28B087F0000000000000017 /* DiffViewerNavigationDocumentSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B087F0000000000000016 /* DiffViewerNavigationDocumentSnapshot.swift */; };
@@ -2491,6 +2495,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexFireAndForgetHooks.swift"; sourceTree = "<group>"; };
B90000D1A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CommandSuggestions.swift"; sourceTree = "<group>"; };
B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Config.swift"; sourceTree = "<group>"; };
D1FF51000000000000000002 /* CMUXCLI+DiffSidecar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+DiffSidecar.swift"; sourceTree = "<group>"; };
C0DE75890000000000000002 /* CMUXCLI+DiffViewerBundledAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+DiffViewerBundledAssets.swift"; sourceTree = "<group>"; };
C0DE79210000000000000002 /* CMUXCLI+DiffViewerShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+DiffViewerShortcuts.swift"; sourceTree = "<group>"; };
B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+DocsSettings.swift"; sourceTree = "<group>"; };
@@ -2527,6 +2532,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
7837A0027837A0027837A002 /* CMUXCLI+TmuxCompatResizePane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TmuxCompatResizePane.swift"; sourceTree = "<group>"; };
B9000045A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TmuxCompatSupport.swift"; sourceTree = "<group>"; };
B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TopRendering.swift"; sourceTree = "<group>"; };
D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TypedDiffViewer.swift"; sourceTree = "<group>"; };
7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+WorkspaceTodo.swift"; sourceTree = "<group>"; };
C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLIErrorOutputRegressionTests.swift; sourceTree = "<group>"; };
8A4FE96C3F394FC6A6D4B018 /* CmuxCLIPathInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxCLIPathInstaller.swift; sourceTree = "<group>"; };
@@ -2678,6 +2684,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
D1FFC0DE000000000000A001 /* DiffCommentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffCommentStore.swift; sourceTree = "<group>"; };
D1FFC0DE000000000000C001 /* DiffCommentStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffCommentStoreTests.swift; sourceTree = "<group>"; };
D1FFC0DE000000000000D001 /* DiffCommentSubmissionPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffCommentSubmissionPool.swift; sourceTree = "<group>"; };
D1FFC0DE000000000000E001 /* DiffSidecarBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DiffSidecarBridge.swift; sourceTree = "<group>"; };
D1FFC0DE000000000000F001 /* DiffViewerAssetReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DiffViewerAssetReader.swift; sourceTree = "<group>"; };
A28B087F0000000000000002 /* DiffViewerEditableFocusMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DiffViewerEditableFocusMessageHandler.swift; sourceTree = "<group>"; };
A28B087F0000000000000018 /* DiffViewerLiveHTTPSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DiffViewerLiveHTTPSession.swift; sourceTree = "<group>"; };
A28B087F0000000000000016 /* DiffViewerNavigationDocumentSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DiffViewerNavigationDocumentSnapshot.swift; sourceTree = "<group>"; };
@@ -5028,6 +5036,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
A9F100000000000000000012 /* AgentSessionWebHostGeometryState.swift */,
A9F100000000000000000013 /* AgentSessionWebRendererCoordinator.swift */,
D1FFC0DE000000000000B001 /* DiffCommentsBridge.swift */,
D1FFC0DE000000000000E001 /* DiffSidecarBridge.swift */,
D1FFC0DE000000000000F001 /* DiffViewerAssetReader.swift */,
A28B087F0000000000000002 /* DiffViewerEditableFocusMessageHandler.swift */,
A28B087F0000000000000004 /* MarkdownEditableFocusMessageHandler.swift */,
A28B087F0000000000000006 /* ViewerNavigationKeyRouter.swift */,
@@ -5406,6 +5416,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
C0DE75890000000000000002 /* CMUXCLI+DiffViewerBundledAssets.swift */,
C0DE79210000000000000002 /* CMUXCLI+DiffViewerShortcuts.swift */,
B9000030A1B2C3D4E5F60719 /* cmux_open.swift */,
D1FF51000000000000000002 /* CMUXCLI+DiffSidecar.swift */,
D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */,
CBF07000000000000000001 /* CMUXCLI+SSHStartupScripts.swift */,
C0DE1A060000000000000002 /* cmux_layout.swift */,
B9000040A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift */,
@@ -6017,6 +6029,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
D1320AA0D1320AA0D1320AA6 /* Copy Dock Tile Plugin */,
A5001020 /* Embed Frameworks */,
B900000AA1B2C3D4E5F60719 /* Copy CLI */,
D1FF50000000000000000001 /* Build Diff Sidecar */,
C0DEFF100000000000000003 /* Build Command Palette Nucleo FFI */,
A5001300A1B2C3D4E5F60719 /* ShellScript */,
A9E030000000000000000009 /* Reject Bundled Provider Binaries */,
@@ -6381,6 +6394,38 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
shellPath = /bin/sh;
shellScript = "\"${SRCROOT}/scripts/build-command-palette-nucleo-ffi.sh\"\n";
};
D1FF50000000000000000001 /* Build Diff Sidecar */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/Native/DiffSidecar/Cargo.toml",
"$(SRCROOT)/Native/DiffSidecar/Cargo.lock",
"$(SRCROOT)/Native/DiffSidecar/rust-toolchain.toml",
"$(SRCROOT)/Native/DiffSidecar/src/benchmark.rs",
"$(SRCROOT)/Native/DiffSidecar/src/lib.rs",
"$(SRCROOT)/Native/DiffSidecar/src/main.rs",
"$(SRCROOT)/Native/DiffSidecar/src/manifest.rs",
"$(SRCROOT)/Native/DiffSidecar/src/protocol.rs",
"$(SRCROOT)/Native/DiffSidecar/src/server.rs",
"$(SRCROOT)/scripts/build-diff-sidecar.sh",
"$(SRCROOT)/scripts/run-diff-sidecar-cargo.sh",
"$(SRCROOT)/scripts/verify-diff-sidecar-artifact.sh",
);
name = "Build Diff Sidecar";
outputFileListPaths = (
);
outputPaths = (
"$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/bin/cmux-diff-sidecar",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${SRCROOT}/scripts/build-diff-sidecar.sh\"\n";
};
A9E030000000000000000009 /* Reject Bundled Provider Binaries */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -6789,6 +6834,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
D1FFC0DE000000000000B002 /* DiffCommentsBridge.swift in Sources */,
D1FFC0DE000000000000A002 /* DiffCommentStore.swift in Sources */,
D1FFC0DE000000000000D002 /* DiffCommentSubmissionPool.swift in Sources */,
D1FFC0DE000000000000E002 /* DiffSidecarBridge.swift in Sources */,
D1FFC0DE000000000000F002 /* DiffViewerAssetReader.swift in Sources */,
A28B087F0000000000000001 /* DiffViewerEditableFocusMessageHandler.swift in Sources */,
A28B087F0000000000000019 /* DiffViewerLiveHTTPSession.swift in Sources */,
A28B087F0000000000000017 /* DiffViewerNavigationDocumentSnapshot.swift in Sources */,
@@ -7674,6 +7721,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */,
B90000D2A1B2C3D4E5F60719 /* CMUXCLI+CommandSuggestions.swift in Sources */,
B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */,
D1FF51000000000000000001 /* CMUXCLI+DiffSidecar.swift in Sources */,
C0DE75890000000000000001 /* CMUXCLI+DiffViewerBundledAssets.swift in Sources */,
C0DE79210000000000000001 /* CMUXCLI+DiffViewerShortcuts.swift in Sources */,
B9000035A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */,
@@ -7710,6 +7758,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
7837A0017837A0017837A001 /* CMUXCLI+TmuxCompatResizePane.swift in Sources */,
B9000044A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift in Sources */,
B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */,
D1FF52000000000000000001 /* CMUXCLI+TypedDiffViewer.swift in Sources */,
06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */,
C0DECAFE0000000000000001 /* CodexTeamsApprovalBridge.swift in Sources */,
FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */,
+13 -13
View File
@@ -715,7 +715,6 @@ final class BrowserPanelDiffViewerSchemeTests: XCTestCase {
let indexURL = rootURL.appendingPathComponent("index.html", isDirectory: false)
try FileManager.default.createDirectory(at: assetURL.deletingLastPathComponent(), withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: rootURL) }
let deflatedAssetURL = assetURL.appendingPathExtension("deflate")
let deflatedWorkerAssetURL = workerAssetURL.appendingPathExtension("deflate")
try DeflatedAssetTestSupport.writeText("""
@@ -729,13 +728,13 @@ final class BrowserPanelDiffViewerSchemeTests: XCTestCase {
<html>
<body>
<script type="module">
import { marker } from "./assets/mod.mjs";
import { workerMarker } from "./assets/worker.js";
WebAssembly.compile(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]))
.then(() => {
Promise.all([import("./assets/mod.mjs"), import("./assets/worker.js"), fetch("./assets/mod.mjs").then((response) => response.text())]).then(([{ marker }, { workerMarker }, source]) => {
if (!source.includes('marker = "module-ok"')) throw new Error("custom-scheme fetch returned compressed module bytes");
return WebAssembly.compile(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])).then(() => ({ marker, workerMarker }));
})
.then(({ marker, workerMarker }) => {
const result = `${marker}:${workerMarker}:wasm-ok`;
document.body.dataset.loaded = result;
window.webkit.messageHandlers.moduleLoaded.postMessage(result);
document.body.dataset.loaded = result; window.webkit.messageHandlers.moduleLoaded.postMessage(result);
})
.catch((error) => {
const result = `wasm-error:${error.message}`;
@@ -758,16 +757,17 @@ final class BrowserPanelDiffViewerSchemeTests: XCTestCase {
.init(requestPath: "/index.patch", fileURL: patchURL, mimeType: "text/x-diff"),
]
)
let allowedURL = try XCTUnwrap(URL(string: "\(CmuxDiffViewerURLSchemeHandler.scheme)://\(token)/index.html"))
let allowedPatchURL = try XCTUnwrap(URL(string: "\(CmuxDiffViewerURLSchemeHandler.scheme)://\(token)/index.patch"))
let blockedURL = try XCTUnwrap(URL(string: "\(CmuxDiffViewerURLSchemeHandler.scheme)://\(token)/not-allowed.html"))
let queryURL = try XCTUnwrap(URL(string: "\(CmuxDiffViewerURLSchemeHandler.scheme)://\(token)/index.html?copy=1"))
let rejectedURLs = try ["\(CmuxDiffViewerURLSchemeHandler.scheme)://\(token)/not-allowed.html", "\(CmuxDiffViewerURLSchemeHandler.scheme)://\(token)/index.html?copy=1", "\(CmuxDiffViewerURLSchemeHandler.scheme)://\(token)/index.html#route", "\(CmuxDiffViewerURLSchemeHandler.scheme)://user@\(token)/index.html", "\(CmuxDiffViewerURLSchemeHandler.scheme)://\(token):42/index.html"].map { try XCTUnwrap(URL(string: $0)) }
XCTAssertNotNil(CmuxDiffViewerURLSchemeHandler.shared.registeredFile(for: allowedURL))
XCTAssertNotNil(CmuxDiffViewerURLSchemeHandler.shared.registeredFile(for: allowedPatchURL))
XCTAssertNil(CmuxDiffViewerURLSchemeHandler.shared.registeredFile(for: blockedURL))
XCTAssertNil(CmuxDiffViewerURLSchemeHandler.shared.registeredFile(for: queryURL))
XCTAssertNil(CmuxDiffViewerURLSchemeHandler.shared.registeredFile(for: rejectedURLs[0]))
XCTAssertNil(CmuxDiffViewerURLSchemeHandler.shared.registeredFile(for: rejectedURLs[1]))
XCTAssertTrue(CmuxDiffViewerURLSchemeHandler.shared.allowsNavigation(to: allowedURL))
for rejectedURL in rejectedURLs {
XCTAssertFalse(CmuxDiffViewerURLSchemeHandler.shared.allowsNavigation(to: rejectedURL))
}
let config = WKWebViewConfiguration()
let contentController = WKUserContentController()
let moduleLoaded = expectation(description: "module evaluated")
@@ -1,6 +1,92 @@
import XCTest
import Darwin
extension CMUXOpenCommandTests {
func openTypedDiffSession(payload: [String: Any], cliPath: String) throws -> String {
let source = try XCTUnwrap(payload["sessionSource"] as? [String: Any])
let token = try XCTUnwrap(payload["capabilityToken"] as? String)
let sidecarURL = URL(fileURLWithPath: cliPath)
.deletingLastPathComponent()
.appendingPathComponent("cmux-diff-sidecar", isDirectory: false)
let rootURL = URL(fileURLWithPath: "/tmp/cmux-diff-viewer-\(Darwin.getuid())", isDirectory: true)
let request: [String: Any] = [
"id": "xctest-session",
"version": 1,
"method": "sessionOpen",
"params": ["source": source, "capabilityToken": token],
]
let requestData = try JSONSerialization.data(withJSONObject: request)
let result = runProcess(
executablePath: sidecarURL.path,
arguments: ["rpc", "--root", rootURL.path, "--cmux", cliPath],
environment: ProcessInfo.processInfo.environment,
timeout: 15,
stdinText: String(decoding: requestData, as: UTF8.self)
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
let response = try XCTUnwrap(
JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any]
)
if let error = response["error"] as? [String: Any],
error["code"] as? String == "emptyDiff" {
return ""
}
let opened = try XCTUnwrap(response["result"] as? [String: Any])
XCTAssertEqual(opened["type"] as? String, "sessionOpened")
let value = try XCTUnwrap(opened["value"] as? [String: Any])
let patchRef = try XCTUnwrap(value["patch"] as? [String: Any])
let patchID = try XCTUnwrap(patchRef["id"] as? String)
let patchURL = try XCTUnwrap(URL(string: patchID))
let patch = try String(
contentsOf: rootURL.appendingPathComponent(
patchURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
),
encoding: .utf8
)
if let sessionID = value["sessionId"] as? String {
let close: [String: Any] = [
"id": "xctest-session-close",
"version": 1,
"method": "sessionClose",
"params": ["sessionId": sessionID, "capabilityToken": token],
]
if let closeData = try? JSONSerialization.data(withJSONObject: close) {
_ = runProcess(
executablePath: sidecarURL.path,
arguments: ["rpc", "--root", rootURL.path, "--cmux", cliPath],
environment: ProcessInfo.processInfo.environment,
timeout: 15,
stdinText: String(decoding: closeData, as: UTF8.self)
)
}
}
return patch
}
func resolvedDiffViewerHTMLFileURL(_ fileURL: URL, from params: [String: Any]) throws -> URL {
var current = fileURL
for _ in 0..<4 {
let html = try String(contentsOf: current, encoding: .utf8)
guard let redirectURL = Self.diffViewerRedirectURL(from: html) else {
return current
}
current = try diffViewerHTMLFileURL(for: redirectURL, from: params)
}
return current
}
private static func diffViewerRedirectURL(from html: String) -> String? {
let marker = "data-cmux-diff-redirect=\""
guard let start = html.range(of: marker)?.upperBound else { return nil }
let tail = html[start...]
guard let end = tail.firstIndex(of: "\"") else { return nil }
return String(tail[..<end])
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&quot;", with: "\"")
}
}
extension CLINotifyProcessIntegrationRegressionTests {
private final class MockSocketFulfillmentGate: @unchecked Sendable {
private let lock = NSLock()
+90 -148
View File
@@ -3,7 +3,7 @@ import Foundation
import XCTest
final class CMUXOpenCommandTests: XCTestCase {
private struct ProcessRunResult {
struct ProcessRunResult {
let status: Int32
let stdout: String
let stderr: String
@@ -315,9 +315,9 @@ final class CMUXOpenCommandTests: XCTestCase {
params["focus"] as? Bool == true,
let rawURL = params["url"] as? String,
let viewerURL = URL(string: rawURL),
viewerURL.scheme == "http",
viewerURL.host == "127.0.0.1",
viewerURL.fragment == "cmux-diff-viewer" else {
viewerURL.scheme == "cmux-diff-viewer",
viewerURL.host?.isEmpty == false,
viewerURL.fragment == nil else {
return Self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": method])
}
return Self.v2Response(
@@ -357,11 +357,9 @@ final class CMUXOpenCommandTests: XCTestCase {
XCTAssertEqual(params["bypass_remote_proxy"] as? Bool, true)
let rawURL = try XCTUnwrap(params["url"] as? String)
let viewerURL = try XCTUnwrap(URL(string: rawURL))
XCTAssertEqual(viewerURL.scheme, "http")
XCTAssertEqual(viewerURL.host, "127.0.0.1")
XCTAssertEqual(viewerURL.fragment, "cmux-diff-viewer")
XCTAssertNil(params["diff_viewer_token"])
XCTAssertNil(params["diff_viewer_files"])
XCTAssertEqual(viewerURL.scheme, "cmux-diff-viewer")
XCTAssertEqual(params["diff_viewer_token"] as? String, viewerURL.host)
XCTAssertNotNil(params["diff_viewer_files"] as? [[String: Any]])
let viewerFileURL = try diffViewerHTMLFileURL(from: params)
defer { try? FileManager.default.removeItem(at: viewerFileURL) }
let patchSidecarURL = viewerFileURL.deletingPathExtension().appendingPathExtension("patch")
@@ -372,6 +370,9 @@ final class CMUXOpenCommandTests: XCTestCase {
let viewerConfig = try diffViewerConfig(from: html)
let viewerPayload = try diffViewerPayload(from: viewerConfig)
let viewerAssets = try diffViewerAssets(from: viewerConfig)
let transport = try XCTUnwrap(viewerPayload["transport"] as? [String: Any])
XCTAssertEqual([transport["kind"] as? String, transport["endpoint"] as? String], ["webKit", "cmuxDiff"])
XCTAssertEqual(transport["protocolVersion"] as? Int, 1)
let shortcuts = try XCTUnwrap(viewerPayload["shortcuts"] as? [String: Any])
let scrollDown = try XCTUnwrap(shortcuts["diffViewerScrollDown"] as? [String: Any])
let scrollDownFirst = try XCTUnwrap(scrollDown["first"] as? [String: Any])
@@ -456,7 +457,7 @@ final class CMUXOpenCommandTests: XCTestCase {
XCTAssertTrue(darkOnlyTheme.html.contains("\"ghosttyName\":\"Unit Dark\""), darkOnlyTheme.html)
}
func testDiffCommandUsesTaggedSocketAppAssetsAndServer() throws {
func testDiffCommandUsesTaggedSocketAppAssetsWithoutServer() throws {
let cliPath = try bundledCLIPath()
let tag = "asset\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(6).lowercased())"
let socketPath = URL(fileURLWithPath: NSTemporaryDirectory())
@@ -541,23 +542,24 @@ final class CMUXOpenCommandTests: XCTestCase {
XCTAssertTrue(appFilePath.hasSuffix("main.mjs.deflate"), appFilePath)
let appMain = try DeflatedAssetTestSupport.loadText(path: appFilePath)
XCTAssertTrue(appMain.contains("cmuxTaggedSocketAssetMarker = 'target-\(tag)'"), appMain)
let stateURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("cmux-diff-viewer-\(Darwin.getuid())", isDirectory: true)
.appendingPathComponent(".server-state", isDirectory: false)
let serverState = try JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]
XCTAssertEqual(serverState?["executablePath"] as? String, targetCLIURL.path)
XCTAssertEqual(URL(string: rawURL)?.scheme, "cmux-diff-viewer")
}
func testDiffCommandLinksOriginalDiffshubPRURL() throws {
func testDiffCommandMaterializesRemotePatchForCustomScheme() throws {
let cliPath = try bundledCLIPath()
let fakeBin = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-diff-fake-curl-\(UUID().uuidString)", isDirectory: true)
let fakeCurl = fakeBin.appendingPathComponent("curl", isDirectory: false)
try FileManager.default.createDirectory(at: fakeBin, withIntermediateDirectories: true)
let script = "#!/bin/sh\nwhile [ \"$1\" != \"--output\" ]; do shift; done\nshift\nprintf 'diff --git a/file.txt b/file.txt\\n--- a/file.txt\\n+++ b/file.txt\\n@@ -1 +1 @@\\n-old\\n+new\\n' > \"$1\"\n"
try script.write(to: fakeCurl, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeCurl.path)
defer { try? FileManager.default.removeItem(at: fakeBin) }
let originalURL = "https://diffshub.com/oven-sh/bun/pull/30412"
let result = try runDiffCLIAndReadHTML(
cliPath: cliPath,
arguments: ["diff", originalURL, "--title", "Bun PR"],
environmentOverrides: ["CMUX_DIFF_VIEWER_STREAM_REMOTE": "1"],
readPatchSidecar: false
environmentOverrides: ["PATH": "\(fakeBin.path):/usr/bin:/bin"]
)
XCTAssertEqual(result.params["show_omnibar"] as? Bool, false)
@@ -569,11 +571,11 @@ final class CMUXOpenCommandTests: XCTestCase {
let patchFile = try XCTUnwrap(files.first { file in
file["mime_type"] as? String == "text/x-diff"
})
XCTAssertEqual(patchFile["file_path"] as? String, "")
XCTAssertEqual(patchFile["remote_url"] as? String, "https://github.com/oven-sh/bun/pull/30412.diff")
XCTAssertFalse((patchFile["file_path"] as? String ?? "").isEmpty)
XCTAssertNil(patchFile["remote_url"])
let viewerFileURL = try diffViewerHTMLFileURL(for: rawURL, from: result.params)
let patchSidecarURL = viewerFileURL.deletingPathExtension().appendingPathExtension("patch")
XCTAssertFalse(FileManager.default.fileExists(atPath: patchSidecarURL.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: patchSidecarURL.path))
}
func testDiffViewerServerBoundsDeferredWaitRequests() throws {
@@ -716,7 +718,7 @@ final class CMUXOpenCommandTests: XCTestCase {
XCTAssertTrue(result.stdout.contains("--base <ref>"), result.stdout)
}
func testDiffCommandFallsBackToNonEmptyGitSourceForSelector() throws {
func testDiffCommandKeepsRequestedGitSourceAndLoadsAlternativesOnDemand() throws {
let cliPath = try bundledCLIPath()
let rootURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -751,7 +753,7 @@ final class CMUXOpenCommandTests: XCTestCase {
""".write(to: gitWrapperURL, atomically: true, encoding: .utf8)
chmod(gitWrapperURL.path, 0o755)
let stagedFallback = try runDiffCLIAndReadHTML(
let unstagedResult = try runDiffCLIAndReadHTML(
cliPath: cliPath,
arguments: ["diff", "--unstaged"],
environmentOverrides: [
@@ -760,20 +762,22 @@ final class CMUXOpenCommandTests: XCTestCase {
currentDirectoryURL: repoURL
)
XCTAssertTrue(stagedFallback.html.contains("Staged changes"), stagedFallback.html)
XCTAssertTrue(stagedFallback.html.contains("\"sourceLabel\":\"git staged\""), stagedFallback.html)
XCTAssertTrue(stagedFallback.patch.contains("+two"), stagedFallback.patch)
let payload = try diffViewerPayload(from: stagedFallback.html)
XCTAssertTrue(unstagedResult.html.contains("Unstaged changes"), unstagedResult.html)
XCTAssertTrue(unstagedResult.html.contains("\"sourceLabel\":\"git unstaged\""), unstagedResult.html)
XCTAssertTrue(unstagedResult.patch.isEmpty, unstagedResult.patch)
let payload = try diffViewerPayload(from: unstagedResult.html)
let sourceOptions = try XCTUnwrap(payload["sourceOptions"] as? [[String: Any]])
let stagedOption = try XCTUnwrap(sourceOptions.first { $0["value"] as? String == "staged" })
let unstagedOption = try XCTUnwrap(sourceOptions.first { $0["value"] as? String == "unstaged" })
XCTAssertEqual(stagedOption["selected"] as? Bool, true)
XCTAssertEqual(unstagedOption["selected"] as? Bool, false)
let unstagedURLString = try diffViewerOptionURL(value: "unstaged", in: sourceOptions)
let unstagedFileURL = try diffViewerHTMLFileURL(for: unstagedURLString, from: stagedFallback.params)
let unstagedHTML = try String(contentsOf: unstagedFileURL, encoding: .utf8)
XCTAssertTrue(unstagedHTML.contains("No unstaged changes to diff."), unstagedHTML)
XCTAssertFalse(unstagedHTML.contains("+two"), unstagedHTML)
XCTAssertEqual(stagedOption["selected"] as? Bool, false)
XCTAssertEqual(unstagedOption["selected"] as? Bool, true)
XCTAssertEqual(payload["emptyMessage"] as? String, "No unstaged changes to diff.")
let stagedURLString = try diffViewerOptionURL(value: "staged", in: sourceOptions)
let stagedFileURL = try diffViewerHTMLFileURL(for: stagedURLString, from: unstagedResult.params)
let stagedHTML = try String(contentsOf: stagedFileURL, encoding: .utf8)
let stagedPayload = try diffViewerPayload(from: stagedHTML)
XCTAssertEqual((stagedPayload["sessionSource"] as? [String: Any])?["kind"] as? String, "staged")
XCTAssertTrue(try openTypedDiffSession(payload: stagedPayload, cliPath: cliPath).contains("+two"))
let gitLog = try String(contentsOf: gitLogURL, encoding: .utf8)
XCTAssertFalse(gitLog.contains(plainSiblingURL.path), gitLog)
}
@@ -845,10 +849,12 @@ final class CMUXOpenCommandTests: XCTestCase {
let openedFileURL = try diffViewerHTMLFileURL(for: rawURL, from: params)
let viewerFileURL = try resolvedDiffViewerHTMLFileURL(openedFileURL, from: params)
let html = try String(contentsOf: viewerFileURL, encoding: .utf8)
XCTAssertTrue(html.contains("No unstaged changes to diff."), html)
XCTAssertTrue(html.contains("data-cmux-diff-pending=\"true\""), html)
XCTAssertFalse(html.contains("No last-turn diff baseline recorded"), html)
let payload = try diffViewerPayload(from: html)
XCTAssertEqual(payload["statusIsError"] as? Bool, false, html)
XCTAssertEqual(payload["emptyMessage"] as? String, "No unstaged changes to diff.")
XCTAssertTrue(try openTypedDiffSession(payload: payload, cliPath: cliPath).isEmpty)
}
func testDiffCommandShowsFriendlyEmptyStateForLastTurnWithoutBaseline() throws {
@@ -1644,7 +1650,7 @@ final class CMUXOpenCommandTests: XCTestCase {
XCTAssertTrue(large.patch.contains("+new line 4999"), large.patch)
}
func testDiffCommandOpensPendingViewerBeforeGitDiffCompletes() throws {
func testTypedDiffCommandOpensFinalSessionPageWithoutDeferredNavigation() throws {
let cliPath = try bundledCLIPath()
let rootURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -1652,9 +1658,6 @@ final class CMUXOpenCommandTests: XCTestCase {
let fakeBinURL = rootURL.appendingPathComponent("bin", isDirectory: true)
let fakeGitURL = fakeBinURL.appendingPathComponent("git", isDirectory: false)
let diffStartedURL = rootURL.appendingPathComponent("diff-started", isDirectory: false)
let releaseDiffURL = rootURL.appendingPathComponent("release-diff", isDirectory: false)
let alternateStartedURL = rootURL.appendingPathComponent("alternate-started", isDirectory: false)
let releaseAlternateURL = rootURL.appendingPathComponent("release-alternate", isDirectory: false)
try FileManager.default.createDirectory(at: repoURL.appendingPathComponent(".git", isDirectory: true), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: fakeBinURL, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: rootURL) }
@@ -1670,23 +1673,14 @@ final class CMUXOpenCommandTests: XCTestCase {
fi
if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "--verify" ]; then
: > "$CMUX_FAKE_GIT_STARTED"
while [ ! -f "$CMUX_FAKE_GIT_RELEASE" ]; do
sleep 0.05
done
exit 1
fi
if [ "${1:-}" = "diff" ] && [ "${2:-}" = "--cached" ]; then
: > "$CMUX_FAKE_GIT_ALTERNATE_STARTED"
while [ ! -f "$CMUX_FAKE_GIT_RELEASE_ALTERNATE" ]; do
sleep 0.05
done
: > "$CMUX_FAKE_GIT_STARTED"
exit 0
fi
if [ "${1:-}" = "diff" ]; then
: > "$CMUX_FAKE_GIT_STARTED"
while [ ! -f "$CMUX_FAKE_GIT_RELEASE" ]; do
sleep 0.05
done
cat <<'PATCH'
diff --git a/large.txt b/large.txt
index 1111111..2222222 100644
@@ -1710,9 +1704,6 @@ final class CMUXOpenCommandTests: XCTestCase {
let state = MockSocketServerState()
let openedURLBox = AsyncValueBox<String?>(nil)
let openedHTMLURLBox = AsyncValueBox<URL?>(nil)
let pendingHTMLBox = AsyncValueBox<String?>(nil)
let diffHadStartedWhenOpenedBox = AsyncValueBox<Bool?>(nil)
let openHandled = expectation(description: "browser opened before fake git diff completed")
defer {
Darwin.close(listenerFD)
unlink(socketPath)
@@ -1728,12 +1719,9 @@ final class CMUXOpenCommandTests: XCTestCase {
return Self.v2Response(id: "unknown", ok: false, error: ["code": "unexpected"])
}
openedURLBox.set(rawURL)
diffHadStartedWhenOpenedBox.set(FileManager.default.fileExists(atPath: diffStartedURL.path))
if let htmlURL = Self.diffViewerHTMLFileURLFromHTTPManifest(for: rawURL) {
openedHTMLURLBox.set(htmlURL)
pendingHTMLBox.set(try? String(contentsOf: htmlURL, encoding: .utf8))
}
openHandled.fulfill()
return Self.v2Response(
id: id,
ok: true,
@@ -1741,73 +1729,43 @@ final class CMUXOpenCommandTests: XCTestCase {
)
}
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = "\(fakeBinURL.path):\(environment["PATH"] ?? "")"
environment["CMUX_SOCKET_PATH"] = socketPath
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
environment["CMUX_FAKE_GIT_REPO_ROOT"] = repoURL.path
environment["CMUX_FAKE_GIT_STARTED"] = diffStartedURL.path
environment["CMUX_FAKE_GIT_RELEASE"] = releaseDiffURL.path
environment["CMUX_FAKE_GIT_ALTERNATE_STARTED"] = alternateStartedURL.path
environment["CMUX_FAKE_GIT_RELEASE_ALTERNATE"] = releaseAlternateURL.path
process.executableURL = URL(fileURLWithPath: cliPath)
process.arguments = ["diff", "--unstaged", "--cwd", repoURL.path, "--title", "Slow diff", "--no-focus"]
process.environment = environment
process.currentDirectoryURL = repoURL
process.standardInput = FileHandle.nullDevice
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try process.run()
defer { terminateProcess(process) }
let result = runCLI(
cliPath: cliPath,
socketPath: socketPath,
arguments: ["diff", "--unstaged", "--cwd", repoURL.path, "--title", "Slow diff", "--no-focus"],
environmentOverrides: [
"PATH": "\(fakeBinURL.path):\(ProcessInfo.processInfo.environment["PATH"] ?? "")",
"CMUX_FAKE_GIT_REPO_ROOT": repoURL.path,
"CMUX_FAKE_GIT_STARTED": diffStartedURL.path,
],
currentDirectoryURL: repoURL
)
wait(for: [openHandled], timeout: 5)
XCTAssertNotNil(openedURLBox.get())
XCTAssertEqual(diffHadStartedWhenOpenedBox.get() ?? true, false)
let pendingHTML = try XCTUnwrap(pendingHTMLBox.get())
let pendingPayload = try diffViewerPayload(from: pendingHTML)
XCTAssertTrue(pendingHTML.contains("data-cmux-diff-pending=\"true\""), pendingHTML)
XCTAssertFalse(pendingHTML.contains("data-status-only=\"true\""), pendingHTML)
XCTAssertTrue(pendingHTML.contains("<div id=\"root\"></div>"), pendingHTML)
XCTAssertEqual(pendingPayload["pendingReplacement"] as? Bool, true)
XCTAssertEqual(pendingPayload["title"] as? String, "Slow diff")
XCTAssertEqual(pendingPayload["statusIsError"] as? Bool, false)
XCTAssertFalse(FileManager.default.fileExists(atPath: releaseDiffURL.path))
FileManager.default.createFile(atPath: releaseDiffURL.path, contents: Data())
let openingHTMLURL = try XCTUnwrap(openedHTMLURLBox.get())
XCTAssertTrue(waitUntil(timeout: 5) {
let html = (try? String(contentsOf: openingHTMLURL, encoding: .utf8)) ?? ""
return html.contains("data-cmux-diff-redirect=")
&& FileManager.default.fileExists(atPath: alternateStartedURL.path)
})
XCTAssertFalse(FileManager.default.fileExists(atPath: releaseAlternateURL.path))
XCTAssertTrue(process.isRunning)
FileManager.default.createFile(atPath: releaseAlternateURL.path, contents: Data())
let finished = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
finished.signal()
}
XCTAssertEqual(finished.wait(timeout: .now() + 5), .success)
wait(for: [serverClosed], timeout: 5)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertTrue(result.stdout.contains("OK surface=surface-id pane=pane-id"), result.stdout)
XCTAssertEqual(
state.commands.compactMap { Self.v2Payload(from: $0)?["method"] as? String },
["browser.open_split"]
)
XCTAssertFalse(FileManager.default.fileExists(atPath: diffStartedURL.path))
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
XCTAssertEqual(process.terminationStatus, 0, stderr)
XCTAssertTrue(stdout.contains("OK surface=surface-id pane=pane-id"), stdout)
XCTAssertTrue(FileManager.default.fileExists(atPath: diffStartedURL.path))
let openingURL = try XCTUnwrap(openedURLBox.get())
let htmlURL = try resolvedDiffViewerHTMLFileURL(openingHTMLURL, from: ["url": openingURL])
let openedURL = try XCTUnwrap(openedURLBox.get())
let htmlURL = try XCTUnwrap(openedHTMLURLBox.get())
XCTAssertTrue(htmlURL.lastPathComponent.hasSuffix("-viewer.html"), htmlURL.path)
XCTAssertFalse(htmlURL.lastPathComponent.hasSuffix("-opening.html"), htmlURL.path)
let html = try String(contentsOf: htmlURL, encoding: .utf8)
let patch = try String(contentsOf: htmlURL.deletingPathExtension().appendingPathExtension("patch"), encoding: .utf8)
XCTAssertFalse(html.contains("data-cmux-diff-pending=\"true\""), html)
XCTAssertTrue(html.contains("Slow diff"), html)
XCTAssertTrue(patch.contains("+new line"), patch)
let payload = try diffViewerPayload(from: html)
XCTAssertTrue(html.contains("data-cmux-diff-pending=\"true\""), html)
XCTAssertFalse(html.contains("data-cmux-diff-redirect="), html)
XCTAssertEqual(payload["pendingReplacement"] as? Bool, true)
XCTAssertEqual(payload["title"] as? String, "Slow diff")
XCTAssertEqual((payload["sessionSource"] as? [String: Any])?["kind"] as? String, "unstaged")
XCTAssertNotNil(payload["capabilityToken"] as? String)
XCTAssertFalse(openedURL.isEmpty)
XCTAssertTrue(patch.isEmpty, patch)
}
func testTopCommandSortsWorkspacesByCPUDescending() throws {
@@ -2252,11 +2210,9 @@ final class CMUXOpenCommandTests: XCTestCase {
let rawURL = try XCTUnwrap(params["url"] as? String)
XCTAssertEqual(params["bypass_remote_proxy"] as? Bool, true)
let viewerURL = try XCTUnwrap(URL(string: rawURL))
XCTAssertEqual(viewerURL.scheme, "http")
XCTAssertEqual(viewerURL.host, "127.0.0.1")
XCTAssertEqual(viewerURL.fragment, "cmux-diff-viewer")
XCTAssertNil(params["diff_viewer_token"])
XCTAssertNil(params["diff_viewer_files"])
XCTAssertEqual(viewerURL.scheme, "cmux-diff-viewer")
XCTAssertEqual(params["diff_viewer_token"] as? String, viewerURL.host)
XCTAssertNotNil(params["diff_viewer_files"] as? [[String: Any]])
let openedFileURL = try diffViewerHTMLFileURL(for: rawURL, from: params)
let viewerFileURL = try resolvedDiffViewerHTMLFileURL(openedFileURL, from: params)
if openedFileURL != viewerFileURL {
@@ -2268,35 +2224,21 @@ final class CMUXOpenCommandTests: XCTestCase {
let patch: String
if readPatchSidecar {
defer { try? FileManager.default.removeItem(at: patchURL) }
patch = try String(contentsOf: patchURL, encoding: .utf8)
let inlinePatch = try String(contentsOf: patchURL, encoding: .utf8)
let payload = try diffViewerPayload(from: html)
if inlinePatch.isEmpty,
payload["sessionSource"] is [String: Any],
payload["capabilityToken"] is String {
patch = try openTypedDiffSession(payload: payload, cliPath: cliPath)
} else {
patch = inlinePatch
}
} else {
patch = ""
}
return (html, patch, params, result.stdout)
}
private func resolvedDiffViewerHTMLFileURL(_ fileURL: URL, from params: [String: Any]) throws -> URL {
var current = fileURL
for _ in 0..<4 {
let html = try String(contentsOf: current, encoding: .utf8)
guard let redirectURL = Self.diffViewerRedirectURL(from: html) else {
return current
}
current = try diffViewerHTMLFileURL(for: redirectURL, from: params)
}
return current
}
private static func diffViewerRedirectURL(from html: String) -> String? {
let marker = "data-cmux-diff-redirect=\""
guard let start = html.range(of: marker)?.upperBound else { return nil }
let tail = html[start...]
guard let end = tail.firstIndex(of: "\"") else { return nil }
return String(tail[..<end])
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&quot;", with: "\"")
}
private func diffViewerHTMLFileURL(from params: [String: Any]) throws -> URL {
let rawURL = try XCTUnwrap(params["url"] as? String)
return try diffViewerHTMLFileURL(for: rawURL, from: params)
@@ -2331,7 +2273,7 @@ final class CMUXOpenCommandTests: XCTestCase {
return URL(fileURLWithPath: filePath, isDirectory: false)
}
private func diffViewerHTMLFileURL(for rawURL: String, from params: [String: Any]) throws -> URL {
func diffViewerHTMLFileURL(for rawURL: String, from params: [String: Any]) throws -> URL {
let viewerURL = try XCTUnwrap(URL(string: rawURL))
if viewerURL.scheme == "http" {
XCTAssertEqual(viewerURL.host, "127.0.0.1")
@@ -2577,7 +2519,7 @@ final class CMUXOpenCommandTests: XCTestCase {
)
}
private func runProcess(
func runProcess(
executablePath: String,
arguments: [String],
environment: [String: String],
+5 -7
View File
@@ -520,16 +520,14 @@ struct DockSocketLifecycleTests {
#expect(urlResult["workspace_id"] as? String == windowId.uuidString)
#expect(urlResult["surface_id"] as? String == dockSurfaceId.uuidString)
let navigateResult = try await v2ResultOnSocketWorker(
method: "browser.navigate",
params: [
await #expect(throws: (any Error).self) {
try await v2ResultOnSocketWorker(method: "browser.navigate", params: [
"workspace_id": windowId.uuidString,
"surface_id": dockSurfaceId.uuidString,
"url": "about:blank",
]
)
#expect(navigateResult["workspace_id"] as? String == windowId.uuidString)
#expect(navigateResult["surface_id"] as? String == dockSurfaceId.uuidString)
"expected_url": "https://stale.invalid",
])
}
let appDelegate = try #require(AppDelegate.shared)
let secondManager = TabManager(autoWelcomeIfNeeded: false)
+67
View File
@@ -85,6 +85,65 @@ struct ViewerNavigationTests {
#expect(!DiffCommentsBridge.isTrustedDiffViewerURL(wrongPort))
}
@Test
func sidecarBridgeRequiresRegisteredCustomSchemeViewerURL() throws {
let token = UUID().uuidString.lowercased()
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-sidecar-bridge-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let html = root.appendingPathComponent("index.html", isDirectory: false)
try "<html></html>".write(to: html, atomically: true, encoding: .utf8)
try CmuxDiffViewerURLSchemeHandler.shared.register(
token: token,
files: [.init(requestPath: "/index.html", fileURL: html, mimeType: "text/html")]
)
let customSchemeURL = try #require(URL(string: "cmux-diff-viewer://\(token)/index.html"))
let loopbackLookalikeURL = try #require(URL(string: "http://127.0.0.1:5050/\(token)/index.html"))
#expect(DiffSidecarBridge.isTrustedSidecarURL(customSchemeURL))
#expect(!DiffSidecarBridge.isTrustedSidecarURL(loopbackLookalikeURL))
}
@Test
func sidecarProcessPoolCancelsQueuedWorkWithoutLeakingPermit() async throws {
let pool = DiffSidecarProcessPool(limit: 1)
let counter = SidecarPoolTestCounter()
let firstStarted = AsyncStream<Void>.makeStream()
let releaseFirst = AsyncStream<Void>.makeStream()
var firstStartedIterator = firstStarted.stream.makeAsyncIterator()
let first = Task {
try await pool.withPermit {
await counter.increment()
firstStarted.continuation.yield()
for await _ in releaseFirst.stream {
return
}
}
}
_ = await firstStartedIterator.next()
let cancelled = Task {
try await pool.withPermit {
await counter.increment()
}
}
cancelled.cancel()
releaseFirst.continuation.yield()
releaseFirst.continuation.finish()
try await first.value
await #expect(throws: CancellationError.self) {
try await cancelled.value
}
#expect(await counter.value == 1)
try await pool.withPermit {
await counter.increment()
}
#expect(await counter.value == 2)
}
@Test
func registeredLiveHTTPViewerTrustRenewsWhileSessionRemainsActive() throws {
let liveToken = UUID().uuidString.lowercased()
@@ -356,6 +415,14 @@ struct ViewerNavigationTests {
}
}
private actor SidecarPoolTestCounter {
private(set) var value = 0
func increment() {
value += 1
}
}
private final class ViewerNavigationShellLoadDelegate: NSObject, WKNavigationDelegate {
private var continuation: CheckedContinuation<Void, Error>?
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SAMPLE_BYTES="${CMUX_DIFF_BENCH_BYTES:-16777216}"
ITERATIONS="${CMUX_DIFF_BENCH_ITERATIONS:-20}"
export CMUX_DIFF_BENCH_MAX_MANIFEST_P95_US="${CMUX_DIFF_BENCH_MAX_MANIFEST_P95_US:-1000}"
export CMUX_DIFF_BENCH_MIN_READ_MIBPS="${CMUX_DIFF_BENCH_MIN_READ_MIBPS:-500}"
export CMUX_DIFF_BENCH_MAX_STREAM_P95_MS="${CMUX_DIFF_BENCH_MAX_STREAM_P95_MS:-250}"
"${ROOT}/scripts/run-diff-sidecar-cargo.sh" run \
--quiet \
--release \
--locked \
--manifest-path "${ROOT}/Native/DiffSidecar/Cargo.toml" \
--bin cmux-diff-sidecar \
--features benchmark \
-- benchmark "$SAMPLE_BYTES" "$ITERATIONS"
(
cd "${ROOT}/webviews"
CMUX_DIFF_BENCH_ITERATIONS="${CMUX_DIFF_WEB_BENCH_ITERATIONS:-5}" \
CMUX_DIFF_BENCH_RENDER_APP="${CMUX_DIFF_BENCH_RENDER_APP:-1}" \
bun run benchmark
)
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CRATE_DIR="${ROOT}/Native/DiffSidecar"
BINARY_NAME="cmux-diff-sidecar"
BUILD_OUTPUT_DIR="${TARGET_BUILD_DIR:-${CRATE_DIR}/target/cmux-diff-sidecar}"
BUILD_WORK_DIR="${TARGET_TEMP_DIR:-${CRATE_DIR}/target/cmux-diff-sidecar-build}"
CARGO_RUNNER="${ROOT}/scripts/run-diff-sidecar-cargo.sh"
TOOLCHAIN="$(awk -F '"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' "${CRATE_DIR}/rust-toolchain.toml")"
# Xcode build phases do not inherit a login-shell PATH. Prefer rustup's
# conventional bin directory, then the standard Homebrew prefixes.
export PATH="${CARGO_HOME:-${HOME}/.cargo}/bin:/opt/homebrew/bin:/usr/local/bin:${PATH}"
if ! command -v rustup >/dev/null 2>&1; then
echo "error: rustup is required to build ${BINARY_NAME}; run ./scripts/setup.sh after installing Rust from https://rustup.rs" >&2
exit 1
fi
rust_target_for_arch() {
case "$1" in
arm64|arm64e) echo "aarch64-apple-darwin" ;;
x86_64) echo "x86_64-apple-darwin" ;;
*)
echo "error: unsupported Rust macOS arch $1" >&2
return 1
;;
esac
}
ensure_rust_target() {
local target="$1"
if ! rustup target list --toolchain "$TOOLCHAIN" --installed | grep -qx "$target"; then
rustup target add --toolchain "$TOOLCHAIN" "$target"
fi
}
requested_archs="${CMUX_DIFF_SIDECAR_ARCHS:-${ARCHS:-}}"
if [[ -z "$requested_archs" ]]; then
case "$(uname -m)" in
arm64|aarch64) requested_archs="arm64" ;;
x86_64) requested_archs="x86_64" ;;
*)
echo "error: cannot infer Rust macOS target for host arch $(uname -m)" >&2
exit 1
;;
esac
fi
mkdir -p "$BUILD_OUTPUT_DIR"
mkdir -p "$BUILD_WORK_DIR"
binaries=()
seen_targets=""
for arch in $requested_archs; do
target="$(rust_target_for_arch "$arch")"
case " $seen_targets " in
*" $target "*) continue ;;
esac
seen_targets="$seen_targets $target"
ensure_rust_target "$target"
target_dir="${BUILD_WORK_DIR}/${target}"
CARGO_TARGET_DIR="$target_dir" \
MACOSX_DEPLOYMENT_TARGET="${CMUX_DIFF_SIDECAR_MIN_MACOS:-14.0}" \
"$CARGO_RUNNER" build \
--manifest-path "${CRATE_DIR}/Cargo.toml" \
--bin "$BINARY_NAME" \
--release \
--locked \
--target "$target" \
--no-default-features
source_binary="${target_dir}/${target}/release/${BINARY_NAME}"
[[ -x "$source_binary" ]] || { echo "error: missing ${source_binary}" >&2; exit 1; }
binaries+=("$source_binary")
done
output_binary="${BUILD_OUTPUT_DIR}/${BINARY_NAME}"
if [[ "${#binaries[@]}" -eq 1 ]]; then
rsync -a "${binaries[0]}" "$output_binary"
else
lipo -create -output "$output_binary" "${binaries[@]}"
fi
chmod +x "$output_binary"
"${ROOT}/scripts/verify-diff-sidecar-artifact.sh" "$output_binary" --archs "$requested_archs"
if [[ -z "${TARGET_BUILD_DIR:-}" ]]; then
echo "built ${output_binary}"
exit 0
fi
destination_dir="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/bin"
destination="${destination_dir}/${BINARY_NAME}"
mkdir -p "$destination_dir"
rsync -a "$output_binary" "$destination"
chmod +x "$destination"
if [[ "${CODE_SIGNING_ALLOWED:-YES}" != "NO" && -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ]]; then
codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$destination" >/dev/null
fi
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CRATE="${ROOT}/Native/DiffSidecar/Cargo.toml"
OUTPUT_DIR="${ROOT}/webviews/src/diff/generated"
MODE="${1:-write}"
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-diff-types.XXXXXX")"
trap 'rm -rf "$TEMP_DIR"' EXIT
TS_RS_EXPORT_DIR="$TEMP_DIR" TS_RS_LARGE_INT=number \
"$ROOT/scripts/run-diff-sidecar-cargo.sh" run --quiet --locked --manifest-path "$CRATE" --bin generate_types
if [[ "$MODE" == "--check" ]]; then
diff -ru "$OUTPUT_DIR" "$TEMP_DIR"
exit 0
fi
if [[ "$MODE" != "write" ]]; then
echo "usage: $0 [write|--check]" >&2
exit 2
fi
mkdir -p "$OUTPUT_DIR"
rsync -a --delete "$TEMP_DIR/" "$OUTPUT_DIR/"
+8
View File
@@ -24,3 +24,11 @@ fi
rustup target add aarch64-apple-darwin x86_64-apple-darwin
cargo --version
rustc --version
if [ -f Native/DiffSidecar/rust-toolchain.toml ]; then
DIFF_RUST_TOOLCHAIN="$(awk -F '"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' Native/DiffSidecar/rust-toolchain.toml)"
rustup toolchain install "$DIFF_RUST_TOOLCHAIN" --profile minimal --component clippy,rustfmt
rustup target add --toolchain "$DIFF_RUST_TOOLCHAIN" aarch64-apple-darwin x86_64-apple-darwin
rustup run "$DIFF_RUST_TOOLCHAIN" cargo --version
rustup run "$DIFF_RUST_TOOLCHAIN" rustc --version
fi
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TOOLCHAIN_FILE="$ROOT/Native/DiffSidecar/rust-toolchain.toml"
TOOLCHAIN="$(awk -F '"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' "$TOOLCHAIN_FILE")"
if [[ -z "$TOOLCHAIN" ]]; then
echo "error: missing Rust channel in $TOOLCHAIN_FILE" >&2
exit 1
fi
if ! command -v rustup >/dev/null 2>&1; then
echo "error: rustup is required for the pinned Rust $TOOLCHAIN toolchain" >&2
exit 1
fi
exec rustup run "$TOOLCHAIN" cargo "$@"
+15
View File
@@ -16,6 +16,21 @@ if ! command -v zig &> /dev/null; then
exit 1
fi
echo "==> Checking for Rust..."
# Xcode uses a non-login shell, so verify the same PATH used by the sidecar
# build phase rather than relying on the caller's interactive shell setup.
export PATH="${CARGO_HOME:-${HOME}/.cargo}/bin:/opt/homebrew/bin:/usr/local/bin:${PATH}"
if ! command -v rustup &> /dev/null; then
echo "Error: Rust is not installed."
echo "Install via: https://rustup.rs"
exit 1
fi
DIFF_RUST_TOOLCHAIN="$(awk -F '"' '/^[[:space:]]*channel[[:space:]]*=/{print $2; exit}' Native/DiffSidecar/rust-toolchain.toml)"
rustup toolchain install "$DIFF_RUST_TOOLCHAIN" --profile minimal --component clippy,rustfmt
rustup target add --toolchain "$DIFF_RUST_TOOLCHAIN" aarch64-apple-darwin x86_64-apple-darwin
rustup run "$DIFF_RUST_TOOLCHAIN" cargo --version
rustup run "$DIFF_RUST_TOOLCHAIN" rustc --version
"$SCRIPT_DIR/ensure-ghosttykit.sh"
"$SCRIPT_DIR/install-git-hooks.sh"
+3
View File
@@ -90,6 +90,9 @@ echo "==> signing main bundle"
echo "==> verifying"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
"$SCRIPT_DIR/verify-command-palette-nucleo-ffi-artifact.sh" "$APP_PATH"
"$SCRIPT_DIR/verify-diff-sidecar-artifact.sh" \
"$APP_PATH/Contents/Resources/bin/cmux-diff-sidecar" \
--require-signed
APP_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.application-identifier" \
/dev/stdin <<<"$(plutil -convert xml1 -o - "$APP_ENTITLEMENTS")" 2>/dev/null || true)"
+1
View File
@@ -42,6 +42,7 @@ strip_if_macho() {
strip_if_macho "$APP_PATH/Contents/MacOS/cmux"
strip_if_macho "$APP_PATH/Contents/Resources/bin/cmux"
strip_if_macho "$APP_PATH/Contents/Resources/bin/cmux-diff-sidecar"
if [ -d "$APP_PATH/Contents/PlugIns" ]; then
while IFS= read -r -d '' binary; do
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "usage: $0 <cmux-diff-sidecar> [--archs \"arm64 x86_64\"] [--require-signed]" >&2
exit 2
}
[[ $# -ge 1 ]] || usage
BINARY="$1"
shift
ARCHS="arm64 x86_64"
REQUIRE_SIGNED=0
while [[ $# -gt 0 ]]; do
case "$1" in
--archs)
[[ $# -ge 2 ]] || usage
ARCHS="$2"
shift 2
;;
--require-signed)
REQUIRE_SIGNED=1
shift
;;
*) usage ;;
esac
done
[[ -f "$BINARY" ]] || { echo "error: missing diff sidecar at $BINARY" >&2; exit 1; }
[[ -x "$BINARY" ]] || { echo "error: diff sidecar is not executable: $BINARY" >&2; exit 1; }
MAX_BYTES="${CMUX_DIFF_SIDECAR_MAX_BYTES:-5242880}"
SIZE_BYTES="$(stat -f %z "$BINARY")"
if (( SIZE_BYTES > MAX_BYTES )); then
echo "error: diff sidecar is ${SIZE_BYTES} bytes; limit is ${MAX_BYTES} bytes" >&2
exit 1
fi
for arch in $ARCHS; do
lipo "$BINARY" -verify_arch "$arch"
MINOS="$(otool -arch "$arch" -l "$BINARY" | awk '/LC_BUILD_VERSION/{found=1; next} found && /minos / && !printed {print $2; printed=1}')"
if [[ "$MINOS" != "14.0" ]]; then
echo "error: $arch diff sidecar has macOS minimum $MINOS, expected 14.0" >&2
exit 1
fi
while IFS= read -r dependency; do
case "$dependency" in
/usr/lib/*|/System/Library/*) ;;
*) echo "error: unexpected $arch dependency: $dependency" >&2; exit 1 ;;
esac
done < <(otool -arch "$arch" -L "$BINARY" | tail -n +2 | awk '{print $1}')
LOCAL_SYMBOLS="$(nm -arch "$arch" -a "$BINARY" 2>/dev/null | awk '$2 ~ /^[NnSsTt]$/ && $3 != "__mh_execute_header" {count++} END {print count+0}')"
if (( LOCAL_SYMBOLS != 0 )); then
echo "error: $arch diff sidecar retains ${LOCAL_SYMBOLS} local or debug symbols" >&2
exit 1
fi
done
DEBUG_INFO="$(dwarfdump --debug-info "$BINARY" 2>&1)"
if [[ "$DEBUG_INFO" == *"DW_TAG_"* ]]; then
echo "error: diff sidecar retains embedded DWARF debug information" >&2
exit 1
fi
if codesign --verify --strict "$BINARY" >/dev/null 2>&1; then
SIGNING_STATE="signed"
elif (( REQUIRE_SIGNED )); then
echo "error: diff sidecar is not validly signed" >&2
exit 1
else
SIGNING_STATE="unsigned"
fi
HANDSHAKE="$("$BINARY" handshake)"
python3 - "$HANDSHAKE" <<'PY'
import json
import sys
payload = json.loads(sys.argv[1])
assert payload["id"] == "handshake", payload
assert payload["version"] == 1, payload
assert payload["error"] is None, payload
assert payload["result"]["type"] == "handshake", payload
capabilities = payload["result"]["value"]["capabilities"]
assert "transport.webkit" in capabilities, payload
assert "transport.stdio" in capabilities, payload
assert "transport.fetch" not in capabilities, payload
assert "transport.websocket" not in capabilities, payload
PY
echo "diff sidecar: ${SIZE_BYTES} bytes, architectures: ${ARCHS}, minimum macOS: 14.0, ${SIGNING_STATE}"
+3
View File
@@ -252,6 +252,7 @@ def linux_preflight_needs(
"remote-daemon-tests": "success",
"web-typecheck": "success",
"react-apps-check": "success",
"diff-sidecar-check": "success",
"web-db-migrations": "success",
"agent-session-web-resources": "success",
}
@@ -565,6 +566,7 @@ def test_ci_status_job_accepts_skipped_routed_jobs() -> None:
"remote-daemon-tests",
"web-typecheck",
"react-apps-check",
"diff-sidecar-check",
"web-db-migrations",
"linux-preflight",
"app-host-unit-tests",
@@ -627,6 +629,7 @@ def test_linux_preflight_blocks_macos_on_cheap_layer_failure() -> None:
assert " - remote-daemon-tests" in block
assert " - web-typecheck" in block
assert " - react-apps-check" in block
assert " - diff-sidecar-check" in block
assert " - web-db-migrations" in block
assert " - agent-session-web-resources" in block
assert "if: ${{ always() }}" in block
+9 -8
View File
@@ -16,6 +16,7 @@ mkdir -p \
for path in \
"$APP/Contents/MacOS/cmux" \
"$APP/Contents/Resources/bin/cmux" \
"$APP/Contents/Resources/bin/cmux-diff-sidecar" \
"$APP/Contents/Resources/bin/ghostty" \
"$APP/Contents/PlugIns/CmuxDockTilePlugin.plugin/Contents/MacOS/CmuxDockTilePlugin" \
"$APP/Contents/Frameworks/libcmux_command_palette_nucleo_ffi.dylib" \
@@ -29,7 +30,7 @@ done
cat > "$TMP_DIR/tools/file" <<'EOF'
#!/usr/bin/env bash
case "$1" in
*"/Contents/MacOS/cmux"|*"/Contents/Resources/bin/cmux"|*"CmuxDockTilePlugin"|*"libcmux_"*)
*"/Contents/MacOS/cmux"|*"/Contents/Resources/bin/cmux"|*"/Contents/Resources/bin/cmux-diff-sidecar"|*"CmuxDockTilePlugin"|*"libcmux_"*)
printf '%s: Mach-O universal binary\n' "$1"
;;
*)
@@ -51,15 +52,15 @@ CMUX_STRIP_TOOL="$TMP_DIR/tools/strip" \
"$ROOT/scripts/strip-release-bundle.sh" "$APP"
expected="$TMP_DIR/expected.log"
cat > "$expected" <<EOF
-S -x $APP/Contents/MacOS/cmux
-S -x $APP/Contents/Resources/bin/cmux
-S -x $APP/Contents/PlugIns/CmuxDockTilePlugin.plugin/Contents/MacOS/CmuxDockTilePlugin
-S -x $APP/Contents/Frameworks/libcmux_command_palette_nucleo_ffi.dylib
EOF
printf '%s\n' \
"-S -x $APP/Contents/MacOS/cmux" \
"-S -x $APP/Contents/Resources/bin/cmux" \
"-S -x $APP/Contents/Resources/bin/cmux-diff-sidecar" \
"-S -x $APP/Contents/PlugIns/CmuxDockTilePlugin.plugin/Contents/MacOS/CmuxDockTilePlugin" \
"-S -x $APP/Contents/Frameworks/libcmux_command_palette_nucleo_ffi.dylib" \
> "$expected"
if ! diff -u "$expected" "$TMP_DIR/strip.log"; then
echo "strip-release-bundle.sh stripped the wrong files" >&2
exit 1
fi
+125
View File
@@ -0,0 +1,125 @@
export function makeMixedPatch(count: number): string {
let result = "";
for (let index = 0; index < count; index += 1) {
const path = `src/generated/group-${index % 100}/file-${index}.ts`;
switch (index % 7) {
case 0:
result += modifiedPatch(path, index);
break;
case 1:
result += addedPatch(path, index);
break;
case 2:
result += deletedPatch(path, index);
break;
case 3:
result += pureRenamePatch(path, index);
break;
case 4:
result += changedRenamePatch(path, index);
break;
case 5:
result += modePatch(path);
break;
default:
result += binaryPatch(path);
break;
}
}
return result;
}
function modifiedPatch(path: string, index: number): string {
return lines([
`diff --git a/${path} b/${path}`,
"index 1111111..2222222 100644",
`--- a/${path}`,
`+++ b/${path}`,
"@@ -1,3 +1,3 @@",
` export const id = ${index};`,
"-export const state = \"old\";",
"+export const state = \"new\";",
" export const enabled = true;",
"@@ -20,2 +20,3 @@ export function tail() {",
" return true;",
`+ // ${"x".repeat(index % 31)}`,
" }",
]);
}
function addedPatch(path: string, index: number): string {
return lines([
`diff --git a/${path} b/${path}`,
"new file mode 100644",
"index 0000000..2222222",
"--- /dev/null",
`+++ b/${path}`,
"@@ -0,0 +1,2 @@",
`+export const id = ${index};`,
"+export const added = true;",
]);
}
function deletedPatch(path: string, index: number): string {
return lines([
`diff --git a/${path} b/${path}`,
"deleted file mode 100644",
"index 1111111..0000000",
`--- a/${path}`,
"+++ /dev/null",
"@@ -1,2 +0,0 @@",
`-export const id = ${index};`,
"-export const removed = true;",
]);
}
function pureRenamePatch(path: string, index: number): string {
const previous = `src/generated/old/file-${index}.ts`;
return lines([
`diff --git a/${previous} b/${path}`,
"similarity index 100%",
`rename from ${previous}`,
`rename to ${path}`,
]);
}
function changedRenamePatch(path: string, index: number): string {
const previous = `src/generated/old/file-${index}.ts`;
return lines([
`diff --git a/${previous} b/${path}`,
"similarity index 80%",
`rename from ${previous}`,
`rename to ${path}`,
"index 1111111..2222222 100644",
`--- a/${previous}`,
`+++ b/${path}`,
"@@ -1 +1,2 @@",
` export const id = ${index};`,
"+export const renamed = true;",
]);
}
function modePatch(path: string): string {
return lines([
`diff --git a/${path} b/${path}`,
"old mode 100644",
"new mode 100755",
]);
}
function binaryPatch(path: string): string {
return lines([
`diff --git a/${path} b/${path}`,
"index 1111111111111111111111111111111111111111..2222222222222222222222222222222222222222 100644",
"GIT binary patch",
"literal 5",
"McmZQz<YZ<6001rk5&!@I",
"",
"literal 4",
"LcmZQzWMT#Y01f~L",
]);
}
function lines(value: string[]): string {
return `${value.join("\n")}\n`;
}
+153
View File
@@ -0,0 +1,153 @@
import { parsePatchFiles, processFile } from "@pierre/diffs";
import { resolve } from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { JumpSelect } from "../src/App";
import { createDiffViewerLabelResolver } from "../src/labels";
import { streamPatch, type DiffItem, type StreamMetrics } from "../src/diff-stream";
import { makeMixedPatch } from "./diff-fixture";
const fileCount = Number(process.env.CMUX_DIFF_BENCH_FILES ?? 2000);
const iterations = Number(process.env.CMUX_DIFF_BENCH_ITERATIONS ?? 5);
if (!Number.isSafeInteger(fileCount) || fileCount <= 0) {
throw new Error("CMUX_DIFF_BENCH_FILES must be a positive integer");
}
if (!Number.isSafeInteger(iterations) || iterations <= 0) {
throw new Error("CMUX_DIFF_BENCH_ITERATIONS must be a positive integer");
}
const patch = makeMixedPatch(fileCount);
const includeAppRender = process.env.CMUX_DIFF_BENCH_RENDER_APP === "1";
const patchOutputPath = process.env.CMUX_DIFF_BENCH_PATCH_OUTPUT == null
? undefined
: resolve(process.env.CMUX_DIFF_BENCH_PATCH_OUTPUT);
if (patchOutputPath != null) {
await Bun.write(patchOutputPath, patch);
}
const originalFetch = globalThis.fetch;
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
Object.assign(globalThis, {
document: { visibilityState: "hidden", hasFocus: () => false },
window: globalThis,
fetch: async () => new Response(patch, {
status: 200,
headers: { "Content-Type": "text/x-diff" },
}),
});
const samples: number[] = [];
let lastMetrics: StreamMetrics | null = null;
let lastAppMetrics: ReturnType<typeof createAppRenderMetrics> | null = null;
for (let index = 0; index < iterations; index += 1) {
const appMetrics = createAppRenderMetrics();
const started = performance.now();
await streamPatch({
getCollapsed: () => false,
initialFileTreeRowCount: 32,
label: createDiffViewerLabelResolver(undefined),
onBatch: (batch) => {
if (includeAppRender) {
appMetrics.render(batch);
}
},
onComplete: (metrics) => {
lastMetrics = metrics;
},
onMetrics: () => {},
onRename: () => {},
onTreeSource: () => {},
parsePatchFiles,
patchURL: "benchmark.patch",
processFile,
});
if (includeAppRender && appMetrics.itemCount !== fileCount) {
throw new Error(`app render received ${appMetrics.itemCount} files, expected ${fileCount}`);
}
if (includeAppRender && appMetrics.maxJumpOptionCount > 501) {
throw new Error(`app render created ${appMetrics.maxJumpOptionCount} jump options, expected at most 501`);
}
lastAppMetrics = appMetrics;
samples.push(performance.now() - started);
}
globalThis.fetch = originalFetch;
globalThis.document = originalDocument;
globalThis.window = originalWindow;
samples.sort((left, right) => left - right);
const medianMs = percentile(samples, 50);
const p95Ms = percentile(samples, 95);
const report = {
patchBytes: new TextEncoder().encode(patch).byteLength,
fileCount,
iterations,
medianMs: Number(medianMs.toFixed(2)),
p95Ms: Number(p95Ms.toFixed(2)),
filesPerSecond: Math.round(fileCount / (medianMs / 1000)),
firstBatchFileCount: lastMetrics?.firstBatchFileCount ?? 0,
firstBatchMs: lastMetrics?.firstBatchAt == null
? null
: Number((lastMetrics.firstBatchAt - lastMetrics.startedAt).toFixed(2)),
flushCount: lastMetrics?.flushCount ?? 0,
longYieldCount: lastMetrics?.longYieldCount ?? 0,
maxBatchSize: lastMetrics?.maxBatchSize ?? 0,
maxYieldMs: Number((lastMetrics?.maxYieldMs ?? 0).toFixed(2)),
appRenderCount: includeAppRender ? lastAppMetrics?.renderCount ?? 0 : undefined,
appRenderMs: includeAppRender ? Number((lastAppMetrics?.renderMs ?? 0).toFixed(2)) : undefined,
appRenderedItemCount: includeAppRender ? lastAppMetrics?.itemCount ?? 0 : undefined,
maxJumpOptionCount: includeAppRender ? lastAppMetrics?.maxJumpOptionCount ?? 0 : undefined,
patchOutputPath,
yieldCount: lastMetrics?.yieldCount ?? 0,
};
const maxP95Ms = Number(process.env.CMUX_DIFF_BENCH_MAX_STREAM_P95_MS ?? Number.POSITIVE_INFINITY);
if (!Number.isFinite(maxP95Ms) && maxP95Ms !== Number.POSITIVE_INFINITY) {
throw new Error("CMUX_DIFF_BENCH_MAX_STREAM_P95_MS must be a number");
}
if (p95Ms > maxP95Ms) {
throw new Error(`diff stream p95 was ${p95Ms.toFixed(2)} ms, budget is ${maxP95Ms.toFixed(2)} ms`);
}
await Bun.write(Bun.stdout, `${JSON.stringify(report, null, 2)}\n`);
process.exit(0);
function percentile(values: number[], target: number): number {
const rank = Math.ceil((values.length * target) / 100);
return values[Math.max(0, Math.min(values.length - 1, rank - 1))] ?? 0;
}
function createAppRenderMetrics() {
let items: DiffItem[] = [];
let maxJumpOptionCount = 0;
let renderCount = 0;
let renderMs = 0;
const label = createDiffViewerLabelResolver(undefined);
return {
get itemCount() {
return items.length;
},
get maxJumpOptionCount() {
return maxJumpOptionCount;
},
get renderCount() {
return renderCount;
},
get renderMs() {
return renderMs;
},
render(batch: DiffItem[]) {
items = [...items, ...batch];
const startedAt = performance.now();
const markup = renderToStaticMarkup(createElement(JumpSelect, {
items,
label,
onJump: () => {},
onOpenSearch: () => {},
searchOpen: false,
selectedItemId: items[0]?.id ?? "",
}));
renderMs += performance.now() - startedAt;
renderCount += 1;
maxJumpOptionCount = Math.max(maxJumpOptionCount, markup.match(/<option(?:\s|>)/g)?.length ?? 0);
},
};
}
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"build": "bun run verify:tanstack-router && bun run typecheck && vite build",
"benchmark": "bun run bench/diff-stream.bench.ts",
"lint": "oxlint . --react-plugin --jsx-a11y-plugin --import-plugin",
"lint:ci": "oxlint . --react-plugin --jsx-a11y-plugin --import-plugin --deny-warnings",
"lint:fix": "oxlint . --react-plugin --jsx-a11y-plugin --import-plugin --fix",
+549 -88
View File
@@ -3,11 +3,11 @@ import { getFiletypeFromFileName, parsePatchFiles, preloadHighlighter, processFi
import type { SelectedLineRange } from "@pierre/diffs";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { preparePresortedFileTreeInput } from "@pierre/trees";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import "../../Resources/markdown-viewer/viewer-navigation.js";
import { copyGitApplyCommand, resolveDiffNavigationURL } from "./actions";
import { resolveDiffViewerAppearance } from "./appearance";
import { BranchBasePicker, type BranchPickerPayload } from "./BranchBasePicker";
import { BranchBasePicker, branchPickerStateKey, type BranchPickerPayload } from "./BranchBasePicker";
import { lineTextFor, type CommentFileDiff } from "./comments/anchor";
import {
applyCommentAnnotations,
@@ -34,6 +34,7 @@ import type {
import { useCommentsBootstrap } from "./comments/useCommentsBootstrap";
import { resolveDiffFileLanguage, resolveDiffPreloadLanguages } from "./diff-language";
import { fileName, type DiffItem, type FileTreeSource, type StreamMetrics, streamPatch } from "./diff-stream";
import { DiffHeaderMetadata } from "./diff-metadata";
import { applyPierreFileTreeGitStatus, planPierreFileTreeRefresh, selectPierreFileTreePath } from "./file-tree-refresh";
import { Icon, type IconName } from "./icons";
import { createDiffViewerLabelResolver, shouldAssertMissingLabels } from "./labels";
@@ -50,6 +51,8 @@ import { useToolbarWidth } from "./useToolbarWidth";
import type { DiffViewerLabelResolver } from "./labels";
import type { DiffViewerStatus } from "./status";
import type { DiffViewerConfig } from "./types";
import { createDiffTransport, DiffTransportError, type DiffTransport } from "./diff/transport";
import type { DiffSource, DiffTransportConfig } from "./diff/generated/protocol";
import { createDiffWorkerPoolOptions } from "./worker-pool";
type ConfigProps = {
@@ -57,6 +60,14 @@ type ConfigProps = {
initialStatus: DiffViewerStatus;
};
type ActiveDiffSession = {
capabilityToken: string;
sessionId: string;
};
const registeredCustomThemeNames = new Set<string>();
const pendingSessionID = "00000000-0000-0000-0000-000000000000";
type AppState = {
activeItemId: string;
activeTreePath: string;
@@ -78,10 +89,11 @@ type AppState = {
type AppAction =
| { type: "append-items"; items: DiffItem[] }
| { type: "reset-diff"; status: DiffViewerStatus }
| { type: "remove-comment"; id: string }
| { type: "rename-item"; oldId: string; newId: string }
| { type: "set-active-item"; itemId: string; treePath?: string }
| { type: "set-comments"; comments: DiffCommentRecord[] }
| { type: "replace-comments"; comments: DiffCommentRecord[] }
| { type: "set-copy-feedback"; message: string }
| { type: "set-draft"; draft: CommentDraft | null }
| { type: "set-file-search-open"; open: boolean }
@@ -149,6 +161,18 @@ function reducer(state: AppState, action: AppAction): AppState {
status: state.status.loading ? createDiffViewerStatus("", { loading: false }) : state.status,
};
}
case "reset-diff":
return {
...state,
activeItemId: "",
activeTreePath: "",
draft: null,
items: [],
languages: ["text"],
metrics: null,
status: action.status,
treeSource: null,
};
case "remove-comment": {
const comments = state.comments.filter((comment) => comment.id !== action.id);
return {
@@ -176,11 +200,12 @@ function reducer(state: AppState, action: AppAction): AppState {
activeItemId: action.itemId,
activeTreePath: action.treePath ?? state.activeTreePath,
};
case "set-comments":
case "replace-comments":
return {
...state,
comments: action.comments,
items: applyCommentAnnotations(state.items, action.comments, state.draft),
draft: null,
items: applyCommentAnnotations(state.items, action.comments, null),
};
case "set-copy-feedback":
return { ...state, copyFeedback: action.message };
@@ -242,36 +267,95 @@ function reducer(state: AppState, action: AppAction): AppState {
export function App({ config, initialStatus }: ConfigProps) {
const payload = config.payload ?? {};
const label = createDiffViewerLabelResolver(payload.labels, {
assertMissing: shouldAssertMissingLabels(),
});
const label = useMemo(
() => createDiffViewerLabelResolver(payload.labels, {
assertMissing: shouldAssertMissingLabels(),
}),
[payload.labels],
);
const appearance = resolveDiffViewerAppearance(payload.appearance);
const transport = useDiffTransport(payload.transport);
const [activeSessionSource, setActiveSessionSource] = useState<DiffSource | null>(
validDiffSource(payload.sessionSource) ? payload.sessionSource : null,
);
const [resolvedSessionSource, setResolvedSessionSource] = useState<DiffSource | null>(activeSessionSource);
const branchSourceByRepoRef = useRef(new Map<string, Extract<DiffSource, { kind: "branch" }>>());
if (activeSessionSource?.kind === "branch" && !branchSourceByRepoRef.current.has(activeSessionSource.repoRoot)) {
branchSourceByRepoRef.current.set(activeSessionSource.repoRoot, activeSessionSource);
}
const [activePatchURL, setActivePatchURL] = useState<string | undefined>(payload.patchURL);
const [state, dispatch] = useReducer(reducer, initialAppState(config, initialStatus));
const latestState = useSyncedRef(state);
const codeViewRef = useRef<CodeViewHandle<any> | null>(null);
const codeViewScrollTopRef = useRef(0);
const copyFallbackRef = useRef<HTMLTextAreaElement | null>(null);
const activeSessionRef = useRef<ActiveDiffSession | null>(null);
const viewerContainerRef = useRef<HTMLDivElement | null>(null);
const workerModuleURL = resolveDiffViewerAssetURL(config.assets?.workerModuleURL);
const workerPoolOptions = createDiffWorkerPoolOptions(workerModuleURL);
const highlighterOptions = workerHighlighterOptions(state.options, appearance, state.languages);
const repoRoot = typeof payload.repoRoot === "string" && payload.repoRoot !== "" ? payload.repoRoot : null;
const bridgeAvailable = diffCommentsBridgeAvailable() && repoRoot != null;
const payloadRepoRoot = typeof payload.repoRoot === "string" && payload.repoRoot !== "" ? payload.repoRoot : null;
const commentRepoRoot = diffSourceRepoRoot(resolvedSessionSource ?? activeSessionSource) ?? payloadRepoRoot;
const bridgeAvailable = diffCommentsBridgeAvailable() && commentRepoRoot != null;
const commentLabels = resolveCommentLabels(payload);
const comments = useDiffComments({
bridgeAvailable,
dispatch,
latestState,
repoRoot,
repoRoot: commentRepoRoot,
});
const renderedCodeViewOptions = codeViewOptions(state.options, appearance);
renderedCodeViewOptions.onGutterUtilityClick = comments.onGutterUtilityClick as any;
const closeActiveSession = useCallback(() => {
const activeSession = activeSessionRef.current;
if (!transport) {
return Promise.resolve();
}
if (!activeSession) {
if (typeof payload.capabilityToken !== "string") {
return Promise.resolve();
}
return closeDiffSession(transport, {
sessionId: pendingSessionID,
capabilityToken: payload.capabilityToken,
});
}
activeSessionRef.current = null;
return transport.request({
method: "sessionClose",
params: activeSession,
})
.then(() => {})
.catch(() => {
if (!activeSessionRef.current) {
activeSessionRef.current = activeSession;
}
});
}, [payload.capabilityToken, transport]);
const rememberResolvedSessionSource = useCallback((source: DiffSource) => {
if (source.kind === "branch") {
branchSourceByRepoRef.current.set(source.repoRoot, source);
}
setResolvedSessionSource(source);
}, []);
usePageDataAttributes(state);
usePendingReplacement(payload, label, dispatch);
useRenderDiff(config, label, dispatch, latestState);
useCommentsBootstrap(bridgeAvailable ? repoRoot : null, comments.onLoaded);
usePendingReplacement(payload, label, dispatch, transport);
useRenderDiff(
config,
transport,
label,
dispatch,
latestState,
setActivePatchURL,
activeSessionRef,
closeActiveSession,
activeSessionSource,
rememberResolvedSessionSource,
);
useCommentsBootstrap(bridgeAvailable ? commentRepoRoot : null, comments.onLoaded);
useOptionsDismiss(state.optionsOpen, dispatch);
useFileSearchDismiss(state.fileSearchOpen, dispatch);
const renderCommentAnnotation = (annotation: CommentAnnotation, item: DiffItem) => {
const metadata = annotation.metadata;
@@ -359,13 +443,14 @@ export function App({ config, initialStatus }: ConfigProps) {
};
return (
<div id="app">
<div id="app" data-file-search-open={state.fileSearchOpen}>
<Toolbar
config={config}
transport={transport}
label={label}
onCopyGitApply={async () => {
try {
const message = await copyGitApplyCommand(payload.patchURL, label, copyFallbackRef.current);
const message = await copyGitApplyCommand(activePatchURL, label, copyFallbackRef.current);
dispatch({ type: "set-copy-feedback", message });
} catch {
dispatch({ type: "set-copy-feedback", message: label("copyFailedGitApplyCommand") });
@@ -374,14 +459,44 @@ export function App({ config, initialStatus }: ConfigProps) {
onJump={scrollToItem}
onNavigate={(url) => {
setStatus(createDiffViewerStatus(label("loadingDiff"), { pending: true }));
// Session cleanup is best-effort and can wait on WebKit's reply path.
// Do not make source/repository/base selection wait for it: navigation
// starts a new typed session and must stay responsive.
void closeActiveSession();
window.location.href = resolveDiffNavigationURL(url);
}}
onReload={() => window.location.reload()}
activeSessionSource={resolvedSessionSource ?? activeSessionSource}
onSelectSessionSource={(source) => {
const currentSource = resolvedSessionSource ?? activeSessionSource;
const selectedSource = source.kind === "branch"
&& (currentSource?.kind !== "branch" || source.baseRef == null)
? branchSourceByRepoRef.current.get(source.repoRoot) ?? source
: source;
if (selectedSource.kind === "branch") {
branchSourceByRepoRef.current.set(selectedSource.repoRoot, selectedSource);
}
const status = createDiffViewerStatus(label("loadingDiff"), { pending: true });
applyDiffViewerStatusToDocument(status);
dispatch({ type: "reset-diff", status });
setActivePatchURL(undefined);
void closeActiveSession();
setResolvedSessionSource(selectedSource);
setActiveSessionSource(selectedSource);
}}
onReload={async () => {
await closeActiveSession();
window.location.reload();
}}
onSetLayout={setLayout}
dispatch={dispatch}
state={state}
/>
<section id="content" style={{ "--cmux-diff-files-width": `${state.filesWidth}px` } as React.CSSProperties}>
<FilesSidebarBackdrop
label={label}
onClose={() => closeFileSearch(dispatch)}
open={state.fileSearchOpen}
/>
<FilesSidebar
commentEntries={commentEntries}
commentLabels={commentLabels}
@@ -407,6 +522,9 @@ export function App({ config, initialStatus }: ConfigProps) {
items={state.items}
onScroll={handleCodeViewScroll}
options={renderedCodeViewOptions}
renderHeaderMetadata={(item) => (
<DiffHeaderMetadata fileDiff={(item as DiffItem).fileDiff} label={label} />
)}
renderAnnotation={(annotation, item) =>
renderCommentAnnotation(annotation as CommentAnnotation, item as DiffItem)}
/>
@@ -426,6 +544,30 @@ export function App({ config, initialStatus }: ConfigProps) {
);
}
export function FilesSidebarBackdrop({
label,
onClose,
open,
}: {
label: DiffViewerLabelResolver;
onClose: () => void;
open: boolean;
}) {
if (!open) {
return null;
}
return (
<button
id="files-sidebar-backdrop"
type="button"
aria-controls="files-sidebar"
aria-label={label("hideFileSearch")}
title={label("hideFileSearch")}
onClick={onClose}
/>
);
}
function resolveDiffViewerAssetURL(rawURL: string | undefined): URL {
return new URL(rawURL || defaultWorkerModuleURL, window.location.href);
}
@@ -447,8 +589,9 @@ function useDiffComments({
latestState: React.MutableRefObject<AppState>;
repoRoot: string | null;
}) {
const activeRepoRoot = useSyncedRef(repoRoot);
const onLoaded = useCallback(
(comments: DiffCommentRecord[]) => dispatch({ type: "set-comments", comments }),
(comments: DiffCommentRecord[]) => dispatch({ type: "replace-comments", comments }),
[dispatch],
);
@@ -484,6 +627,9 @@ function useDiffComments({
: Promise.resolve(localCommentRecord(record));
save
.then((saved) => {
if (activeRepoRoot.current !== repoRoot) {
return;
}
dispatch({ type: "upsert-comment", comment: saved });
dispatch({ type: "set-draft", draft: null });
})
@@ -504,16 +650,23 @@ function useDiffComments({
? bridgeSaveComment(repoRoot, updated)
: Promise.resolve(updated);
save
.then((saved) => dispatch({ type: "upsert-comment", comment: saved }))
.then((saved) => {
if (activeRepoRoot.current === repoRoot) {
dispatch({ type: "upsert-comment", comment: saved });
}
})
.catch((error) => console.warn("cmux diff comment edit failed", error));
};
const remove = (comment: DiffCommentRecord) => {
const targetRepoRoot = repoRoot;
if (bridgeAvailable && repoRoot != null) {
bridgeDeleteComment(repoRoot, comment.id)
.catch((error) => console.warn("cmux diff comment delete failed", error));
}
dispatch({ type: "remove-comment", id: comment.id });
if (activeRepoRoot.current === targetRepoRoot) {
dispatch({ type: "remove-comment", id: comment.id });
}
};
return { editMessage, onGutterUtilityClick, onLoaded, remove, saveDraft };
@@ -566,25 +719,31 @@ function WorkerRenderOptionsSync({
}
function Toolbar({
activeSessionSource,
config,
dispatch,
label,
onCopyGitApply,
onJump,
onNavigate,
onSelectSessionSource,
onReload,
onSetLayout,
state,
transport,
}: {
activeSessionSource: DiffSource | null;
config: DiffViewerConfig;
dispatch: React.Dispatch<AppAction>;
label: DiffViewerLabelResolver;
onCopyGitApply: () => void;
onJump: (itemId: string) => void;
onNavigate: (url: string) => void;
onSelectSessionSource: (source: DiffSource) => void;
onReload: () => void;
onSetLayout: (layout: DiffViewerLayout) => void;
state: AppState;
transport: DiffTransport | null;
}) {
const payload = config.payload ?? {};
const externalURL =
@@ -624,15 +783,26 @@ function Toolbar({
const showExternalLink = externalURL != null && !overflow.has("external-link");
return (
<header id="toolbar" ref={toolbarRef}>
<SourceControls label={label} onNavigate={onNavigate} payload={payload} />
{/* The jump-to-file select duplicates the Files sidebar (both scroll to a
file). It is the only file-jump control when the sidebar is hidden, so
it always renders, but its centered middle grid track is collapsed via
CSS whenever the sidebar is actually visible (sidebar shown AND viewport
wide enough that the sidebar is not media-auto-hidden), letting
toolbar-left reclaim the space. */}
<SourceControls
activeSessionSource={activeSessionSource}
label={label}
onNavigate={onNavigate}
onSelectSessionSource={onSelectSessionSource}
payload={payload}
transport={transport}
/>
{/* Small diffs use a native jump select. Large diffs route this control to
the virtualized file-tree search so the toolbar never creates one DOM
option per file. */}
<div className="toolbar-middle flex min-w-0 flex-1 items-center justify-center gap-1.5">
<JumpSelect items={state.items} label={label} onJump={onJump} selectedItemId={state.activeItemId} />
<JumpSelect
items={state.items}
label={label}
onJump={onJump}
onOpenSearch={() => dispatch({ type: "set-file-search-open", open: true })}
searchOpen={state.fileSearchOpen}
selectedItemId={state.activeItemId}
/>
</div>
<div className="toolbar-actions flex items-center gap-1.5">
{showExternalLink ? (
@@ -723,13 +893,19 @@ function hasRepoSelect(payload: any): boolean {
}
function SourceControls({
activeSessionSource,
label,
onNavigate,
onSelectSessionSource,
payload,
transport,
}: {
activeSessionSource: DiffSource | null;
label: DiffViewerLabelResolver;
onNavigate: (url: string) => void;
onSelectSessionSource: (source: DiffSource) => void;
payload: any;
transport: DiffTransport | null;
}) {
return (
<div className="toolbar-left flex min-w-0 items-center gap-1.5">
@@ -739,18 +915,35 @@ function SourceControls({
id="source-select"
options={payload.sourceOptions}
onNavigate={onNavigate}
onSelectSessionSource={(source) => onSelectSessionSource(
sourceSelectionWithActiveRepo(source, activeSessionSource),
)}
selectedValue={diffSourceKind(activeSessionSource)}
/>
{/* The repo select is ALWAYS rendered (a native <select> has no "..." menu
equivalent, so dropping it would strand multi-repo users). It shrinks
and ellipsizes in place via field-sizing + the .toolbar-left clip. */}
<NavigationSelect
ariaLabel={label("repoPath")}
fallbackValue={payload.repoRoot ?? ""}
id="repo-select"
options={payload.repoOptions}
{activeSessionSource?.kind !== "patch" ? (
<NavigationSelect
ariaLabel={label("repoPath")}
fallbackValue={payload.repoRoot ?? ""}
id="repo-select"
options={payload.repoOptions}
onNavigate={onNavigate}
onSelectSessionSource={(source) => onSelectSessionSource(
repoSelectionWithActiveSource(source, activeSessionSource),
)}
selectedValue={diffSourceRepoRoot(activeSessionSource)}
/>
) : null}
<BaseControl
activeSessionSource={activeSessionSource}
label={label}
onNavigate={onNavigate}
onSelectSessionSource={onSelectSessionSource}
payload={payload}
transport={transport}
/>
<BaseControl label={label} onNavigate={onNavigate} payload={payload} />
</div>
);
}
@@ -761,17 +954,58 @@ function SourceControls({
* `<select>` for older backends that only send `payload.baseOptions`.
*/
function BaseControl({
activeSessionSource,
label,
onNavigate,
onSelectSessionSource,
payload,
transport,
}: {
activeSessionSource: DiffSource | null;
label: DiffViewerLabelResolver;
onNavigate: (url: string) => void;
onSelectSessionSource: (source: DiffSource) => void;
payload: any;
transport: DiffTransport | null;
}) {
if (activeSessionSource?.kind === "branch" && transport) {
const typedPicker: BranchPickerPayload = {
repoRoot: activeSessionSource.repoRoot,
capabilityToken: payload.capabilityToken,
headRef: "HEAD",
currentRef: activeSessionSource.baseRef ?? "",
currentReason: "",
confidence: "high",
aheadBehind: null,
refsURL: "typed://branch-list",
regenerateURLTemplate: "typed://branch-change/{ref}",
};
return (
<BranchBasePicker
key={branchPickerStateKey(typedPicker)}
label={label}
onNavigate={onNavigate}
onSelectBranchBase={(baseRef) => onSelectSessionSource({
kind: "branch",
repoRoot: activeSessionSource.repoRoot,
baseRef,
})}
picker={typedPicker}
transport={transport}
/>
);
}
const picker = resolveBranchPicker(payload);
if (picker) {
return <BranchBasePicker label={label} onNavigate={onNavigate} picker={picker} />;
return (
<BranchBasePicker
key={branchPickerStateKey(picker)}
label={label}
onNavigate={onNavigate}
picker={picker}
transport={transport}
/>
);
}
return (
<NavigationSelect
@@ -860,26 +1094,36 @@ function NavigationSelect({
fallbackValue,
id,
onNavigate,
onSelectSessionSource,
options,
selectedValue,
}: {
ariaLabel: string;
fallbackValue: string;
id: string;
onNavigate: (url: string) => void;
onSelectSessionSource?: (source: DiffSource) => void;
options: any[] | undefined;
selectedValue?: string | null;
}) {
if (!Array.isArray(options) || options.length < 2) {
return null;
}
const selected = options.find((option) => option.selected) ?? options.find((option) => !option.disabled);
const selected = options.find((option) => option.value === selectedValue)
?? options.find((option) => option.selected)
?? options.find((option) => !option.disabled);
return (
<select
id={id}
aria-label={ariaLabel}
defaultValue={selected?.value ?? fallbackValue}
value={selected?.value ?? fallbackValue}
title={ariaLabel}
onChange={(event) => {
const next = options.find((option) => option.value === event.currentTarget.value);
if (validDiffSource(next?.sessionSource) && onSelectSessionSource) {
onSelectSessionSource(next.sessionSource);
return;
}
if (!next?.url) {
event.currentTarget.value = selected?.value ?? fallbackValue;
return;
@@ -891,7 +1135,7 @@ function NavigationSelect({
<option
key={option.value}
value={option.value}
disabled={option.disabled || !option.url}
disabled={option.disabled || (!option.url && !validDiffSource(option.sessionSource))}
title={option.message}
>
{option.label}
@@ -901,20 +1145,39 @@ function NavigationSelect({
);
}
function JumpSelect({
export function JumpSelect({
items,
label,
onJump,
onOpenSearch,
searchOpen,
selectedItemId,
}: {
items: DiffItem[];
label: DiffViewerLabelResolver;
onJump: (itemId: string) => void;
onOpenSearch: () => void;
searchOpen: boolean;
selectedItemId: string;
}) {
if (items.length === 0) {
return null;
}
if (items.length > 500) {
return (
<button
id="jump-search-button"
type="button"
aria-controls="files-sidebar"
aria-expanded={searchOpen}
aria-label={label("jumpToFile")}
title={label("jumpToFile")}
onClick={onOpenSearch}
>
{label("jumpToFile")}
</button>
);
}
return (
<select
id="jump-select"
@@ -1097,7 +1360,9 @@ function FilesSidebar({
aria-label={state.fileSearchOpen ? label("hideFileSearch") : label("showFileSearch")}
aria-pressed={state.fileSearchOpen}
disabled={!state.treeSource}
onClick={() => dispatch({ type: "set-file-search-open", open: !state.fileSearchOpen })}
onClick={() => state.fileSearchOpen
? closeFileSearch(dispatch)
: dispatch({ type: "set-file-search-open", open: true })}
>
<Icon name="search" />
</button>
@@ -1354,60 +1619,198 @@ function usePierreFileTreeSelection(model: ReturnType<typeof useFileTree>["model
function useRenderDiff(
config: DiffViewerConfig,
transport: DiffTransport | null,
label: DiffViewerLabelResolver,
dispatch: React.Dispatch<AppAction>,
latestState: React.MutableRefObject<AppState>,
onPatchURL: (url: string) => void,
activeSessionRef: React.MutableRefObject<ActiveDiffSession | null>,
closeActiveSession: () => Promise<void>,
sessionSource: DiffSource | null,
onResolvedSessionSource: (source: DiffSource) => void,
) {
const started = useRef(false);
useEffect(() => {
if (started.current || isStatusOnlyPayload(config.payload)) {
if (isStatusOnlyPayload(config.payload, transport, sessionSource)) {
return;
}
started.current = true;
const payload = config.payload ?? {};
const appearance = resolveDiffViewerAppearance(payload.appearance);
if (appearance.themes.light.name) {
registerCustomTheme(appearance.themes.light.name, () => Promise.resolve(shikiThemeFromGhostty(appearance.themes.light, appearance)));
for (const theme of [appearance.themes.light, appearance.themes.dark]) {
if (theme.name && !registeredCustomThemeNames.has(theme.name)) {
registerCustomTheme(theme.name, () => Promise.resolve(shikiThemeFromGhostty(theme, appearance)));
registeredCustomThemeNames.add(theme.name);
}
}
if (appearance.themes.dark.name) {
registerCustomTheme(appearance.themes.dark.name, () => Promise.resolve(shikiThemeFromGhostty(appearance.themes.dark, appearance)));
}
const streamedItems: DiffItem[] = [];
dispatch({ type: "set-status", status: createDiffViewerStatus(label("parsingDiff"), { loading: true }) });
streamPatch({
getCollapsed: () => latestState.current.options.collapsed,
initialFileTreeRowCount: getInitialFileTreeRowCount(),
label,
onBatch: (items) => {
streamedItems.push(...items);
dispatch({ type: "append-items", items });
},
onComplete: (metrics) => {
dispatch({ type: "set-metrics", metrics });
const items = streamedItems;
if (items.length === 0) {
dispatch({ type: "set-status", status: createDiffViewerStatus(label("noFileDiffs"), { error: true, loading: false, statusOnly: true }) });
let cancelled = false;
const streamAbortController = new AbortController();
const handlePageHide = () => {
void closeActiveSession();
};
window.addEventListener("pagehide", handlePageHide);
void (async () => {
try {
let patchURL = payload.patchURL as string | undefined;
const session = diffSessionRequest(payload, transport, sessionSource);
if (session) {
const result = await transport!.request({ method: "sessionOpen", params: session });
if (result.type !== "sessionOpened") {
throw new DiffTransportError("invalidResponse", "Diff transport did not open a session");
}
const openedSession = {
sessionId: result.value.sessionId,
capabilityToken: String(payload.capabilityToken ?? ""),
};
if (cancelled) {
await closeDiffSession(transport!, openedSession);
return;
}
activeSessionRef.current = openedSession;
onResolvedSessionSource(result.value.source);
patchURL = result.value.patch.id;
}
if (cancelled || !patchURL) {
return;
}
const themes = Array.from(new Set([appearance.theme?.light, appearance.theme?.dark].filter(Boolean)));
const langs = Array.from(new Set(items.flatMap((item) => {
const diff = item.fileDiff ?? {};
return resolveDiffPreloadLanguages(fileName(diff, ""), diff.lang, diff, getFiletypeFromFileName);
})));
preloadHighlighter({ themes, langs: langs.length > 0 ? langs : ["text"] })
.catch((error) => console.warn("cmux diff highlighter preload failed", error));
},
onMetrics: (metrics) => dispatch({ type: "set-metrics", metrics }),
onRename: (rename) => dispatch({ type: "rename-item", oldId: rename.oldId, newId: rename.newId }),
onTreeSource: (source) => dispatch({ type: "set-tree-source", source }),
parsePatchFiles,
patchURL: payload.patchURL,
processFile,
}).catch((error) => {
console.error("cmux diff viewer render failed", error);
dispatch({ type: "set-status", status: createDiffViewerStatus(label("renderFailed"), { error: true, loading: false, statusOnly: true }) });
});
}, [config, dispatch, label, latestState]);
onPatchURL(patchURL);
const streamedItems: DiffItem[] = [];
dispatch({ type: "set-status", status: createDiffViewerStatus(label("parsingDiff"), { loading: true }) });
await streamPatch({
getCollapsed: () => latestState.current.options.collapsed,
initialFileTreeRowCount: getInitialFileTreeRowCount(),
label,
signal: streamAbortController.signal,
onBatch: (items) => {
if (cancelled) return;
streamedItems.push(...items);
dispatch({ type: "append-items", items });
},
onComplete: (metrics) => {
if (cancelled) return;
dispatch({ type: "set-metrics", metrics });
const items = streamedItems;
if (items.length === 0) {
const emptyMessage = typeof payload.emptyMessage === "string" ? payload.emptyMessage : label("noFileDiffs");
dispatch({ type: "set-status", status: createDiffViewerStatus(emptyMessage, { error: false, loading: false, statusOnly: true }) });
return;
}
const themes = Array.from(new Set([appearance.theme?.light, appearance.theme?.dark].filter(Boolean)));
const langs = Array.from(new Set(items.flatMap((item) => {
const diff = item.fileDiff ?? {};
return resolveDiffPreloadLanguages(fileName(diff, ""), diff.lang, diff, getFiletypeFromFileName);
})));
preloadHighlighter({ themes, langs: langs.length > 0 ? langs : ["text"] })
.catch((error) => console.warn("cmux diff highlighter preload failed", error));
},
onMetrics: (metrics) => {
if (!cancelled) dispatch({ type: "set-metrics", metrics });
},
onRename: (rename) => {
if (!cancelled) dispatch({ type: "rename-item", oldId: rename.oldId, newId: rename.newId });
},
onTreeSource: (source) => {
if (!cancelled) dispatch({ type: "set-tree-source", source });
},
parsePatchFiles,
patchURL,
processFile,
});
} catch (error) {
if (cancelled) {
return;
}
const empty = error instanceof DiffTransportError && error.code === "emptyDiff";
if (!empty) {
console.error("cmux diff viewer render failed", error);
}
const emptyMessage = typeof payload.emptyMessage === "string" ? payload.emptyMessage : label("noFileDiffs");
dispatch({
type: "set-status",
status: createDiffViewerStatus(empty ? emptyMessage : label("renderFailed"), {
error: !empty,
loading: false,
statusOnly: true,
}),
});
}
})();
return () => {
cancelled = true;
streamAbortController.abort();
window.removeEventListener("pagehide", handlePageHide);
void closeActiveSession();
};
}, [activeSessionRef, closeActiveSession, config, dispatch, label, latestState, onPatchURL, onResolvedSessionSource, sessionSource, transport]);
}
function closeDiffSession(transport: DiffTransport, session: ActiveDiffSession): Promise<void> {
return transport.request({ method: "sessionClose", params: session }).then(() => {}, () => {});
}
function diffSessionRequest(payload: any, transport: DiffTransport | null, overrideSource?: DiffSource | null): {
source: DiffSource;
capabilityToken: string;
} | null {
if (!transport || typeof payload?.capabilityToken !== "string") {
return null;
}
const source = overrideSource ?? payload.sessionSource;
if (!validDiffSource(source)) {
return null;
}
return { source, capabilityToken: payload.capabilityToken };
}
function validDiffSource(value: unknown): value is DiffSource {
if (!value || typeof value !== "object" || typeof (value as { kind?: unknown }).kind !== "string") {
return false;
}
const source = value as { kind: string; repoRoot?: unknown; path?: unknown; baseRef?: unknown };
if (source.kind === "patch") {
return typeof source.path === "string";
}
if (source.kind === "unstaged" || source.kind === "staged") {
return typeof source.repoRoot === "string";
}
return source.kind === "branch"
&& typeof source.repoRoot === "string"
&& (source.baseRef == null || typeof source.baseRef === "string");
}
function diffSourceKind(source: DiffSource | null): string | null {
return source?.kind ?? null;
}
function diffSourceRepoRoot(source: DiffSource | null): string | null {
return source && "repoRoot" in source ? source.repoRoot : null;
}
function sourceSelectionWithActiveRepo(source: DiffSource, active: DiffSource | null): DiffSource {
if (source.kind === "patch") {
return source;
}
const activeRepo = diffSourceRepoRoot(active);
if (!activeRepo) {
return source;
}
if (source.kind === "branch") {
return source.repoRoot === activeRepo
? { ...source, repoRoot: activeRepo }
: { kind: "branch", repoRoot: activeRepo };
}
return { ...source, repoRoot: activeRepo };
}
function repoSelectionWithActiveSource(source: DiffSource, active: DiffSource | null): DiffSource {
const repoRoot = diffSourceRepoRoot(source);
if (!repoRoot || !active || active.kind === "patch") {
return source;
}
if (active.kind === "branch") {
return active.repoRoot === repoRoot
? { ...active, repoRoot }
: { kind: "branch", repoRoot };
}
return { ...active, repoRoot };
}
function resolveDiffItemLanguage(item: DiffItem): void {
@@ -1437,12 +1840,23 @@ function mergeLanguages(current: string[], next: string[]): string[] {
return Array.from(languages);
}
function isStatusOnlyPayload(payload: any): boolean {
return payload?.pendingReplacement === true ||
(typeof payload?.statusMessage === "string" && payload.statusMessage.length > 0);
function isStatusOnlyPayload(
payload: any,
transport: DiffTransport | null = null,
sessionSource: DiffSource | null = null,
): boolean {
if (payload?.pendingReplacement === true) {
return diffSessionRequest(payload, transport, sessionSource) == null;
}
return typeof payload?.statusMessage === "string" && payload.statusMessage.length > 0;
}
function usePendingReplacement(payload: any, label: DiffViewerLabelResolver, dispatch: React.Dispatch<AppAction>) {
function usePendingReplacement(
payload: any,
label: DiffViewerLabelResolver,
dispatch: React.Dispatch<AppAction>,
transport: DiffTransport | null,
) {
const started = useRef(false);
useEffect(() => {
if (started.current) {
@@ -1454,7 +1868,16 @@ function usePendingReplacement(payload: any, label: DiffViewerLabelResolver, dis
type: "set-status",
status: createDiffViewerStatus(payload.statusMessage ?? label("loadingDiff"), { loading: true, pending: true }),
});
fetch("/__cmux_diff_viewer_wait" + location.pathname, { cache: "no-store" })
if (diffSessionRequest(payload, transport)) {
return;
}
// The native host replaces the file and navigates this surface when Git
// generation completes. Custom-scheme resources never use an HTTP wait
// endpoint, so keep the loading state until that navigation arrives.
if (window.location.protocol === "cmux-diff-viewer:") {
return;
}
fetch("/__cmux_diff_viewer_wait" + window.location.pathname, { cache: "no-store" })
.then(async (response) => {
if (!response.ok) {
throw new Error("replacement failed");
@@ -1481,7 +1904,7 @@ function usePendingReplacement(payload: any, label: DiffViewerLabelResolver, dis
}),
});
}
}, [dispatch, label, payload]);
}, [dispatch, label, payload, transport]);
}
function usePageDataAttributes(state: AppState) {
@@ -1571,6 +1994,44 @@ function useOptionsDismiss(optionsOpen: boolean, dispatch: React.Dispatch<AppAct
}, [dispatch, optionsOpen]);
}
export function closeFileSearch(dispatch: React.Dispatch<AppAction>, targetDocument: Document = document) {
dispatch({ type: "set-file-search-open", open: false });
const trigger = targetDocument.getElementById("jump-search-button") ?? targetDocument.getElementById("jump-select");
trigger?.focus();
}
export function shouldDismissFileSearch(key: string, narrowViewport: boolean): boolean {
return key === "Escape" && narrowViewport;
}
function useFileSearchDismiss(fileSearchOpen: boolean, dispatch: React.Dispatch<AppAction>) {
useEffect(() => {
if (!fileSearchOpen) {
return;
}
const closeOnEscape = (event: KeyboardEvent) => {
if (shouldDismissFileSearch(event.key, window.matchMedia("(max-width: 520px)").matches)) {
event.preventDefault();
closeFileSearch(dispatch);
}
};
document.addEventListener("keydown", closeOnEscape);
return () => document.removeEventListener("keydown", closeOnEscape);
}, [dispatch, fileSearchOpen]);
}
function useDiffTransport(config: DiffTransportConfig | undefined): DiffTransport | null {
const transportRef = useRef<DiffTransport | null | undefined>(undefined);
if (transportRef.current === undefined) {
transportRef.current = createDiffTransport(config);
}
useEffect(() => {
const transport = transportRef.current;
return () => transport?.close();
}, []);
return transportRef.current;
}
function scrollTargetForItem(itemId: string, items: DiffItem[]): string {
if (items.some((item) => item.id === itemId)) {
return itemId;
+84 -23
View File
@@ -2,6 +2,12 @@ import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "./icons";
import type { DiffViewerLabelResolver, DiffViewerLabelKey } from "./labels";
import type { DiffTransport } from "./diff/transport";
import type {
BranchListResult,
BranchPickerGroup,
BranchPickerRow,
} from "./diff/generated/protocol";
/**
* Searchable, uncapped branch base picker. Renders a heavy toolbar button that
@@ -13,6 +19,8 @@ import type { DiffViewerLabelResolver, DiffViewerLabelKey } from "./labels";
export type BranchPickerPayload = {
repoRoot: string;
groupId?: string;
capabilityToken?: string;
headRef: string;
currentRef: string;
currentReason: string;
@@ -22,23 +30,9 @@ export type BranchPickerPayload = {
regenerateURLTemplate: string;
};
type BranchPickerRow = {
ref: string;
label: string;
secondary?: string;
reason?: string;
confidence?: "high" | "low";
current?: boolean;
worktreeDir?: string;
};
type BranchPickerGroup = {
id: string;
label: string;
rows: BranchPickerRow[];
};
type RefsResponse = { groups: BranchPickerGroup[] };
export function branchPickerStateKey(picker: BranchPickerPayload): string {
return `${picker.repoRoot}\u0000${picker.capabilityToken ?? ""}\u0000${picker.currentRef}`;
}
// Strip a leading `scheme://host[:port]` so the URL becomes root-relative and
// resolves against the CURRENT document origin. The persisted diff HTML embeds
@@ -152,11 +146,15 @@ function computePopoverStyle(rect: DOMRect): PopoverStyle {
export function BranchBasePicker({
label,
onNavigate,
onSelectBranchBase,
picker,
transport = null,
}: {
label: DiffViewerLabelResolver;
onNavigate: (url: string) => void;
onSelectBranchBase?: (baseRef: string) => void;
picker: BranchPickerPayload;
transport?: DiffTransport | null;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
@@ -164,6 +162,7 @@ export function BranchBasePicker({
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle");
const [highlight, setHighlight] = useState(0);
const [generatingRef, setGeneratingRef] = useState<string | null>(null);
const [regenerationFailed, setRegenerationFailed] = useState(false);
// Inline position for the viewport-anchored (position: fixed) popover. Null
// until the first measurement after open, so the popover is not painted at a
// stale 0,0 for a frame. Recomputed on open, resize, and ancestor scroll.
@@ -194,7 +193,7 @@ export function BranchBasePicker({
setHighlight(0);
if (groups == null && loadState !== "loading") {
setLoadState("loading");
fetchRefs(toCurrentOriginRelative(picker.refsURL))
loadRefs(picker, transport)
.then((response) => {
setGroups(response.groups);
setLoadState("idle");
@@ -216,11 +215,47 @@ export function BranchBasePicker({
if (trimmed === "") {
return;
}
if (trimmed === picker.currentRef) {
closePopover();
setGeneratingRef(null);
return;
}
setGeneratingRef(trimmed);
setRegenerationFailed(false);
setOpen(false);
onNavigate(
toCurrentOriginRelative(picker.regenerateURLTemplate).replace("{ref}", encodeURIComponent(trimmed)),
);
if (onSelectBranchBase) {
onSelectBranchBase(trimmed);
setGeneratingRef(null);
return;
}
if (transport && picker.groupId && picker.capabilityToken) {
transport.request({
method: "branchChange",
params: {
groupId: picker.groupId,
repoRoot: picker.repoRoot,
baseRef: trimmed,
capabilityToken: picker.capabilityToken,
},
}).then((result) => {
if (result.type !== "navigation") {
throw new Error("branch change response missing navigation");
}
onNavigate(result.value.url);
}).catch((error) => {
console.warn("cmux diff branch picker regeneration failed", error);
setGeneratingRef(null);
setRegenerationFailed(true);
setLoadState("idle");
setQuery("");
setHighlight(0);
// Reopen the cached picker. Its callback-ref focuses the filter input,
// while the localized error explains why navigation did not happen.
setOpen(true);
});
return;
}
onNavigate(toCurrentOriginRelative(picker.regenerateURLTemplate).replace("{ref}", encodeURIComponent(trimmed)));
};
// Outside-click dismissal while open. Isolated to one effect with a narrow
@@ -372,6 +407,11 @@ export function BranchBasePicker({
onKeyDown={onInputKeyDown}
/>
</div>
{regenerationFailed ? (
<output className="base-picker-status base-picker-status-error">
{label("branchPickerGenerateFailed")}
</output>
) : null}
{/* Searchable command-palette listbox; a native select/datalist cannot
render grouped rows with secondaries, pills, and matched bolding. */}
{/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role */}
@@ -664,14 +704,35 @@ function fuzzyMatchSpan(text: string, query: string): [number, number] | null {
return firstHit >= 0 ? [firstHit, firstHit + 1] : null;
}
async function fetchRefs(refsURL: string): Promise<RefsResponse> {
async function fetchRefs(refsURL: string): Promise<BranchListResult> {
const response = await fetch(refsURL, { cache: "no-store" });
if (!response.ok) {
throw new Error(`refs request failed (${response.status})`);
}
const data = (await response.json()) as RefsResponse;
const data = (await response.json()) as BranchListResult;
if (!data || !Array.isArray(data.groups)) {
throw new Error("refs response missing groups");
}
return data;
}
async function loadRefs(picker: BranchPickerPayload, transport: DiffTransport | null): Promise<BranchListResult> {
if (transport && picker.capabilityToken) {
const result = await transport.request({
method: "branchList",
params: {
repoRoot: picker.repoRoot,
capabilityToken: picker.capabilityToken,
selectedBase: picker.currentRef,
},
});
if (result.type !== "branches") {
throw new Error("branch list response missing groups");
}
if (!Array.isArray(result.value.groups)) {
throw new Error("branch list response missing groups");
}
return result.value;
}
return fetchRefs(toCurrentOriginRelative(picker.refsURL));
}
@@ -14,13 +14,6 @@ declare global {
applyTheme(theme: AgentSessionTheme): void;
receive(event: AgentEvent): void;
};
webkit?: {
messageHandlers?: {
agentSession?: {
postMessage(message: unknown): Promise<NativeReply<unknown>>;
};
};
};
}
}
+19 -9
View File
@@ -1,24 +1,34 @@
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { listComments } from "./bridge";
import type { DiffCommentRecord } from "./types";
/**
* Loads persisted comments once on mount when the native bridge is available
* (repoRoot is null otherwise). Mirrors the started-ref pattern used by
* `useRenderDiff`/`usePendingReplacement` in App.tsx.
* Loads persisted comments for the active repository. Cleanup invalidates an
* older load so a slow response cannot overwrite comments after a repo switch.
*/
export function useCommentsBootstrap(
repoRoot: string | null,
onLoaded: (comments: DiffCommentRecord[]) => void,
): void {
const started = useRef(false);
useEffect(() => {
if (started.current || repoRoot == null) {
onLoaded([]);
if (repoRoot == null) {
return;
}
started.current = true;
let active = true;
listComments(repoRoot)
.then((comments) => onLoaded(comments))
.catch((error) => console.warn("cmux diff comments load failed", error));
.then((comments) => {
if (active) {
onLoaded(comments);
}
})
.catch((error) => {
if (active) {
console.warn("cmux diff comments load failed", error);
}
});
return () => {
active = false;
};
}, [onLoaded, repoRoot]);
}
+39
View File
@@ -0,0 +1,39 @@
import type { DiffViewerLabelResolver } from "./labels";
export type DiffMetadataKind = "binary" | "mode";
export function annotateDiffMetadata(fileDiff: any, patchText?: string): void {
if (fileDiff == null || typeof fileDiff !== "object") {
return;
}
const hunks = Array.isArray(fileDiff.hunks) ? fileDiff.hunks : [];
const hasBinaryMarker = patchText != null && /(?:^|\n)(?:GIT binary patch|Binary files .* differ)(?:\n|$)/.test(patchText);
const isParsedBinary = fileDiff.type === "change" && hunks.length === 0 &&
typeof fileDiff.prevObjectId === "string" && typeof fileDiff.newObjectId === "string" &&
fileDiff.prevMode == null;
if (hasBinaryMarker || isParsedBinary) {
fileDiff.cmuxDiffMetadataKind = "binary" satisfies DiffMetadataKind;
} else if (typeof fileDiff.prevMode === "string" && typeof fileDiff.mode === "string" && fileDiff.prevMode !== fileDiff.mode) {
fileDiff.cmuxDiffMetadataKind = "mode" satisfies DiffMetadataKind;
}
}
export function resolveDiffHeaderMetadata(fileDiff: any, label: DiffViewerLabelResolver): string | undefined {
if (fileDiff?.cmuxDiffMetadataKind === "binary") {
return label("binaryFile");
}
if (fileDiff?.cmuxDiffMetadataKind === "mode") {
return label("modeChange")
.replace("{old}", fileDiff.prevMode ?? "")
.replace("{new}", fileDiff.mode ?? "");
}
return undefined;
}
export function DiffHeaderMetadata({ fileDiff, label }: { fileDiff: any; label: DiffViewerLabelResolver }) {
const metadata = resolveDiffHeaderMetadata(fileDiff, label);
if (metadata == null) {
return null;
}
return <span data-cmux-diff-metadata={fileDiff.cmuxDiffMetadataKind}>{metadata}</span>;
}
+213 -42
View File
@@ -2,6 +2,7 @@ import type { CodeViewItem } from "@pierre/diffs";
import type { CommentAnnotationMetadata } from "./comments/types";
import type { DiffViewerLabelResolver } from "./labels";
import type { FileTreeRefreshSource } from "./file-tree-refresh";
import { annotateDiffMetadata } from "./diff-metadata";
export type GitStatusPatchEntry = {
path: string;
@@ -40,7 +41,8 @@ export type FileTreeSource = FileTreeRefreshSource & {
pathCount: number;
paths: string[];
pathToItemId: Map<string, string>;
previousSource?: FileTreeSource;
previousRevision?: number;
revision: number;
statsChanged?: boolean;
statsByPath: Map<string, FileStats>;
treePathByItemId: Map<string, string>;
@@ -49,11 +51,16 @@ export type FileTreeSource = FileTreeRefreshSource & {
export type StreamMetrics = {
completedAt: number;
fileCount?: number;
firstBatchAt?: number;
firstBatchFileCount?: number;
flushCount: number;
longYieldCount: number;
maxBatchSize: number;
maxYieldMs: number;
renderableFileCount?: number;
startedAt: number;
treeRefreshCount: number;
yieldCount: number;
};
type PathState = {
@@ -68,10 +75,11 @@ type StreamingDiffModel = {
diffStats: DiffStats;
fileIndex: number;
gitStatusByPath: Map<string, GitStatusPatchEntry>;
gitStatusEntries: GitStatusPatchEntry[];
gitStatusIndexByPath: Map<string, number>;
itemIdByTreePath: Map<string, string>;
itemIdToFile: Map<string, { fileOrder: number; path: string }>;
items: DiffItem[];
lastTreeSource?: FileTreeSource;
nextCollisionSuffixByBase: Map<string, number>;
paths: string[];
pathStateByTreePath: Map<string, PathState>;
@@ -83,6 +91,7 @@ type StreamingDiffModel = {
pendingStatsChanged: boolean;
statsByPath: Map<string, FileStats>;
treePathByItemId: Map<string, string>;
treeRevision: number;
};
type RenameDiffItem = {
@@ -94,6 +103,7 @@ export type StreamPatchOptions = {
getCollapsed: () => boolean;
initialFileTreeRowCount: number;
label: DiffViewerLabelResolver;
signal?: AbortSignal;
onBatch: (items: DiffItem[]) => void;
onComplete: (metrics: StreamMetrics) => void;
onMetrics: (metrics: StreamMetrics) => void;
@@ -107,27 +117,39 @@ export type StreamPatchOptions = {
const commitMetadataPattern = /^From\s+([a-f0-9]+)\s/im;
export async function streamPatch(options: StreamPatchOptions): Promise<void> {
const throwIfAborted = () => {
if (options.signal?.aborted) {
throw options.signal.reason ?? new DOMException("The diff stream was cancelled", "AbortError");
}
};
const model = createStreamingDiffModel();
const metrics: StreamMetrics = {
startedAt: performance.now(),
completedAt: 0,
flushCount: 0,
longYieldCount: 0,
maxBatchSize: 0,
maxYieldMs: 0,
treeRefreshCount: 0,
yieldCount: 0,
};
let firstRender = true;
let lastYieldAt = performance.now();
let lastFlushAt = performance.now();
let nextIncrementalBatchSize = 128;
let currentPatchPrefix: string | undefined;
let patchMetadataIndex = 0;
const batchConfig = {
initialBatchSize: options.initialFileTreeRowCount,
incrementalBatchSize: 25,
maxIncrementalBatchSize: 4_096,
initialMaxWait: 500,
incrementalMaxWait: 100,
};
function makeItem(fileDiff: any, patchPrefix: string | undefined): DiffItem | undefined {
throwIfAborted();
annotateDiffMetadata(fileDiff);
normalizeGitFileDiffPaths(fileDiff);
const result = appendFileDiffToModel(model, fileDiff, patchPrefix, options.getCollapsed(), options.label("untitled"));
if (result?.renamedItem) {
options.onRename(result.renamedItem);
@@ -136,6 +158,7 @@ export async function streamPatch(options: StreamPatchOptions): Promise<void> {
}
async function enqueueFileDiff(fileDiff: any, patchPrefix: string | undefined) {
throwIfAborted();
const item = makeItem(fileDiff, patchPrefix);
if (!item) {
return;
@@ -144,6 +167,7 @@ export async function streamPatch(options: StreamPatchOptions): Promise<void> {
}
async function maybeFlushPendingItems(force: boolean) {
throwIfAborted();
if (model.pendingItems.length === 0) {
return;
}
@@ -155,20 +179,21 @@ export async function streamPatch(options: StreamPatchOptions): Promise<void> {
model.pendingItems.length < batchConfig.initialBatchSize &&
now - lastFlushAt < batchConfig.initialMaxWait
) {
await yieldToNextFrame();
await measuredYield();
lastYieldAt = performance.now();
return;
}
const batchSize = firstRender ? batchConfig.initialBatchSize : batchConfig.incrementalBatchSize;
const batchSize = firstRender ? batchConfig.initialBatchSize : nextIncrementalBatchSize;
const maxWait = firstRender ? batchConfig.initialMaxWait : batchConfig.incrementalMaxWait;
if (force || model.pendingItems.length >= batchSize || now - lastFlushAt >= maxWait) {
flushPendingItems();
await yieldToNextFrame();
await measuredYield();
lastYieldAt = performance.now();
}
}
function flushPendingItems() {
throwIfAborted();
if (model.pendingItems.length === 0) {
return;
}
@@ -176,6 +201,10 @@ export async function streamPatch(options: StreamPatchOptions): Promise<void> {
model.pendingItemById.clear();
model.items.push(...batch);
options.onBatch(batch);
if (metrics.firstBatchAt == null) {
metrics.firstBatchAt = performance.now();
metrics.firstBatchFileCount = batch.length;
}
metrics.flushCount += 1;
metrics.maxBatchSize = Math.max(metrics.maxBatchSize, batch.length);
metrics.fileCount = model.items.length;
@@ -183,15 +212,32 @@ export async function streamPatch(options: StreamPatchOptions): Promise<void> {
refreshTreeSource();
options.onMetrics({ ...metrics });
lastFlushAt = performance.now();
if (!firstRender) {
nextIncrementalBatchSize = Math.min(nextIncrementalBatchSize * 4, batchConfig.maxIncrementalBatchSize);
}
firstRender = false;
}
async function measuredYield() {
throwIfAborted();
const startedAt = performance.now();
await yieldToNextFrame();
const duration = performance.now() - startedAt;
metrics.yieldCount += 1;
metrics.maxYieldMs = Math.max(metrics.maxYieldMs, duration);
if (duration > 16) {
metrics.longYieldCount += 1;
}
}
function refreshTreeSource() {
throwIfAborted();
metrics.treeRefreshCount += 1;
options.onTreeSource(createFileTreeSourceFromModel(model));
}
async function appendCompleteFileText(fileText: string) {
throwIfAborted();
if (fileText.trim() === "") {
return;
}
@@ -201,16 +247,20 @@ export async function streamPatch(options: StreamPatchOptions): Promise<void> {
patchMetadataIndex += 1;
}
const cacheKey = `cmux-diff-file-${model.fileIndex}`;
await enqueueFileDiff(options.processFile(fileText, { cacheKey, isGitDiff: true }), currentPatchPrefix);
const fileDiff = options.processFile(fileText, { cacheKey, isGitDiff: true });
annotateDiffMetadata(fileDiff, fileText);
await enqueueFileDiff(fileDiff, currentPatchPrefix);
}
const response = await fetch(options.patchURL, { cache: "no-store" });
const response = await fetch(options.patchURL, { cache: "no-store", signal: options.signal });
if (!response.ok) {
throw new Error(`${options.label("loadingDiff")} (${response.status})`);
}
if (!response.body?.getReader) {
throwIfAborted();
const text = await response.text();
throwIfAborted();
await appendParsedPatchText(text, options, enqueueFileDiff);
await maybeFlushPendingItems(true);
metrics.completedAt = performance.now();
@@ -221,7 +271,9 @@ export async function streamPatch(options: StreamPatchOptions): Promise<void> {
const decoder = new TextDecoder();
const reader = response.body.getReader();
const splitter = createStreamingPatchFileSplitter();
const maxDecodeChunkBytes = 256 * 1024;
while (true) {
throwIfAborted();
const { done, value } = await reader.read();
if (done) {
const tail = decoder.decode();
@@ -231,11 +283,15 @@ export async function streamPatch(options: StreamPatchOptions): Promise<void> {
}
break;
}
splitter.push(decoder.decode(value, { stream: true }));
await drainPatchFileSplitter(splitter, appendCompleteFileText);
for (let offset = 0; offset < value.byteLength; offset += maxDecodeChunkBytes) {
throwIfAborted();
splitter.push(decoder.decode(value.subarray(offset, offset + maxDecodeChunkBytes), { stream: true }));
await drainPatchFileSplitter(splitter, appendCompleteFileText);
}
}
const finalFile = splitter.finish();
throwIfAborted();
if (finalFile.fileText != null) {
await appendCompleteFileText(finalFile.fileText);
await drainPatchFileSplitter(splitter, appendCompleteFileText);
@@ -253,6 +309,8 @@ function createStreamingDiffModel(): StreamingDiffModel {
diffStats: { addedLines: 0, deletedLines: 0, fileCount: 0, totalLinesOfCode: 0 },
fileIndex: 0,
gitStatusByPath: new Map(),
gitStatusEntries: [],
gitStatusIndexByPath: new Map(),
itemIdToFile: new Map(),
itemIdByTreePath: new Map(),
nextCollisionSuffixByBase: new Map(),
@@ -267,6 +325,7 @@ function createStreamingDiffModel(): StreamingDiffModel {
pendingStatsChanged: false,
statsByPath: new Map(),
treePathByItemId: new Map(),
treeRevision: 0,
};
}
@@ -333,7 +392,7 @@ function moveCurrentPathItemToPrevious(model: StreamingDiffModel, treePath: stri
const replacementItem = { ...state.currentItem, id: newId };
state.currentItem = replacementItem;
state.currentItemId = newId;
replaceModelItem(model.items, oldId, replacementItem);
replaceModelItem(model, oldId, replacementItem);
const itemMetadata = model.itemIdToFile.get(oldId);
if (itemMetadata) {
model.itemIdToFile.delete(oldId);
@@ -344,7 +403,6 @@ function moveCurrentPathItemToPrevious(model: StreamingDiffModel, treePath: stri
model.treePathByItemId.set(newId, treePath);
}
if (model.pendingItemById.has(oldId)) {
replaceModelItem(model.pendingItems, oldId, replacementItem);
model.pendingItemById.delete(oldId);
model.pendingItemById.set(newId, replacementItem);
return undefined;
@@ -352,10 +410,15 @@ function moveCurrentPathItemToPrevious(model: StreamingDiffModel, treePath: stri
return { oldId, newId };
}
function replaceModelItem(items: DiffItem[], oldId: string, replacementItem: DiffItem): void {
const index = items.findIndex((item) => item.id === oldId);
if (index !== -1) {
items[index] = replacementItem;
function replaceModelItem(model: StreamingDiffModel, oldId: string, replacementItem: DiffItem): void {
const fileOrder = model.itemIdToFile.get(oldId)?.fileOrder;
if (fileOrder == null) {
return;
}
if (fileOrder < model.items.length) {
model.items[fileOrder] = replacementItem;
} else {
model.pendingItems[fileOrder - model.items.length] = replacementItem;
}
}
@@ -376,6 +439,7 @@ function uniqueDiffItemId(model: StreamingDiffModel, baseId: string): string {
function updateGitStatusForPath(model: StreamingDiffModel, treePath: string, changeType: string | undefined, sawDeleted: boolean): void {
if (sawDeleted && changeType !== "deleted") {
if (model.gitStatusByPath.delete(treePath)) {
removeGitStatusEntry(model, treePath);
markGitStatusRemoved(model, treePath);
}
return;
@@ -383,6 +447,7 @@ function updateGitStatusForPath(model: StreamingDiffModel, treePath: string, cha
const status = gitStatusType(changeType);
if (status === "modified") {
if (model.gitStatusByPath.delete(treePath)) {
removeGitStatusEntry(model, treePath);
markGitStatusRemoved(model, treePath);
}
return;
@@ -393,32 +458,56 @@ function updateGitStatusForPath(model: StreamingDiffModel, treePath: string, cha
}
const entry = { path: treePath, status };
model.gitStatusByPath.set(treePath, entry);
const currentIndex = model.gitStatusIndexByPath.get(treePath);
if (currentIndex == null) {
model.gitStatusIndexByPath.set(treePath, model.gitStatusEntries.length);
model.gitStatusEntries.push(entry);
} else {
model.gitStatusEntries[currentIndex] = entry;
}
model.pendingGitStatusRemovePaths.delete(treePath);
model.pendingGitStatusSetByPath.set(treePath, entry);
}
function removeGitStatusEntry(model: StreamingDiffModel, treePath: string): void {
const index = model.gitStatusIndexByPath.get(treePath);
if (index == null) {
return;
}
const lastIndex = model.gitStatusEntries.length - 1;
const lastEntry = model.gitStatusEntries[lastIndex];
model.gitStatusEntries.pop();
model.gitStatusIndexByPath.delete(treePath);
if (index !== lastIndex && lastEntry != null) {
model.gitStatusEntries[index] = lastEntry;
model.gitStatusIndexByPath.set(lastEntry.path, index);
}
}
function markGitStatusRemoved(model: StreamingDiffModel, treePath: string): void {
model.pendingGitStatusSetByPath.delete(treePath);
model.pendingGitStatusRemovePaths.add(treePath);
}
function createFileTreeSourceFromModel(model: StreamingDiffModel): FileTreeSource {
const previousSource = model.lastTreeSource;
const paths = [...model.paths];
const previousRevision = model.treeRevision === 0 ? undefined : model.treeRevision;
model.treeRevision += 1;
const source: FileTreeSource = {
diffStats: { ...model.diffStats },
gitStatus: Array.from(model.gitStatusByPath.values()),
gitStatus: model.gitStatusEntries.map((entry) => ({ ...entry })),
gitStatusPatch: buildGitStatusPatch(model),
pathCount: paths.length,
paths,
pathCount: model.paths.length,
paths: [...model.paths],
pathToItemId: new Map(model.pathToItemId),
previousSource,
previousRevision,
revision: model.treeRevision,
statsChanged: model.pendingStatsChanged,
statsByPath: new Map(model.statsByPath),
statsByPath: new Map(
Array.from(model.statsByPath, ([path, stats]) => [path, { ...stats }]),
),
treePathByItemId: new Map(model.treePathByItemId),
};
model.pendingStatsChanged = false;
model.lastTreeSource = source;
return source;
}
@@ -456,6 +545,7 @@ async function appendParsedPatchText(
function createStreamingPatchFileSplitter() {
let boundaryIndex: number | undefined;
let buffer = "";
let consumed = 0;
let searchStart = 0;
let sawGitBoundary = false;
const gitMarker = "diff --git ";
@@ -465,8 +555,8 @@ function createStreamingPatchFileSplitter() {
function nextGitBoundaryIndex(text: string, start: number): number | undefined {
const offset = Math.max(start, 0);
if (offset === 0 && text.startsWith(gitMarker)) {
return 0;
if (text.startsWith(gitMarker, offset)) {
return offset;
}
const index = text.indexOf(gitMarkerWithNewline, offset);
return index === -1 ? undefined : index + 1;
@@ -478,9 +568,9 @@ function createStreamingPatchFileSplitter() {
function takeAvailableFile(): string | null {
if (boundaryIndex == null) {
boundaryIndex = nextGitBoundaryIndex(buffer, searchStart);
boundaryIndex = nextGitBoundaryIndex(buffer, Math.max(searchStart, consumed));
if (boundaryIndex == null) {
searchStart = nextGitBoundarySearchStart(buffer, 0);
searchStart = nextGitBoundarySearchStart(buffer, consumed);
return null;
}
sawGitBoundary = true;
@@ -498,10 +588,11 @@ function createStreamingPatchFileSplitter() {
return null;
}
const splitBoundary = commitMetadataBoundaryIndex(buffer, currentBoundary + 1, nextBoundary) ?? nextBoundary;
const fileText = buffer.slice(0, splitBoundary);
buffer = buffer.slice(splitBoundary);
boundaryIndex = nextGitBoundaryIndex(buffer, 0);
searchStart = boundaryIndex == null ? 0 : boundaryIndex + 1;
const fileText = buffer.slice(consumed, splitBoundary);
consumed = splitBoundary;
compactConsumedPrefix();
boundaryIndex = nextGitBoundaryIndex(buffer, consumed);
searchStart = boundaryIndex == null ? consumed : boundaryIndex + 1;
if (nonWhitespacePattern.test(fileText)) {
return fileText;
}
@@ -511,6 +602,7 @@ function createStreamingPatchFileSplitter() {
return {
push(text: string) {
if (text.length > 0) {
compactConsumedPrefix();
buffer += text;
}
},
@@ -522,18 +614,31 @@ function createStreamingPatchFileSplitter() {
}
if (!nonWhitespacePattern.test(buffer)) {
buffer = "";
consumed = 0;
return {};
}
if (!sawGitBoundary) {
const fallbackPatchContent = buffer;
const fallbackPatchContent = buffer.slice(consumed);
buffer = "";
consumed = 0;
return { fallbackPatchContent };
}
const trailingFileText = buffer;
const trailingFileText = buffer.slice(consumed);
buffer = "";
consumed = 0;
return { fileText: trailingFileText };
},
};
function compactConsumedPrefix(): void {
if (consumed === 0 || consumed < 256 * 1024 || consumed * 2 < buffer.length) {
return;
}
buffer = buffer.slice(consumed);
boundaryIndex = boundaryIndex == null ? undefined : Math.max(0, boundaryIndex - consumed);
searchStart = Math.max(0, searchStart - consumed);
consumed = 0;
}
}
async function drainPatchFileSplitter(
@@ -552,14 +657,16 @@ function commitMetadataBoundaryIndex(text: string, start: number, end: number):
if (minimum >= maximum) {
return undefined;
}
let index = text.lastIndexOf("\nFrom ", maximum - 1);
const searchStart = Math.max(0, minimum - 1);
const window = text.slice(searchStart, maximum);
let index = window.indexOf("\nFrom ");
while (index !== -1) {
const boundary = index + 1;
if (boundary < minimum) {
const boundary = searchStart + index + 1;
if (boundary >= maximum) {
return undefined;
}
if (boundary >= maximum) {
index = text.lastIndexOf("\nFrom ", index - 1);
if (boundary < minimum) {
index = window.indexOf("\nFrom ", index + 1);
continue;
}
const lineEnd = text.indexOf("\n", boundary + 1);
@@ -567,7 +674,7 @@ function commitMetadataBoundaryIndex(text: string, start: number, end: number):
if (commitMetadataPattern.test(line)) {
return boundary;
}
index = text.lastIndexOf("\nFrom ", index - 1);
index = window.indexOf("\nFrom ", index + 1);
}
return undefined;
}
@@ -585,8 +692,8 @@ function nextGitBoundaryIndex(text: string, start: number): number | undefined {
const gitMarker = "diff --git ";
const gitMarkerWithNewline = "\n" + gitMarker;
const offset = Math.max(start, 0);
if (offset === 0 && text.startsWith(gitMarker)) {
return 0;
if (text.startsWith(gitMarker, offset)) {
return offset;
}
const index = text.indexOf(gitMarkerWithNewline, offset);
return index === -1 ? undefined : index + 1;
@@ -604,6 +711,70 @@ export function fileName(fileDiff: any, fallback = "Untitled"): string {
return fileDiff.name ?? fileDiff.newName ?? fileDiff.oldName ?? fileDiff.prevName ?? fallback;
}
function normalizeGitFileDiffPaths(fileDiff: any): void {
if (fileDiff == null || typeof fileDiff !== "object") {
return;
}
for (const key of ["name", "newName", "oldName", "prevName"] as const) {
if (typeof fileDiff[key] === "string") {
fileDiff[key] = decodeGitQuotedPath(fileDiff[key]);
}
}
}
export function decodeGitQuotedPath(path: string): string {
if (!path.includes("\\")) {
return path;
}
const bytes: number[] = [];
const encoder = new TextEncoder();
const appendText = (text: string) => bytes.push(...encoder.encode(text));
const namedEscapes: Record<string, number> = {
"a": 0x07,
"b": 0x08,
"f": 0x0c,
"n": 0x0a,
"r": 0x0d,
"t": 0x09,
"v": 0x0b,
"\\": 0x5c,
"\"": 0x22,
};
for (let index = 0; index < path.length;) {
const codePoint = path.codePointAt(index);
if (codePoint == null) {
break;
}
const character = String.fromCodePoint(codePoint);
if (character !== "\\" || index + 1 >= path.length) {
appendText(character);
index += character.length;
continue;
}
const escaped = path[index + 1];
if (escaped != null && /[0-7]/.test(escaped)) {
let octal = escaped;
let cursor = index + 2;
while (octal.length < 3 && cursor < path.length && /[0-7]/.test(path[cursor] ?? "")) {
octal += path[cursor];
cursor += 1;
}
bytes.push(Number.parseInt(octal, 8));
index = cursor;
continue;
}
const escapedByte = escaped == null ? undefined : namedEscapes[escaped];
if (escapedByte != null) {
bytes.push(escapedByte);
index += 2;
continue;
}
appendText("\\");
index += 1;
}
return new TextDecoder().decode(Uint8Array.from(bytes));
}
export function fileStats(fileDiff: any): FileStats {
const stats = { added: 0, deleted: 0 };
for (const hunk of fileDiff.hunks ?? []) {
+43
View File
@@ -0,0 +1,43 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type BranchChangeRequest = { groupId: string, repoRoot: string, baseRef: string, capabilityToken: string, };
export type BranchListRequest = { repoRoot: string, capabilityToken: string, selectedBase: string | null, };
export type BranchListResult = { groups: Array<BranchPickerGroup>, };
export type BranchPickerConfidence = "high" | "low";
export type BranchPickerGroup = { id: string, label: string, rows: Array<BranchPickerRow>, };
export type BranchPickerRow = { ref: string, label: string, secondary?: string, reason?: string, confidence?: BranchPickerConfidence, current?: boolean, worktreeDir?: string, };
export type DiffEvent = { "type": "sessionStatus", sessionId: string, status: DiffSessionStatus, } | { "type": "patchReady", sessionId: string, patch: DiffResourceRef, } | { "type": "sessionFailed", sessionId: string, error: DiffProtocolError, };
export type DiffProtocolError = { code: string, message: string, };
export type DiffRequest = { id: string, version: number, } & ({ "method": "protocolHandshake" } | { "method": "sessionOpen", "params": OpenSessionRequest } | { "method": "sessionClose", "params": SessionRequest } | { "method": "branchList", "params": BranchListRequest } | { "method": "branchChange", "params": BranchChangeRequest });
export type DiffResourceRef = { id: string, mediaType: string, byteLength: number | null, revision: number, };
export type DiffResponse = { id: string, version: number, result: DiffResult | null, error: DiffProtocolError | null, };
export type DiffResult = { "type": "handshake", "value": HandshakeResult } | { "type": "sessionOpened", "value": SessionOpened } | { "type": "sessionClosed" } | { "type": "branches", "value": BranchListResult } | { "type": "navigation", "value": NavigationResult };
export type DiffSessionStatus = "opening" | "ready" | "closed";
export type DiffSource = { "kind": "patch", path: string, } | { "kind": "unstaged", repoRoot: string, } | { "kind": "staged", repoRoot: string, } | { "kind": "branch", repoRoot: string, baseRef?: string, };
export type DiffTransportConfig = { kind: DiffTransportKind, endpoint: string, protocolVersion: number, };
export type DiffTransportKind = "fetch" | "webSocket" | "webKit";
export type HandshakeResult = { protocolVersion: number, capabilities: Array<string>, };
export type NavigationResult = { url: string, };
export type OpenSessionRequest = { source: DiffSource, capabilityToken: string, sessionId?: string, };
export type SessionOpened = { sessionId: string, patch: DiffResourceRef, source: DiffSource, };
export type SessionRequest = { sessionId: string, capabilityToken: string, };
+243
View File
@@ -0,0 +1,243 @@
import type {
DiffEvent,
DiffRequest,
DiffResourceRef,
DiffResult,
DiffResponse,
DiffTransportConfig,
} from "./generated/protocol";
type WithoutEnvelope<T> = T extends unknown ? Omit<T, "id" | "version"> : never;
type DiffCommand = WithoutEnvelope<DiffRequest>;
type DiffEventListener = (event: DiffEvent) => void;
declare global {
interface Window {
cmuxDiffBridge?: {
receive(event: DiffEvent): void;
};
}
}
export interface DiffTransport {
request(command: DiffCommand): Promise<DiffResult>;
subscribe(listener: DiffEventListener): () => void;
openResource(ref: DiffResourceRef): Promise<Response>;
close(): void;
}
abstract class BaseDiffTransport implements DiffTransport {
protected readonly version: number;
private readonly listeners = new Set<DiffEventListener>();
constructor(version: number) {
this.version = version;
}
abstract request(command: DiffCommand): Promise<DiffResult>;
subscribe(listener: DiffEventListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
openResource(ref: DiffResourceRef): Promise<Response> {
return fetch(ref.id, { cache: "no-store" });
}
close(): void {}
protected receive(event: DiffEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}
protected makeRequest(command: DiffCommand): DiffRequest {
return {
id: makeRequestId(),
version: this.version,
...command,
} as DiffRequest;
}
protected unwrap(response: DiffResponse): DiffResult {
if (response.error) {
throw new DiffTransportError(response.error.code, response.error.message);
}
if (!response.result) {
throw new DiffTransportError("missingResult", "Diff transport returned no result");
}
return response.result;
}
}
export class FetchDiffTransport extends BaseDiffTransport {
private readonly endpoint: string;
constructor(endpoint: string, version: number) {
super(version);
this.endpoint = endpoint;
}
async request(command: DiffCommand): Promise<DiffResult> {
const response = await fetch(this.endpoint, {
method: "POST",
cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.makeRequest(command)),
});
if (!response.ok) {
throw new DiffTransportError("requestFailed", `Diff transport request failed (${response.status})`);
}
return this.unwrap((await response.json()) as DiffResponse);
}
}
export class WebKitDiffTransport extends BaseDiffTransport {
private readonly handler: NonNullable<NonNullable<NonNullable<Window["webkit"]>["messageHandlers"]>["cmuxDiff"]>;
constructor(
handler: NonNullable<NonNullable<NonNullable<Window["webkit"]>["messageHandlers"]>["cmuxDiff"]>,
version: number,
) {
super(version);
this.handler = handler;
window.cmuxDiffBridge = { receive: (event) => this.receive(event) };
}
async request(command: DiffCommand): Promise<DiffResult> {
return this.unwrap(await this.handler.postMessage(this.makeRequest(command)));
}
override close(): void {
delete window.cmuxDiffBridge;
}
}
export class WebSocketDiffTransport extends BaseDiffTransport {
private readonly endpoint: string;
private socket: WebSocket | null = null;
private connecting: Promise<WebSocket> | null = null;
private readonly pending = new Map<
string,
{ resolve: (result: DiffResult) => void; reject: (error: Error) => void }
>();
constructor(endpoint: string, version: number) {
super(version);
this.endpoint = endpoint;
}
async request(command: DiffCommand): Promise<DiffResult> {
const socket = await this.connect();
const request = this.makeRequest(command);
return new Promise<DiffResult>((resolve, reject) => {
this.pending.set(request.id, { resolve, reject });
socket.send(JSON.stringify(request));
});
}
override close(): void {
this.socket?.close();
this.socket = null;
this.connecting = null;
this.rejectPending(new DiffTransportError("closed", "Diff transport closed"));
}
private connect(): Promise<WebSocket> {
if (this.socket?.readyState === WebSocket.OPEN) {
return Promise.resolve(this.socket);
}
if (this.connecting) {
return this.connecting;
}
this.connecting = new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(this.endpoint);
socket.addEventListener("open", () => {
this.socket = socket;
this.connecting = null;
resolve(socket);
}, { once: true });
socket.addEventListener("message", (message) => this.handleMessage(message));
socket.addEventListener("close", () => {
this.socket = null;
this.connecting = null;
this.rejectPending(new DiffTransportError("closed", "Diff transport closed"));
});
socket.addEventListener("error", () => {
this.connecting = null;
reject(new DiffTransportError("connectFailed", "Could not connect to diff transport"));
}, { once: true });
});
return this.connecting;
}
private handleMessage(message: MessageEvent): void {
if (typeof message.data !== "string") {
return;
}
const decoded = JSON.parse(message.data) as DiffResponse | DiffEvent;
if ("id" in decoded) {
const pending = this.pending.get(decoded.id);
if (!pending) {
return;
}
this.pending.delete(decoded.id);
try {
pending.resolve(this.unwrap(decoded));
} catch (error) {
pending.reject(error instanceof Error ? error : new Error(String(error)));
}
return;
}
this.receive(decoded);
}
private rejectPending(error: Error): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
}
}
export function createDiffTransport(config: DiffTransportConfig | undefined): DiffTransport | null {
if (!config) {
return null;
}
const webKitHandler = window.webkit?.messageHandlers?.cmuxDiff;
if (config.kind === "webKit" && webKitHandler) {
return new WebKitDiffTransport(webKitHandler, config.protocolVersion);
}
if (config.kind === "webSocket") {
return new WebSocketDiffTransport(config.endpoint, config.protocolVersion);
}
if (config.kind === "fetch") {
if (!supportsFetchTransport(window.location.protocol)) {
return null;
}
return new FetchDiffTransport(config.endpoint, config.protocolVersion);
}
return null;
}
export function supportsFetchTransport(protocol: string): boolean {
return protocol === "http:" || protocol === "https:";
}
export class DiffTransportError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "DiffTransportError";
this.code = code;
}
}
function makeRequestId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `diff-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
+5 -1
View File
@@ -1,7 +1,9 @@
export type FileTreeRefreshSource = {
pathCount?: number;
paths?: readonly string[];
previousRevision?: number;
previousSource?: FileTreeRefreshSource;
revision?: number;
};
export type FileTreeRefreshPlan =
@@ -43,7 +45,9 @@ export function planPierreFileTreeRefresh(
const previousPathCount = previousSource.pathCount ?? previousSource.paths?.length ?? 0;
const sourcePathCount = source.pathCount ?? paths.length;
const sourceFollowsPrevious = source.previousSource === previousSource;
const sourceFollowsPrevious = source.previousSource === previousSource || (
previousSource.revision != null && source.previousRevision === previousSource.revision
);
const canAppend = sourceFollowsPrevious || isPathPrefix(previousSource, source);
if (!canAppend || sourcePathCount < previousPathCount) {
+16
View File
@@ -1,5 +1,11 @@
import type { DiffResponse } from "./diff/generated/protocol";
export {};
type AgentSessionNativeReply =
| { ok: true; value: unknown }
| { ok: false; error?: { code?: string; userMessage?: string } };
declare global {
var CmuxViewerNavigation: {
install(options: {
@@ -25,5 +31,15 @@ declare global {
streamMetrics?: unknown;
workerPool?: unknown;
};
webkit?: {
messageHandlers?: {
agentSession?: {
postMessage(message: unknown): Promise<AgentSessionNativeReply>;
};
cmuxDiff?: {
postMessage(message: unknown): Promise<DiffResponse>;
};
};
};
}
}
+3
View File
@@ -1,11 +1,13 @@
const DEFAULT_DIFF_VIEWER_LABELS = {
additions: "Additions",
bars: "Bars",
binaryFile: "Binary file",
branchBase: "Branch base",
branchPickerCurrent: "current",
branchPickerBasePrefix: "Base:",
branchPickerComparing: "Comparing {head} against {base}",
branchPickerFilterPlaceholder: "Filter branches",
branchPickerGenerateFailed: "Could not generate the diff. Choose a branch to retry.",
branchPickerGenerating: "Generating diff against {ref}...",
branchPickerGroupBranches: "Branches",
branchPickerGroupRecent: "Recent",
@@ -45,6 +47,7 @@ const DEFAULT_DIFF_VIEWER_LABELS = {
jumpToFile: "Jump to file",
loadingDiff: "Loading diff...",
loadingRenderer: "Loading renderer...",
modeChange: "Mode {old} → {new}",
noFileDiffs: "No file diffs found in patch input.",
none: "None",
openSourceURL: "Open source URL",
+54 -1
View File
@@ -168,7 +168,8 @@ body {
#source-select,
#repo-select,
#base-select,
#jump-select {
#jump-select,
#jump-search-button {
appearance: none;
height: 24px;
min-width: 0;
@@ -213,6 +214,10 @@ body {
border-color: color-mix(in lab, var(--cmux-diff-fg) 24%, transparent);
background-color: color-mix(in lab, var(--cmux-diff-fg) 10%, transparent);
}
#jump-search-button:hover {
border-color: color-mix(in lab, var(--cmux-diff-fg) 24%, transparent);
background-color: color-mix(in lab, var(--cmux-diff-fg) 10%, transparent);
}
#source-select[hidden],
#repo-select[hidden],
#base-select[hidden],
@@ -228,6 +233,12 @@ body {
field-sizing: content;
min-width: 64px;
}
#jump-search-button {
min-width: 64px;
padding-right: 9px;
background-image: none;
cursor: pointer;
}
#repo-select {
/* Sized to its selected value like #source-select, with the HIGHEST shrink so
it is the first to ellipsize when the toolbar runs out of room. */
@@ -242,6 +253,7 @@ body {
#repo-select:focus,
#base-select:focus,
#jump-select:focus,
#jump-search-button:focus-visible,
.base-picker-button:focus-visible,
.base-picker-input:focus-visible,
.toolbar-icon:focus-visible,
@@ -756,6 +768,9 @@ body[data-files-hidden="true"] #files-sidebar {
body[data-status-only="true"] #files-sidebar {
display: none;
}
#files-sidebar-backdrop {
display: none;
}
#files-header {
position: relative;
z-index: 1;
@@ -937,6 +952,44 @@ body[data-loading="false"]:not([data-status-only="true"]) #loading-layer {
#files-sidebar {
display: none;
}
#app[data-file-search-open="true"] #files-sidebar-backdrop {
position: absolute;
inset: 0;
z-index: 44;
display: block;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
background: color-mix(in srgb, #000 38%, transparent);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
cursor: default;
}
#app[data-file-search-open="true"] #files-sidebar-backdrop:focus-visible {
outline: 2px solid var(--cmux-diff-accent);
outline-offset: -2px;
}
#app[data-file-search-open="true"] #files-sidebar {
display: flex;
position: absolute;
inset: 0 0 0 auto;
width: min(88vw, 360px);
max-width: calc(100% - 32px);
z-index: 45;
visibility: visible;
opacity: 1;
pointer-events: auto;
border-left: 1px solid var(--cmux-diff-border);
background: var(--cmux-diff-bg);
box-shadow: -18px 0 42px color-mix(in srgb, #000 34%, transparent);
isolation: isolate;
--cmux-diff-sidebar-bg: var(--cmux-diff-bg);
}
#app[data-file-search-open="true"] #files-resize-handle {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
#files-sidebar {
+2
View File
@@ -1,7 +1,9 @@
import type { DiffViewerAppearance } from "./appearance";
import type { DiffTransportConfig } from "./diff/generated/protocol";
export type DiffViewerPayload = {
appearance?: DiffViewerAppearance;
transport?: DiffTransportConfig;
externalURL?: string;
labels?: Record<string, string>;
layout?: "split" | "unified";
+90
View File
@@ -0,0 +1,90 @@
import { expect, test } from "bun:test";
import { JSDOM } from "jsdom";
import { renderToStaticMarkup } from "react-dom/server";
import {
closeFileSearch,
FilesSidebarBackdrop,
JumpSelect,
shouldDismissFileSearch,
} from "../src/App";
import type { DiffItem } from "../src/diff-stream";
import { createDiffViewerLabelResolver } from "../src/labels";
test("large diff navigation keeps the rendered DOM bounded", () => {
const items = Array.from({ length: 10_000 }, (_, index) => ({
id: `src/file-${index}.ts`,
type: "diff",
fileDiff: { name: `src/file-${index}.ts`, hunks: [] },
version: 0,
})) as DiffItem[];
const markup = renderToStaticMarkup(
<JumpSelect
items={items}
label={createDiffViewerLabelResolver(undefined)}
onJump={() => {}}
onOpenSearch={() => {}}
searchOpen={false}
selectedItemId=""
/>,
);
const dom = new JSDOM(markup);
expect(dom.window.document.querySelectorAll("option")).toHaveLength(0);
const searchButton = dom.window.document.querySelector('[aria-label="Jump to file"]');
expect(searchButton?.tagName).toBe("BUTTON");
expect(searchButton?.getAttribute("aria-controls")).toBe("files-sidebar");
expect(searchButton?.getAttribute("aria-expanded")).toBe("false");
expect(dom.window.document.querySelectorAll("*").length).toBeLessThan(10);
dom.window.close();
let openedSearch = false;
const control = JumpSelect({
items,
label: createDiffViewerLabelResolver(undefined),
onJump: () => {},
onOpenSearch: () => {
openedSearch = true;
},
searchOpen: false,
selectedItemId: "",
}) as any;
control.props.onClick();
expect(openedSearch).toBe(true);
});
test("mobile file drawer backdrop is an accessible close control", () => {
const label = createDiffViewerLabelResolver(undefined);
const markup = renderToStaticMarkup(
<FilesSidebarBackdrop label={label} onClose={() => {}} open={true} />,
);
const dom = new JSDOM(markup);
const backdrop = dom.window.document.getElementById("files-sidebar-backdrop");
expect(backdrop?.tagName).toBe("BUTTON");
expect(backdrop?.getAttribute("aria-controls")).toBe("files-sidebar");
expect(backdrop?.getAttribute("aria-label")).toBe("Hide file search");
dom.window.close();
let closed = false;
const control = FilesSidebarBackdrop({
label,
onClose: () => {
closed = true;
},
open: true,
}) as any;
control.props.onClick();
expect(closed).toBe(true);
expect(FilesSidebarBackdrop({ label, onClose: () => {}, open: false })).toBeNull();
});
test("mobile file drawer dismisses Escape without changing wide search behavior", () => {
expect(shouldDismissFileSearch("Escape", true)).toBe(true);
expect(shouldDismissFileSearch("Escape", false)).toBe(false);
expect(shouldDismissFileSearch("Enter", true)).toBe(false);
const dom = new JSDOM('<button id="jump-search-button">Jump</button>');
const actions: any[] = [];
closeFileSearch((action) => actions.push(action), dom.window.document);
expect(actions).toEqual([{ type: "set-file-search-open", open: false }]);
expect(dom.window.document.activeElement?.id).toBe("jump-search-button");
dom.window.close();
});
+421 -3
View File
@@ -10,7 +10,7 @@ type FetchMock = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respo
let root: Root | null = null;
let dom: JSDOM | null = null;
const originalGlobals = new Map<string, any>();
for (const key of ["window", "document", "navigator", "Element", "Node", "HTMLElement", "HTMLStyleElement", "customElements", "fetch"]) {
for (const key of ["window", "document", "navigator", "Element", "Node", "HTMLElement", "HTMLStyleElement", "customElements", "fetch", "requestAnimationFrame", "cancelAnimationFrame"]) {
originalGlobals.set(key, (globalThis as any)[key]);
}
@@ -58,6 +58,422 @@ test("App renders the React-owned shell without starting a patch fetch for statu
expect(fetched).toBe(false);
});
test("custom-scheme pending pages wait for native navigation without HTTP polling", () => {
dom = createDom("cmux-diff-viewer://0123456789abcdef/opening.html");
let fetched = false;
installDomGlobals(dom, () => {
fetched = true;
throw new Error("unexpected fetch");
});
renderApp(
<App
config={{
payload: {
pendingReplacement: true,
statusMessage: "Loading diff",
title: "Diff",
},
}}
initialStatus={createDiffViewerStatus("Loading diff", { loading: true, pending: true })}
/>,
);
expect(dom.window.document.getElementById("status-text")?.textContent).toBe("Loading diff");
expect(fetched).toBe(false);
expect(dom.window.document.documentElement.dataset.cmuxDiffWait).toBeUndefined();
});
test("custom-scheme pending pages stream exactly one typed Rust session", async () => {
dom = createDom("cmux-diff-viewer://0123456789abcdef/branch.html");
const requests: any[] = [];
const commentRequests: any[] = [];
const fetched: string[] = [];
let releaseSecondSession: (() => void) | undefined;
installDomGlobals(dom, (input) => {
fetched.push(String(input));
return new Response("", { status: 200 });
});
(dom.window as any).webkit = {
messageHandlers: {
cmuxDiff: {
async postMessage(request: any) {
requests.push(request);
if (request.method === "sessionClose") {
return { id: request.id, version: 1, result: { type: "sessionClosed" }, error: null };
}
if (requests.filter((candidate) => candidate.method === "sessionOpen").length === 2) {
await new Promise<void>((resolve) => {
releaseSecondSession = resolve;
});
}
return {
id: request.id,
version: 1,
result: {
type: "sessionOpened",
value: {
sessionId: "01234567-89ab-cdef-0123-456789abcdef",
patch: {
id: "cmux-diff-viewer://0123456789abcdef/diff-session.patch",
mediaType: "text/x-diff",
byteLength: 128,
revision: 1,
},
source: request.params.source,
},
},
error: null,
};
},
},
cmuxDiffComments: {
async postMessage(request: any) {
commentRequests.push(request);
return { ok: true, value: { comments: [] } };
},
},
},
};
renderApp(
<App
config={{
payload: {
capabilityToken: "0123456789abcdef",
pendingReplacement: true,
sessionSource: { kind: "branch", repoRoot: "/tmp/repo", baseRef: "main" },
sourceOptions: [
{ label: "Branch", selected: true, sessionSource: { kind: "branch", repoRoot: "/tmp/repo", baseRef: "main" }, value: "branch" },
{ label: "Unstaged", selected: false, sessionSource: { kind: "unstaged", repoRoot: "/tmp/repo" }, value: "unstaged" },
{ label: "Last turn", selected: false, sessionSource: { kind: "patch", path: "/last-turn.patch" }, value: "last-turn" },
],
repoOptions: [
{ label: "repo", selected: true, sessionSource: { kind: "branch", repoRoot: "/tmp/repo", baseRef: "main" }, value: "/tmp/repo" },
{ label: "other-repo", selected: false, sessionSource: { kind: "branch", repoRoot: "/tmp/other-repo" }, value: "/tmp/other-repo" },
],
statusMessage: "Loading diff",
title: "Diff",
transport: { kind: "webKit", endpoint: "cmuxDiff", protocolVersion: 1 },
},
}}
initialStatus={createDiffViewerStatus("Loading diff", { loading: true, pending: true })}
/>,
);
await waitFor(() => dom?.window.document.body.dataset.streamFileCount === "0");
expect(requests.filter((request) => request.method === "sessionOpen")).toHaveLength(1);
await waitFor(() => commentRequests.length === 1);
expect(commentRequests[0].params.repoRoot).toBe("/tmp/repo");
expect(requests[0].params.source).toEqual({ kind: "branch", repoRoot: "/tmp/repo", baseRef: "main" });
expect(fetched).toEqual(["cmux-diff-viewer://0123456789abcdef/diff-session.patch"]);
expect(requests.filter((request) => request.method === "sessionClose")).toHaveLength(0);
const repoSelect = dom.window.document.getElementById("repo-select") as HTMLSelectElement;
repoSelect.value = "/tmp/other-repo";
repoSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 2);
dom.window.document.getElementById("options-button")?.click();
await waitFor(() => Boolean(copyGitApplyButton()));
copyGitApplyButton()?.click();
await waitFor(() => dom?.window.document.getElementById("copy-feedback")?.textContent === "Could not copy git apply command.");
releaseSecondSession?.();
await waitFor(() => fetched.length === 2);
expect(requests.filter((request) => request.method === "sessionOpen")[1].params.source)
.toEqual({ kind: "branch", repoRoot: "/tmp/other-repo" });
const sourceSelect = dom.window.document.getElementById("source-select") as HTMLSelectElement;
sourceSelect.value = "unstaged";
sourceSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 3);
await waitFor(() => fetched.length === 3);
expect(requests.filter((request) => request.method === "sessionOpen")[2].params.source)
.toEqual({ kind: "unstaged", repoRoot: "/tmp/other-repo" });
repoSelect.value = "/tmp/repo";
repoSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 4);
await waitFor(() => fetched.length === 4);
expect(requests.filter((request) => request.method === "sessionOpen")[3].params.source)
.toEqual({ kind: "unstaged", repoRoot: "/tmp/repo" });
sourceSelect.value = "last-turn";
sourceSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 5);
await waitFor(() => fetched.length === 5);
expect(requests.filter((request) => request.method === "sessionOpen")[4].params.source)
.toEqual({ kind: "patch", path: "/last-turn.patch" });
const closeCountBeforePageHide = requests.filter((request) => request.method === "sessionClose").length;
dom.window.dispatchEvent(new dom.window.Event("pagehide"));
await waitFor(() => requests.filter((request) => request.method === "sessionClose").length > closeCountBeforePageHide);
flushSync(() => root?.unmount());
root = null;
expect(requests.filter((request) => request.method === "sessionClose").length)
.toBeGreaterThan(closeCountBeforePageHide);
});
test("typed Rust empty diffs keep the localized source-specific message", async () => {
dom = createDom("cmux-diff-viewer://0123456789abcdef/unstaged.html");
let fetched = false;
installDomGlobals(dom, () => {
fetched = true;
return new Response("", { status: 200 });
});
(dom.window as any).webkit = {
messageHandlers: {
cmuxDiff: {
async postMessage(request: any) {
return {
id: request.id,
version: 1,
result: null,
error: { code: "emptyDiff", message: "No changes to diff" },
};
},
},
},
};
renderApp(
<App
config={{
payload: {
capabilityToken: "0123456789abcdef",
emptyMessage: "No unstaged changes to diff.",
pendingReplacement: true,
sessionSource: { kind: "unstaged", repoRoot: "/tmp/repo" },
statusMessage: "Loading diff",
transport: { kind: "webKit", endpoint: "cmuxDiff", protocolVersion: 1 },
},
}}
initialStatus={createDiffViewerStatus("Loading diff", { loading: true, pending: true })}
/>,
);
await waitFor(() => dom?.window.document.getElementById("status-text")?.textContent === "No unstaged changes to diff.");
expect(fetched).toBe(false);
});
test("typed branch empty diffs keep the base picker available before base resolution", async () => {
dom = createDom("cmux-diff-viewer://0123456789abcdef/branch.html");
installDomGlobals(dom, () => new Response("", { status: 200 }));
(dom.window as any).webkit = {
messageHandlers: {
cmuxDiff: {
async postMessage(request: any) {
if (request.method === "sessionOpen") {
return {
id: request.id,
version: 1,
result: null,
error: { code: "emptyDiff", message: "No changes to diff" },
};
}
return {
id: request.id,
version: 1,
result: { type: "branches", value: { groups: [] } },
error: null,
};
},
},
},
};
renderApp(
<App
config={{
payload: {
capabilityToken: "0123456789abcdef",
emptyMessage: "No branch changes to diff.",
sessionSource: { kind: "branch", repoRoot: "/tmp/repo" },
transport: { kind: "webKit", endpoint: "cmuxDiff", protocolVersion: 1 },
},
}}
initialStatus={createDiffViewerStatus("Loading diff", { loading: true })}
/>,
);
await waitFor(() => dom?.window.document.getElementById("status-text")?.textContent === "No branch changes to diff.");
expect(dom.window.document.querySelector(".base-picker-button")).toBeTruthy();
});
test("typed source switching preserves the last resolved branch base", async () => {
dom = createDom("cmux-diff-viewer://0123456789abcdef/branch.html");
const requests: any[] = [];
installDomGlobals(dom, () => new Response("", { status: 200 }));
(dom.window as any).webkit = {
messageHandlers: {
cmuxDiff: {
async postMessage(request: any) {
requests.push(request);
if (request.method === "sessionClose") {
return { id: request.id, version: 1, result: { type: "sessionClosed" }, error: null };
}
const source = request.params.source.kind === "branch"
? {
...request.params.source,
baseRef: request.params.source.baseRef
?? (request.params.source.repoRoot === "/tmp/other-repo" ? "other-base" : "chosen-base"),
}
: request.params.source;
return {
id: request.id,
version: 1,
result: {
type: "sessionOpened",
value: {
sessionId: crypto.randomUUID(),
patch: { id: "/diff.patch", mediaType: "text/x-diff", byteLength: 23, revision: 1 },
source,
},
},
error: null,
};
},
},
},
};
renderApp(
<App
config={{ payload: {
capabilityToken: "0123456789abcdef",
sessionSource: { kind: "branch", repoRoot: "/tmp/repo" },
sourceOptions: [
{ label: "Branch", selected: true, sessionSource: { kind: "branch", repoRoot: "/tmp/repo" }, value: "branch" },
{ label: "Unstaged", selected: false, sessionSource: { kind: "unstaged", repoRoot: "/tmp/repo" }, value: "unstaged" },
],
repoOptions: [
{ label: "repo", selected: true, sessionSource: { kind: "branch", repoRoot: "/tmp/repo" }, value: "/tmp/repo" },
{ label: "other-repo", selected: false, sessionSource: { kind: "branch", repoRoot: "/tmp/other-repo" }, value: "/tmp/other-repo" },
],
transport: { kind: "webKit", endpoint: "cmuxDiff", protocolVersion: 1 },
} }}
initialStatus={createDiffViewerStatus("Loading diff", { loading: true })}
/>,
);
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 1);
await waitFor(() => dom?.window.document.querySelector(".base-picker-button")?.textContent?.includes("chosen-base") === true);
const sourceSelect = dom.window.document.getElementById("source-select") as HTMLSelectElement;
sourceSelect.value = "unstaged";
sourceSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 2);
sourceSelect.value = "branch";
sourceSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 3);
expect(requests.filter((request) => request.method === "sessionOpen")[2].params.source)
.toEqual({ kind: "branch", repoRoot: "/tmp/repo", baseRef: "chosen-base" });
const repoSelect = dom.window.document.getElementById("repo-select") as HTMLSelectElement;
repoSelect.value = "/tmp/other-repo";
repoSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 4);
await waitFor(() => dom?.window.document.querySelector(".base-picker-button")?.textContent?.includes("other-base") === true);
repoSelect.value = "/tmp/repo";
repoSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 5);
expect(requests.filter((request) => request.method === "sessionOpen")[4].params.source)
.toEqual({ kind: "branch", repoRoot: "/tmp/repo", baseRef: "chosen-base" });
});
test("pagehide cancels a typed session while its initial open is pending", async () => {
dom = createDom("cmux-diff-viewer://0123456789abcdef/unstaged.html");
const requests: any[] = [];
installDomGlobals(dom, () => new Response("", { status: 200 }));
(dom.window as any).webkit = {
messageHandlers: {
cmuxDiff: {
async postMessage(request: any) {
requests.push(request);
if (request.method === "sessionClose") {
return { id: request.id, version: 1, result: { type: "sessionClosed" }, error: null };
}
await new Promise<void>(() => {});
},
},
},
};
renderApp(
<App
config={{ payload: {
capabilityToken: "0123456789abcdef",
sessionSource: { kind: "unstaged", repoRoot: "/tmp/repo" },
transport: { kind: "webKit", endpoint: "cmuxDiff", protocolVersion: 1 },
} }}
initialStatus={createDiffViewerStatus("Loading diff", { loading: true })}
/>,
);
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 1);
dom.window.dispatchEvent(new dom.window.Event("pagehide"));
await waitFor(() => requests.filter((request) => request.method === "sessionClose").length === 1);
expect(requests.find((request) => request.method === "sessionClose").params.sessionId)
.toBe("00000000-0000-0000-0000-000000000000");
});
test("Last Turn reveals repo selection after switching to a typed git source", async () => {
dom = createDom("cmux-diff-viewer://0123456789abcdef/last-turn.html");
const requests: any[] = [];
installDomGlobals(dom, () => new Response("", { status: 200 }));
(dom.window as any).webkit = {
messageHandlers: {
cmuxDiff: {
async postMessage(request: any) {
requests.push(request);
if (request.method === "sessionClose") {
return { id: request.id, version: 1, result: { type: "sessionClosed" }, error: null };
}
return {
id: request.id,
version: 1,
result: {
type: "sessionOpened",
value: {
sessionId: "01234567-89ab-cdef-0123-456789abcdef",
patch: { id: "/session.patch", mediaType: "text/x-diff", byteLength: 1, revision: 1 },
source: request.params.source,
},
},
error: null,
};
},
},
},
};
renderApp(
<App
config={{ payload: {
capabilityToken: "0123456789abcdef",
sessionSource: { kind: "patch", path: "/last-turn.patch" },
sourceOptions: [
{ label: "Last turn", selected: true, sessionSource: { kind: "patch", path: "/last-turn.patch" }, value: "last-turn" },
{ label: "Unstaged", selected: false, sessionSource: { kind: "unstaged", repoRoot: "/tmp/repo" }, value: "unstaged" },
],
repoOptions: [
{ label: "repo", selected: true, sessionSource: { kind: "unstaged", repoRoot: "/tmp/repo" }, value: "/tmp/repo" },
{ label: "other", selected: false, sessionSource: { kind: "unstaged", repoRoot: "/tmp/other" }, value: "/tmp/other" },
],
transport: { kind: "webKit", endpoint: "cmuxDiff", protocolVersion: 1 },
} }}
initialStatus={createDiffViewerStatus("Loading diff", { loading: true })}
/>,
);
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 1);
expect(dom.window.document.getElementById("repo-select")).toBeNull();
const sourceSelect = dom.window.document.getElementById("source-select") as HTMLSelectElement;
sourceSelect.value = "unstaged";
sourceSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 2);
const repoSelect = dom.window.document.getElementById("repo-select") as HTMLSelectElement;
expect(repoSelect).toBeTruthy();
repoSelect.value = "/tmp/other";
repoSelect.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
await waitFor(() => requests.filter((request) => request.method === "sessionOpen").length === 3);
expect(requests.filter((request) => request.method === "sessionOpen")[2].params.source)
.toEqual({ kind: "unstaged", repoRoot: "/tmp/other" });
});
test("App still starts diff rendering when statusMessage is an empty string", async () => {
dom = createDom();
let fetchCount = 0;
@@ -248,9 +664,9 @@ test("native viewer navigation remains installed after an unrelated render", asy
await waitFor(() => dom?.window.document.getElementById("file-search-toggle")?.getAttribute("aria-pressed") === "true");
});
function createDom(): JSDOM {
function createDom(url = "http://127.0.0.1/diff"): JSDOM {
return new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
url: "http://127.0.0.1/diff",
url,
});
}
@@ -264,6 +680,8 @@ function installDomGlobals(nextDom: JSDOM, fetchImpl: FetchMock): void {
(globalThis as any).HTMLStyleElement = nextDom.window.HTMLStyleElement;
(globalThis as any).customElements = nextDom.window.customElements;
(globalThis as any).fetch = fetchImpl;
(globalThis as any).requestAnimationFrame = (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0);
(globalThis as any).cancelAnimationFrame = (handle: number) => clearTimeout(handle);
}
function renderApp(element: React.ReactNode): void {
+143 -1
View File
@@ -2,7 +2,8 @@ import { afterEach, expect, test } from "bun:test";
import { JSDOM } from "jsdom";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { BranchBasePicker, buildFlatRows, toCurrentOriginRelative, type BranchPickerPayload } from "../src/BranchBasePicker";
import { BranchBasePicker, branchPickerStateKey, buildFlatRows, toCurrentOriginRelative, type BranchPickerPayload } from "../src/BranchBasePicker";
import type { DiffTransport } from "../src/diff/transport";
import { createDiffViewerLabelResolver } from "../src/labels";
// Behavior coverage for the render cap (huge refs lists must not render every
@@ -76,6 +77,64 @@ test("base picker caps a huge remotes group and shows a type-to-filter affordanc
expect(more?.textContent).toContain("2296 more, type to filter");
});
test("switching repositories remounts the picker and ignores an older refs load", async () => {
dom = createDom();
installDomGlobals(dom);
const completions = new Map<string, (response: Response) => void>();
(globalThis as any).fetch = (input: RequestInfo | URL) => new Promise<Response>((resolve) => {
completions.set(String(input), resolve);
});
const first = { ...pickerPayload(0), repoRoot: "/tmp/first", capabilityToken: "first", refsURL: "/first" };
const second = { ...pickerPayload(0), repoRoot: "/tmp/second", capabilityToken: "second", refsURL: "/second" };
const render = (picker: BranchPickerPayload) => {
flushSync(() => {
root?.render(
<BranchBasePicker
key={branchPickerStateKey(picker)}
label={label}
onNavigate={() => {}}
picker={picker}
/>,
);
});
};
root = createRoot(document.getElementById("root")!);
render(first);
document.querySelector<HTMLButtonElement>(".base-picker-button")?.click();
await waitFor(() => completions.has("/first"));
render(second);
document.querySelector<HTMLButtonElement>(".base-picker-button")?.click();
await waitFor(() => completions.has("/second"));
completions.get("/second")?.(new Response(JSON.stringify({
groups: [{ id: "suggested", label: "Suggested", rows: [{ ref: "second-ref", label: "second-ref" }] }],
}), { status: 200 }));
await waitFor(() => document.body.textContent?.includes("second-ref") === true);
let staleResponseRead = false;
const staleResponse = new Response(null, { status: 200 });
staleResponse.json = async () => {
staleResponseRead = true;
return {
groups: [{ id: "suggested", label: "Suggested", rows: [{ ref: "stale-ref", label: "stale-ref" }] }],
};
};
completions.get("/first")?.(staleResponse);
await waitFor(() => staleResponseRead);
expect(document.body.textContent).toContain("second-ref");
expect(document.body.textContent).not.toContain("stale-ref");
const changedBase = { ...second, currentRef: "new-base", refsURL: "/changed-base" };
render(changedBase);
document.querySelector<HTMLButtonElement>(".base-picker-button")?.click();
await waitFor(() => completions.has("/changed-base"));
completions.get("/changed-base")?.(new Response(JSON.stringify({
groups: [{ id: "suggested", label: "Suggested", rows: [{ ref: "new-base-ref", label: "new-base-ref" }] }],
}), { status: 200 }));
await waitFor(() => document.body.textContent?.includes("new-base-ref") === true);
expect(document.body.textContent).not.toContain("second-ref");
});
test("button renders the head -> base comparison with the base as the bold ref", () => {
dom = createDom();
installDomGlobals(dom);
@@ -202,6 +261,89 @@ test("selecting a ref navigates to a root-relative regenerate URL", async () =>
expect(navigated[0]).toBe("/__cmux_diff_viewer_branch?group=g&repo=%2Ftmp%2Fmock&token=abc&base=develop");
});
test("selecting the active base closes without regenerating the same URL", async () => {
dom = createDom();
installDomGlobals(dom);
const navigated: string[] = [];
const picker = pickerPayload(0);
const container = document.getElementById("root");
root = createRoot(container!);
flushSync(() => {
root?.render(<BranchBasePicker label={label} onNavigate={(url) => navigated.push(url)} picker={picker} />);
});
document.querySelector<HTMLButtonElement>(".base-picker-button")?.click();
await waitFor(() => rowCount() > 0);
flushSync(() => {
document.querySelector<HTMLElement>(".base-picker-row")?.dispatchEvent(
new dom!.window.MouseEvent("mousedown", { bubbles: true, cancelable: true }),
);
});
expect(navigated).toEqual([]);
expect(document.querySelector(".base-picker-popover")).toBeNull();
expect(document.activeElement).toBe(document.querySelector(".base-picker-button"));
});
test("a failed branch regeneration leaves cached refs available for retry", async () => {
dom = createDom();
installDomGlobals(dom);
let changeAttempts = 0;
const navigated: string[] = [];
const transport: DiffTransport = {
request(command) {
if (command.method === "branchList") {
return Promise.resolve({
type: "branches",
value: {
groups: [{ id: "suggested", label: "Suggested", rows: [{ ref: "develop", label: "develop" }] }],
},
});
}
changeAttempts += 1;
return changeAttempts === 1
? Promise.reject(new Error("temporary sidecar failure"))
: Promise.resolve({ type: "navigation", value: { url: "/retry-succeeded" } });
},
subscribe: () => () => {},
openResource: () => Promise.reject(new Error("unused")),
close: () => {},
};
const picker: BranchPickerPayload = {
...pickerPayload(0),
groupId: "1234567890-group",
capabilityToken: "0123456789abcdef",
};
const container = document.getElementById("root");
root = createRoot(container!);
flushSync(() => {
root?.render(<BranchBasePicker label={label} onNavigate={(url) => navigated.push(url)} picker={picker} transport={transport} />);
});
document.querySelector<HTMLButtonElement>(".base-picker-button")?.click();
await waitFor(() => rowCount() === 1);
flushSync(() => {
document.querySelector<HTMLElement>(".base-picker-row")?.dispatchEvent(
new dom!.window.MouseEvent("mousedown", { bubbles: true, cancelable: true }),
);
});
await waitFor(() => changeAttempts === 1);
await waitFor(() => rowCount() === 1);
expect(document.querySelector(".base-picker-status-error")?.textContent).toBe(
"Could not generate the diff. Choose a branch to retry.",
);
expect(document.activeElement).toBe(document.querySelector(".base-picker-input"));
flushSync(() => {
document.querySelector<HTMLElement>(".base-picker-row")?.dispatchEvent(
new dom!.window.MouseEvent("mousedown", { bubbles: true, cancelable: true }),
);
});
await waitFor(() => navigated.length === 1);
expect(changeAttempts).toBe(2);
expect(navigated).toEqual(["/retry-succeeded"]);
});
function createDom(): JSDOM {
return new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
url: "http://127.0.0.1/diff",
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "bun:test";
import { JSDOM } from "jsdom";
import { renderToStaticMarkup } from "react-dom/server";
import { annotateDiffMetadata, DiffHeaderMetadata, resolveDiffHeaderMetadata } from "../src/diff-metadata";
import { createDiffViewerLabelResolver } from "../src/labels";
test("binary and mode-only diffs render explicit localized header metadata", () => {
const binary = {
type: "change",
hunks: [],
prevObjectId: "1111111",
newObjectId: "2222222",
mode: "100644",
};
const mode = {
type: "change",
hunks: [],
prevMode: "100644",
mode: "100755",
};
annotateDiffMetadata(binary, "GIT binary patch\n");
annotateDiffMetadata(mode);
const label = createDiffViewerLabelResolver({
binaryFile: "Localized binary",
modeChange: "Permissions {old} to {new}",
});
expect(resolveDiffHeaderMetadata(binary, label)).toBe("Localized binary");
expect(resolveDiffHeaderMetadata(mode, label)).toBe("Permissions 100644 to 100755");
const html = renderToStaticMarkup(
<>
<DiffHeaderMetadata fileDiff={binary} label={label} />
<DiffHeaderMetadata fileDiff={mode} label={label} />
</>,
);
const dom = new JSDOM(`<div id="root">${html}</div>`);
const container = dom.window.document.getElementById("root")!;
expect(container.querySelector('[data-cmux-diff-metadata="binary"]')?.textContent).toBe("Localized binary");
expect(container.querySelector('[data-cmux-diff-metadata="mode"]')?.textContent).toBe("Permissions 100644 to 100755");
dom.window.close();
});
+331
View File
@@ -0,0 +1,331 @@
import { expect, test } from "bun:test";
import { chmod, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parsePatchFiles, processFile } from "@pierre/diffs";
import { decodeGitQuotedPath, streamPatch, type DiffItem, type FileTreeSource } from "../src/diff-stream";
import { createDiffViewerLabelResolver } from "../src/labels";
type GitFileSemantics = {
added: number | null;
deleted: number | null;
oldPath?: string;
path: string;
status: string;
};
test("production stream parser matches Git for real repository diff semantics", async () => {
const repo = await mkdtemp(join(tmpdir(), "cmux-diff-oracle-"));
try {
git(repo, ["init", "-q", "-b", "main"]);
git(repo, ["config", "user.name", "cmux diff oracle"]);
git(repo, ["config", "user.email", "[email protected]"]);
git(repo, ["config", "core.fileMode", "true"]);
await writeFile(join(repo, "modified.txt"), numberedLines(12));
await writeFile(join(repo, "delete.txt"), "gone\n");
await writeFile(join(repo, "pure-old.txt"), "same\nlines\nhere\n");
await writeFile(join(repo, "changed-old.txt"), numberedLines(20));
await writeFile(join(repo, "mode.txt"), "mode\n");
await writeFile(join(repo, "binary.bin"), Uint8Array.from([0, 1, 2, 3]));
await writeFile(join(repo, 'space ü "quote".txt'), "unicode\n");
await writeFile(join(repo, "no-newline.txt"), "old no newline");
await writeFile(join(repo, "scope.txt"), "base\n");
git(repo, ["add", "."]);
git(repo, ["commit", "-qm", "base"]);
const empty = await parseProductionStream(gitText(repo, diffArgs("--")));
expect(empty.items).toEqual([]);
expect(empty.treeSources).toEqual([]);
git(repo, ["switch", "-qc", "feature"]);
await writeFile(join(repo, "modified.txt"), numberedLines(12, new Map([[2, "two changed"], [10, "ten changed"]])));
await rm(join(repo, "delete.txt"));
await rename(join(repo, "pure-old.txt"), join(repo, "pure-new.txt"));
await rename(join(repo, "changed-old.txt"), join(repo, "changed-new.txt"));
await writeFile(join(repo, "changed-new.txt"), `${numberedLines(20)}21\n`);
await chmod(join(repo, "mode.txt"), 0o755);
await writeFile(join(repo, "binary.bin"), Uint8Array.from([0, 1, 9, 3, 4]));
await writeFile(join(repo, 'space ü "quote".txt'), "unicode\nunicode changed\n");
await writeFile(join(repo, "no-newline.txt"), "new no newline");
await writeFile(join(repo, "added.txt"), "new\n");
git(repo, ["add", "."]);
const stagedPatch = gitText(repo, diffArgs("--cached", "--"));
const staged = await parseProductionStream(stagedPatch, [1, 2, 5, 13, 29]);
const stagedOracle = readGitOracle(repo, ["--cached", "--"]);
expectParsedSemantics(staged.items, stagedOracle);
const wholeParsed = parsePatchFiles(stagedPatch, "whole").flatMap((parsedPatch) => parsedPatch.files ?? []);
expect(staged.items.map((item) => normalizedPierreFile(item.fileDiff))).toEqual(
wholeParsed.map(normalizedPierreFile),
);
expect(staged.treeSources.at(-1)?.paths).toEqual(stagedOracle.map((entry) => entry.path));
expect(staged.items.find((item) => item.fileDiff.name === "modified.txt")?.fileDiff.hunks).toHaveLength(2);
expect(staged.items.find((item) => item.fileDiff.name === "binary.bin")?.fileDiff).toMatchObject({
cmuxDiffMetadataKind: "binary",
hunks: [],
mode: "100644",
});
expect(staged.items.find((item) => item.fileDiff.name === "mode.txt")?.fileDiff).toMatchObject({
cmuxDiffMetadataKind: "mode",
mode: "100755",
prevMode: "100644",
});
expect(staged.items.find((item) => item.fileDiff.name === "no-newline.txt")?.fileDiff.hunks[0]).toMatchObject({
noEOFCRAdditions: true,
noEOFCRDeletions: true,
});
expect(staged.items.find((item) => item.fileDiff.name === 'space ü "quote".txt')).toBeDefined();
git(repo, ["commit", "-qm", "feature shapes"]);
await writeFile(join(repo, "scope.txt"), "base\nstaged\n");
git(repo, ["add", "scope.txt"]);
await writeFile(join(repo, "scope.txt"), "base\nstaged\nworking\n");
const stagedOnly = await parseProductionStream(gitText(repo, diffArgs("--cached", "--")));
const unstagedOnly = await parseProductionStream(gitText(repo, diffArgs("--")));
expectParsedSemantics(stagedOnly.items, readGitOracle(repo, ["--cached", "--"]));
expectParsedSemantics(unstagedOnly.items, readGitOracle(repo, ["--"]));
expect(stagedOnly.items.map((item) => item.fileDiff.additionLines.at(-1))).toEqual(["staged\n"]);
expect(unstagedOnly.items.map((item) => item.fileDiff.additionLines.at(-1))).toEqual(["working\n"]);
const mergeBase = gitText(repo, ["merge-base", "HEAD", "main"]).trim();
const branch = await parseProductionStream(gitText(repo, diffArgs(mergeBase, "--")));
expectParsedSemantics(branch.items, readGitOracle(repo, [mergeBase, "--"]));
expect(branch.items.some((item) => item.fileDiff.name === "added.txt")).toBe(true);
expect(branch.items.some((item) => item.fileDiff.name === "scope.txt")).toBe(true);
} finally {
await rm(repo, { force: true, recursive: true });
}
}, 30_000);
test("production stream parser handles mbox repeats, malformed input, and truncated final files", async () => {
const repo = await mkdtemp(join(tmpdir(), "cmux-diff-mbox-"));
try {
git(repo, ["init", "-q", "-b", "main"]);
git(repo, ["config", "user.name", "cmux diff oracle"]);
git(repo, ["config", "user.email", "[email protected]"]);
await writeFile(join(repo, "repeat.txt"), "base\n");
git(repo, ["add", "."]);
git(repo, ["commit", "-qm", "base"]);
await writeFile(join(repo, "repeat.txt"), "base\none\n");
git(repo, ["commit", "-qam", "one"]);
await writeFile(join(repo, "repeat.txt"), "base\none\ntwo\n");
git(repo, ["commit", "-qam", "two"]);
const mbox = gitText(repo, ["format-patch", "--stdout", "HEAD~2..HEAD"]);
const parsedMbox = await parseProductionStream(mbox, [3, 17, 41]);
expect(parsedMbox.items).toHaveLength(2);
expect(parsedMbox.items.map((item) => item.fileDiff.name)).toEqual(["repeat.txt", "repeat.txt"]);
expect(new Set(parsedMbox.items.map((item) => item.id)).size).toBe(2);
expect(parsedMbox.renames).toHaveLength(0);
expect(parsedMbox.treeSources.at(-1)?.paths).toHaveLength(2);
const malformed = await parseProductionStream("this is not a Git patch\n");
expect(malformed.items).toEqual([]);
const truncatedPatch = [
"diff --git a/truncated.txt b/truncated.txt",
"index 1111111..2222222 100644",
"--- a/truncated.txt",
"+++ b/truncated.txt",
"@@ -1 +1 @@",
"-before",
"+after",
].join("\n");
const truncated = await parseProductionStream(truncatedPatch, [1]);
expect(truncated.items).toHaveLength(1);
expect(truncated.items[0]?.fileDiff).toMatchObject({
name: "truncated.txt",
type: "change",
});
} finally {
await rm(repo, { force: true, recursive: true });
}
}, 30_000);
function diffArgs(...tail: string[]): string[] {
// Keep this byte-for-byte aligned with CLI/cmux_open.swift gitDiffPatchArguments.
return ["diff", "--no-ext-diff", "--no-color", "--binary", ...tail];
}
function git(repo: string, args: string[]): Uint8Array {
const result = Bun.spawnSync(["git", "-C", repo, ...args], {
env: { ...process.env, LC_ALL: "C" },
stdout: "pipe",
stderr: "pipe",
});
if (result.exitCode !== 0) {
throw new Error(`git ${args.join(" ")} failed: ${new TextDecoder().decode(result.stderr)}`);
}
return result.stdout;
}
function gitText(repo: string, args: string[]): string {
return new TextDecoder().decode(git(repo, args));
}
function readGitOracle(repo: string, tail: string[]): GitFileSemantics[] {
const nameStatus = splitNul(git(repo, ["diff", "--name-status", "-z", "--find-renames", ...tail]));
const stats = parseNumstat(git(repo, ["diff", "--numstat", "-z", "--find-renames", ...tail]));
const result: GitFileSemantics[] = [];
for (let index = 0; index < nameStatus.length;) {
const rawStatus = nameStatus[index++] ?? "";
const status = rawStatus[0] ?? "";
if (status === "R" || status === "C") {
const oldPath = nameStatus[index++] ?? "";
const path = nameStatus[index++] ?? "";
result.push({ ...stats.get(path)!, oldPath, path, status });
} else {
const path = nameStatus[index++] ?? "";
result.push({ ...stats.get(path)!, path, status });
}
}
return result;
}
function parseNumstat(output: Uint8Array): Map<string, Pick<GitFileSemantics, "added" | "deleted">> {
const fields = splitNul(output);
const result = new Map<string, Pick<GitFileSemantics, "added" | "deleted">>();
for (let index = 0; index < fields.length;) {
const header = fields[index++] ?? "";
const [rawAdded, rawDeleted, pathInHeader] = header.split("\t");
let path = pathInHeader ?? "";
if (path.length === 0) {
index += 1;
path = fields[index++] ?? "";
}
result.set(path, {
added: rawAdded === "-" ? null : Number(rawAdded),
deleted: rawDeleted === "-" ? null : Number(rawDeleted),
});
}
return result;
}
function splitNul(output: Uint8Array): string[] {
const fields = new TextDecoder().decode(output).split("\0");
if (fields.at(-1) === "") {
fields.pop();
}
return fields;
}
function expectParsedSemantics(items: DiffItem[], oracle: GitFileSemantics[]): void {
const actual = items.map((item) => {
const additions = item.fileDiff.hunks.reduce((sum: number, hunk: any) => sum + (hunk.additionLines ?? 0), 0);
const deletions = item.fileDiff.hunks.reduce((sum: number, hunk: any) => sum + (hunk.deletionLines ?? 0), 0);
return {
added: additions,
deleted: deletions,
oldPath: item.fileDiff.prevName,
path: item.fileDiff.name,
status: pierreStatus(item.fileDiff.type),
};
});
expect(actual).toEqual(oracle.map((entry) => ({
added: entry.added ?? 0,
deleted: entry.deleted ?? 0,
oldPath: entry.oldPath,
path: entry.path,
status: entry.status,
})));
}
function normalizedPierreFile(fileDiff: any): unknown {
const normalizePath = (value: unknown) => typeof value === "string" ? decodeGitQuotedPath(value) : value;
return {
additionLines: fileDiff.additionLines,
deletionLines: fileDiff.deletionLines,
hunks: (fileDiff.hunks ?? []).map((hunk: any) => ({
additionCount: hunk.additionCount,
additionLines: hunk.additionLines,
additionStart: hunk.additionStart,
deletionCount: hunk.deletionCount,
deletionLines: hunk.deletionLines,
deletionStart: hunk.deletionStart,
noEOFCRAdditions: hunk.noEOFCRAdditions,
noEOFCRDeletions: hunk.noEOFCRDeletions,
splitLineCount: hunk.splitLineCount,
splitLineStart: hunk.splitLineStart,
unifiedLineCount: hunk.unifiedLineCount,
unifiedLineStart: hunk.unifiedLineStart,
})),
mode: fileDiff.mode,
name: normalizePath(fileDiff.name),
newName: normalizePath(fileDiff.newName),
newObjectId: fileDiff.newObjectId,
oldName: normalizePath(fileDiff.oldName),
prevMode: fileDiff.prevMode,
prevName: normalizePath(fileDiff.prevName),
prevObjectId: fileDiff.prevObjectId,
type: fileDiff.type,
};
}
function pierreStatus(type: string): string {
switch (type) {
case "new":
return "A";
case "deleted":
return "D";
case "rename-pure":
case "rename-changed":
return "R";
default:
return "M";
}
}
async function parseProductionStream(
patch: string,
chunkPattern: number[] = [64 * 1024],
): Promise<{ items: DiffItem[]; renames: unknown[]; treeSources: FileTreeSource[] }> {
const originalDocument = globalThis.document;
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
const bytes = new TextEncoder().encode(patch);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let offset = 0;
let chunkIndex = 0;
while (offset < bytes.length) {
const chunkSize = chunkPattern[chunkIndex % chunkPattern.length] ?? bytes.length;
controller.enqueue(bytes.slice(offset, Math.min(offset + chunkSize, bytes.length)));
offset += chunkSize;
chunkIndex += 1;
}
controller.close();
},
});
const items: DiffItem[] = [];
const renames: unknown[] = [];
const treeSources: FileTreeSource[] = [];
Object.assign(globalThis, {
document: { visibilityState: "hidden", hasFocus: () => false },
window: globalThis,
fetch: async () => new Response(stream, { status: 200 }),
});
try {
await streamPatch({
getCollapsed: () => false,
initialFileTreeRowCount: 2,
label: createDiffViewerLabelResolver(undefined),
onBatch: (batch) => items.push(...batch),
onComplete: () => {},
onMetrics: () => {},
onRename: (rename) => renames.push(rename),
onTreeSource: (source) => treeSources.push(source),
parsePatchFiles,
patchURL: "oracle.patch",
processFile,
});
} finally {
globalThis.document = originalDocument;
globalThis.fetch = originalFetch;
globalThis.window = originalWindow;
}
return { items, renames, treeSources };
}
function numberedLines(count: number, replacements = new Map<number, string>()): string {
return Array.from({ length: count }, (_, index) => replacements.get(index + 1) ?? String(index + 1)).join("\n") + "\n";
}
+162
View File
@@ -95,3 +95,165 @@ test("streamPatch uses localized fallback for unnamed file tree paths", async ()
expect(treePaths.at(-1)).toEqual(["Localized untitled"]);
expect(treeSources.at(-1)?.preparedInput).toBeUndefined();
});
test("streamPatch stops callbacks after its abort signal fires", async () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
(globalThis as any).document = dom.window.document;
(globalThis as any).window = dom.window;
dom.window.document.hasFocus = () => false;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\ndiff --git a/b.ts b/b.ts\n"));
setTimeout(() => {
controller.enqueue(encoder.encode("--- a/b.ts\n+++ b/b.ts\n"));
controller.close();
}, 0);
},
});
(globalThis as any).fetch = () => Promise.resolve(new Response(stream, { status: 200 }));
const controller = new AbortController();
let batches = 0;
let completed = 0;
const options = {
getCollapsed: () => false,
initialFileTreeRowCount: 1,
label: createDiffViewerLabelResolver(undefined),
onBatch: () => {
batches += 1;
controller.abort();
},
onComplete: () => { completed += 1; },
onMetrics: () => {},
onRename: () => {},
onTreeSource: () => {},
parsePatchFiles: () => [],
patchURL: "/patch.diff",
processFile: () => ({ name: "unused", type: "modified", hunks: [] }),
signal: controller.signal,
};
await streamPatch(options).catch(() => {});
expect(batches).toBe(1);
expect(completed).toBe(0);
dom.window.close();
});
test("streamPatch publishes revision-stable tree snapshots", async () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
(globalThis as any).document = dom.window.document;
(globalThis as any).window = dom.window;
(globalThis as any).fetch = () => Promise.resolve({
ok: true,
text: () => Promise.resolve("patch"),
});
dom.window.document.hasFocus = () => false;
const treeSources: any[] = [];
await streamPatch({
getCollapsed: () => false,
initialFileTreeRowCount: 1,
label: createDiffViewerLabelResolver(undefined),
onBatch: () => {},
onComplete: () => {},
onMetrics: () => {},
onRename: () => {},
onTreeSource: (source) => treeSources.push(source),
parsePatchFiles: () => [{
files: [
{ name: "a.ts", type: "new", hunks: [] },
{ name: "b.ts", type: "deleted", hunks: [] },
{ name: "c.ts", type: "change", hunks: [] },
],
}],
patchURL: "/patch.diff",
processFile: (patchText) => ({ name: patchText, type: "modified", hunks: [] }),
});
expect(treeSources.map((source) => source.revision)).toEqual([1, 2]);
expect(treeSources.map((source) => source.previousRevision)).toEqual([undefined, 1]);
expect(treeSources.map((source) => source.pathCount)).toEqual([1, 3]);
expect(treeSources[0].paths).not.toBe(treeSources[1].paths);
expect(treeSources[0].pathToItemId).not.toBe(treeSources[1].pathToItemId);
expect(treeSources[0].statsByPath).not.toBe(treeSources[1].statsByPath);
expect(treeSources[0].treePathByItemId).not.toBe(treeSources[1].treePathByItemId);
expect(treeSources[0].gitStatus).not.toBe(treeSources[1].gitStatus);
expect(treeSources[0].paths).toEqual(["a.ts"]);
expect(treeSources[0].gitStatus).toEqual([{ path: "a.ts", status: "added" }]);
expect(treeSources[1].paths).toEqual(["a.ts", "b.ts", "c.ts"]);
expect(treeSources[1].gitStatus).toEqual([
{ path: "a.ts", status: "added" },
{ path: "b.ts", status: "deleted" },
]);
});
test("streamPatch grows batches after first paint for large diffs", async () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
(globalThis as any).document = dom.window.document;
(globalThis as any).window = dom.window;
(globalThis as any).fetch = () => Promise.resolve({
ok: true,
text: () => Promise.resolve("patch"),
});
dom.window.document.hasFocus = () => false;
const files = Array.from({ length: 10_000 }, (_, index) => ({
name: `src/file-${index}.ts`,
type: "change",
hunks: [],
}));
const batches: any[][] = [];
let completedMetrics: any;
await streamPatch({
getCollapsed: () => false,
initialFileTreeRowCount: 32,
label: createDiffViewerLabelResolver(undefined),
onBatch: (batch) => batches.push(batch),
onComplete: (metrics) => {
completedMetrics = metrics;
},
onMetrics: () => {},
onRename: () => {},
onTreeSource: () => {},
parsePatchFiles: () => [{ files }],
patchURL: "/patch.diff",
processFile: (patchText) => ({ name: patchText, type: "change", hunks: [] }),
});
expect(batches[0]).toHaveLength(32);
const renderedItems = batches.flat();
expect(renderedItems).toHaveLength(10_000);
expect(renderedItems.map((item) => item.id)).toEqual(files.map((file) => file.name));
expect(completedMetrics.fileCount).toBe(10_000);
expect(completedMetrics.flushCount).toBeLessThanOrEqual(8);
expect(completedMetrics.maxBatchSize).toBeGreaterThanOrEqual(2_048);
dom.window.close();
});
test("streamPatch replaces many repeated paths by stable file order", async () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
(globalThis as any).document = dom.window.document;
(globalThis as any).window = dom.window;
(globalThis as any).fetch = () => Promise.resolve({ ok: true, text: () => Promise.resolve("patch") });
dom.window.document.hasFocus = () => false;
const batches: any[][] = [];
await streamPatch({
getCollapsed: () => false,
initialFileTreeRowCount: 1,
label: createDiffViewerLabelResolver(undefined),
onBatch: (items) => batches.push(items),
onComplete: () => {},
onMetrics: () => {},
onRename: () => {},
onTreeSource: () => {},
parsePatchFiles: () => [{ files: Array.from({ length: 2_000 }, () => ({ name: "repeat.ts", type: "change", hunks: [] })) }],
patchURL: "/patch.diff",
processFile: () => ({ name: "unused", type: "change", hunks: [] }),
});
expect(batches.reduce((count, batch) => count + batch.length, 0)).toBe(2_000);
expect(batches.at(-1)?.at(-1)?.id).toBe("repeat.ts?2");
});
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, expect, test } from "bun:test";
import type { DiffRequest, DiffResponse } from "../src/diff/generated/protocol";
import {
FetchDiffTransport,
WebKitDiffTransport,
supportsFetchTransport,
} from "../src/diff/transport";
const originalWindow = globalThis.window;
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalWindow === undefined) {
delete (globalThis as { window?: Window }).window;
} else {
globalThis.window = originalWindow;
}
});
test("fetch and WebKit transports use the same generated request envelope", async () => {
let fetchRequest: DiffRequest | null = null;
let webKitRequest: DiffRequest | null = null;
const response: DiffResponse = {
id: "response",
version: 1,
result: { type: "handshake", value: { protocolVersion: 1, capabilities: [] } },
error: null,
};
globalThis.window = { webkit: { messageHandlers: {} } } as Window & typeof globalThis;
globalThis.fetch = (async (_input, init) => {
fetchRequest = JSON.parse(String(init?.body)) as DiffRequest;
return Response.json({ ...response, id: fetchRequest.id });
}) as typeof fetch;
const fetchTransport = new FetchDiffTransport("/__cmux_diff_rpc", 1);
await fetchTransport.request({ method: "protocolHandshake" });
const webKitTransport = new WebKitDiffTransport({
async postMessage(message) {
webKitRequest = message as DiffRequest;
return { ...response, id: webKitRequest.id };
},
}, 1);
await webKitTransport.request({ method: "protocolHandshake" });
const capturedFetchRequest = fetchRequest as DiffRequest | null;
const capturedWebKitRequest = webKitRequest as DiffRequest | null;
expect(capturedFetchRequest?.version).toBe(1);
expect(capturedFetchRequest?.method).toBe("protocolHandshake");
expect(capturedWebKitRequest?.version).toBe(1);
expect(capturedWebKitRequest?.method).toBe("protocolHandshake");
});
test("fetch transport is disabled when a persisted viewer is restored through the custom scheme", () => {
expect(supportsFetchTransport("http:")).toBe(true);
expect(supportsFetchTransport("https:")).toBe(true);
expect(supportsFetchTransport("cmux-diff-viewer:")).toBe(false);
});