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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
/target/
|
||||
Generated
+1933
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "1.88.0"
|
||||
profile = "minimal"
|
||||
components = ["clippy", "rustfmt"]
|
||||
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"]
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
+15
-15
File diff suppressed because one or more lines are too long
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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: "&", with: "&")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
}
|
||||
}
|
||||
|
||||
extension CLINotifyProcessIntegrationRegressionTests {
|
||||
private final class MockSocketFulfillmentGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
|
||||
@@ -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: "&", with: "&")
|
||||
.replacingOccurrences(of: """, 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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>?
|
||||
|
||||
|
||||
Executable
+25
@@ -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
|
||||
)
|
||||
Executable
+98
@@ -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
|
||||
Executable
+24
@@ -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/"
|
||||
@@ -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
|
||||
|
||||
Executable
+17
@@ -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 "$@"
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+91
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ?? []) {
|
||||
|
||||
@@ -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, };
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+16
@@ -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>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user