Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ check_for_updates: false
reporter: xcode

file_length:
warning: 800
error: 1200
warning: 600
error: 800
ignore_comment_only_lines: true
1 change: 1 addition & 0 deletions Packages/Sources/MessageList/MessageList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
}
.coordinateSpace(.named(MessageListConstants.coordinateSpaceName))
}
.scrollIndicators(.hidden)
.onGeometryChange(for: CGFloat.self) { geometry in
geometry.size.height
} action: { height in
Expand Down
60 changes: 59 additions & 1 deletion Packages/Sources/RxCodeCore/CLISession/CLISessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public actor CLISessionStore {
private var cwdIndex: [String: URL] = [:]
private var cwdIndexBuiltAt: Date?

/// Per-sid resolved URL cache. Populated lazily when `jsonlURL` falls back
/// to a cross-directory scan (worktree sessions, moved projects, etc.) so
/// the scan only happens once per session per process.
private var sidURLCache: [String: URL] = [:]

/// Per-sid cache of jsonl sniff results, keyed by sid and invalidated by
/// file mtime. Avoids re-reading the first ~400 lines of every jsonl every
/// time the FS watcher fires (which happens on every assistant turn since
Expand Down Expand Up @@ -459,6 +464,15 @@ public actor CLISessionStore {
}
}

/// Resolve a session's on-disk jsonl URL, including the worktree fallback.
/// Returns nil only when no matching `{sid}.jsonl` exists anywhere under
/// `~/.claude/projects/`. Public so callers outside this actor (e.g.
/// disk-reconcile size checks) can route to the same physical file.
public func resolveExistingJsonlURL(sid: String, cwd: String) async -> URL? {
let url = await jsonlURL(sid: sid, cwd: cwd)
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}

private func jsonlURL(sid: String, cwd: String) async -> URL {
// Fast path: most cwds round-trip cleanly through forward encoding, so
// the jsonl is sitting at the predictable path. Probe that first and
Expand All @@ -471,7 +485,51 @@ public actor CLISessionStore {
if FileManager.default.fileExists(atPath: forwardURL.path) {
return forwardURL
}
return await directory(forCwd: cwd).appendingPathComponent("\(sid).jsonl")
let indexedURL = await directory(forCwd: cwd).appendingPathComponent("\(sid).jsonl")
if FileManager.default.fileExists(atPath: indexedURL.path) {
return indexedURL
}
// Worktree fallback: the session may live under a sibling project
// directory (e.g. the CLI ran inside a git worktree whose cwd doesn't
// match the RxCode project's `path`). Sids are UUIDs, so scanning every
// CLI project subdirectory for `{sid}.jsonl` is unambiguous. Result is
// memoized per-sid so repeat clicks are O(1).
if let resolved = await resolveByScan(sid: sid) {
return resolved
}
// Nothing found — return the forward path so the existing "not found"
// diagnostic logging continues to show the expected location.
return forwardURL
}

/// Locate a session's jsonl by sid, regardless of which project directory
/// the CLI wrote it under. Used as a worktree-aware fallback when the
/// caller's cwd doesn't match the session's recorded cwd.
private func resolveByScan(sid: String) async -> URL? {
if let cached = sidURLCache[sid],
FileManager.default.fileExists(atPath: cached.path) {
return cached
}
let projectsRoot = CLIProjectsDirectory.url
guard let dirs = try? FileManager.default.contentsOfDirectory(
at: projectsRoot,
includingPropertiesForKeys: [.isDirectoryKey],
options: .skipsHiddenFiles
) else {
return nil
}
let filename = "\(sid).jsonl"
for dir in dirs {
let isDir = (try? dir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
guard isDir else { continue }
let candidate = dir.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: candidate.path) {
sidURLCache[sid] = candidate
logger.info("[JsonlURL] resolved-by-scan sid=\(sid, privacy: .public) dir=\(dir.lastPathComponent, privacy: .public)")
return candidate
}
}
return nil
}

// MARK: - External activity detection (S2)
Expand Down
70 changes: 0 additions & 70 deletions RxCode/App/AppState+CrossProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -960,74 +960,4 @@
}
}

func finalizeAgentStream(agentProvider: AgentProvider, streamId: UUID) async {
// Claude needs an explicit stdin close before finalize so the CLI
// sees EOF; other backends manage stdin internally.
if agentProvider == .claudeCode {
await claude.closeStdin(streamId: streamId)
}
await backend(for: agentProvider).finalize(streamId: streamId)
}

/// Release the per-session IDE-MCP listener allocated for ACP turns.
/// Safe to call for non-ACP providers (no-op).
func releaseIDESession(sessionKey: String) async {
await ideMCPServer.release(sessionKey: sessionKey)
}

func consumeAgentStderr(agentProvider: AgentProvider, streamId: UUID) async -> String? {
switch agentProvider {
case .claudeCode:
return await claude.consumeStderr(for: streamId)
case .codex:
return await codex.consumeStderr(for: streamId)
case .acp:
return await acp.consumeStderr(for: streamId)
}
}

// MARK: - Editing File Snapshot Capture

/// Detects Edit/MultiEdit/Write tool_use events and reads the target
/// file's current contents **synchronously** so the pre-edit snapshot is
/// captured before the CLI starts executing the tool. The result is cached
/// on `state.editingFileSnapshots` keyed by absolute path; only the FIRST
/// tool_use per (session, path) reads — later edits in the same thread
/// keep diffing against the original snapshot. A previous implementation
/// did this read on a detached task, which lost the race for large files
/// and produced an `originalContent` identical to `modifiedContent`,
/// collapsing the diff. Reading synchronously here is safe because we run
/// at content_block_stop / assistant tool_use arrival — the CLI has only
/// just finished emitting the tool_use and has not yet executed it.
///
/// Handles both the Claude/Anthropic shape (`file_path` in input) and the
/// Codex `changes: [{ path, diff }]` shape. Tools that don't touch files
/// (Bash, Read, etc.) are skipped.
nonisolated static func captureEditingFileSnapshot(
toolName: String,
input: [String: JSONValue],
state: inout SessionStreamState
) {
let nameLower = toolName.lowercased()
let editToolNames: Set<String> = ["edit", "multiedit", "multi_edit", "write"]
guard editToolNames.contains(nameLower) else { return }

var paths: [String] = []
if let path = input["file_path"]?.stringValue {
paths.append(path)
}
if nameLower == "edit", let changes = input["changes"]?.arrayValue {
for change in changes {
if let obj = change.objectValue,
let path = obj["path"]?.stringValue {
paths.append(path)
}
}
}

for path in paths where state.editingFileSnapshots[path] == nil {
state.editingFileSnapshots[path] = try? String(contentsOfFile: path, encoding: .utf8)
}
}

}

Check warning on line 963 in RxCode/App/AppState+CrossProject.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 772 (file_length)
5 changes: 3 additions & 2 deletions RxCode/App/AppState+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ extension AppState {
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }

let url = await self.cliStore.directory(forCwd: cwd)
.appendingPathComponent("\(sessionId).jsonl")
let resolvedURL = await self.cliStore.resolveExistingJsonlURL(sid: sessionId, cwd: cwd)
let fallbackURL = await self.cliStore.directory(forCwd: cwd).appendingPathComponent("\(sessionId).jsonl")
let url = resolvedURL ?? fallbackURL
let currentSize: UInt64? = ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int)
.flatMap(UInt64.init(exactly:))
if let lastSize, let currentSize, currentSize <= lastSize { return }
Expand Down
200 changes: 200 additions & 0 deletions RxCode/App/AppState+MobileDiff.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import Foundation
import RxCodeChatKit
import RxCodeCore
import RxCodeSync

// MARK: - Mobile File Diff & Snapshot Budget Helpers

extension AppState {
/// Strips per-file `originalContent` / `modifiedContent` once the running
/// total of attached snapshots would push the encoded reply past the
/// relay's 10 MiB envelope cap. Order is preserved so earlier files in the
/// thread keep their snapshot-pair diff; later files fall back to the
/// `fullFileDiff` already attached on each `SyncFileEdit`.
nonisolated static func applySnapshotBudget(to edits: [SyncFileEdit]) -> [SyncFileEdit] {
// Conservative budget — leaves headroom for hunks, full-file diffs,
// uncommitted git changes, JSON escaping, and base64 envelope overhead.
let totalBudget = 4 * 1024 * 1024
let perFileCap = 512 * 1024
var remaining = totalBudget
return edits.map { edit in
let pairSize = (edit.originalContent?.utf8.count ?? 0)
+ (edit.modifiedContent?.utf8.count ?? 0)
guard pairSize > 0, pairSize <= perFileCap, pairSize <= remaining else {
return SyncFileEdit(
path: edit.path,
name: edit.name,
containsWrite: edit.containsWrite,
hunks: edit.hunks,
fullFileDiff: edit.fullFileDiff,
originalContent: nil,
modifiedContent: nil
)
}
remaining -= pairSize
return edit
}
}

nonisolated static func mobileFullFileDiff(
path: String,
hunks: [PreviewFile.EditHunk],
originalContent: String?
) async -> String? {
await Task.detached(priority: .utility) { () -> String? in
guard let currentContent = try? String(contentsOfFile: path, encoding: .utf8) else {
return nil
}
if let originalContent {
return snapshotDiff(original: originalContent, current: currentContent)
}
return fullFileDiff(currentContent: currentContent, hunks: hunks)
}.value
}

nonisolated static func fullFileDiff(currentContent: String, hunks: [PreviewFile.EditHunk]) -> String {
currentContentDiff(currentContent: currentContent, hunks: hunks)
}

/// Build a unified diff string from a pre-edit snapshot and the file's
/// current content. Mirrors `FileDiffView.buildSnapshotDiffLines` on
/// macOS but emits the wire-format string the mobile renderer parses.
nonisolated static func snapshotDiff(original: String, current: String) -> String {
let oldLines = contentLines(original)
let newLines = contentLines(current)
let diff = newLines.difference(from: oldLines)
var removalIndices = Set<Int>()
var insertionElements: [Int: String] = [:]
for change in diff {
switch change {
case .remove(let offset, _, _):
removalIndices.insert(offset)
case .insert(let offset, let element, _):
insertionElements[offset] = element
}
}

var lines: [String] = []
var oldIdx = 0
var newIdx = 0
while oldIdx < oldLines.count || newIdx < newLines.count {
if oldIdx < oldLines.count, removalIndices.contains(oldIdx) {
lines.append("-\(oldLines[oldIdx])")
oldIdx += 1
} else if newIdx < newLines.count, let added = insertionElements[newIdx] {
lines.append("+\(added)")
newIdx += 1
} else if oldIdx < oldLines.count, newIdx < newLines.count {
lines.append(" \(newLines[newIdx])")
oldIdx += 1
newIdx += 1
} else if oldIdx < oldLines.count {
lines.append(" \(oldLines[oldIdx])")
oldIdx += 1
} else if newIdx < newLines.count {
lines.append(" \(newLines[newIdx])")
newIdx += 1
}
}
return lines.joined(separator: "\n")
}

nonisolated static func currentContentDiff(
currentContent: String,
hunks: [PreviewFile.EditHunk]
) -> String {
var lines = contentLines(currentContent).map { " \($0)" }
guard !lines.isEmpty else {
return hunks.flatMap { hunk in
contentLines(hunk.oldString).map { "-\($0)" } + contentLines(hunk.newString).map { "+\($0)" }
}.joined(separator: "\n")
}

for hunk in hunks {
let addedLines = contentLines(hunk.newString)
guard !addedLines.isEmpty else { continue }
let matchStart = firstRange(of: addedLines, in: lines.map(contentWithoutDiffMarker))
guard let matchStart else { continue }

let removedLines = contentLines(hunk.oldString)
if !removedLines.isEmpty {
lines.insert(contentsOf: removedLines.map { "-\($0)" }, at: matchStart)
}

let addedStart = matchStart + removedLines.count
for offset in addedLines.indices where addedStart + offset < lines.count {
lines[addedStart + offset] = "+\(addedLines[offset])"
}
}

return lines.joined(separator: "\n")
}

nonisolated static func firstRange(of needle: [String], in haystack: [String]) -> Int? {
guard !needle.isEmpty, needle.count <= haystack.count else { return nil }
for start in 0...(haystack.count - needle.count) {
if Array(haystack[start..<(start + needle.count)]) == needle {
return start
}
}
return nil
}

nonisolated static func contentWithoutDiffMarker(_ text: String) -> String {
guard let first = text.first, first == " " || first == "+" || first == "-" else {
return text
}
return String(text.dropFirst())
}

nonisolated static func contentLines(_ content: String) -> [String] {
guard !content.isEmpty else { return [] }
var lines = content.components(separatedBy: "\n")
if lines.last == "" { lines.removeLast() }
return lines
}
}

// MARK: - Remote File Preview

enum MobileRemoteFileReadError: LocalizedError {
case unreadable
case binary

var errorDescription: String? {
switch self {
case .unreadable:
return "File could not be opened."
case .binary:
return "Binary file — preview not available."
}
}
}

extension AppState {
nonisolated static func isPath(_ path: String, underAnyProject projectPaths: [String]) -> Bool {
let standardizedPath = URL(fileURLWithPath: path).standardizedFileURL.path
return projectPaths.contains { projectPath in
let root = URL(fileURLWithPath: projectPath).standardizedFileURL.path
return standardizedPath == root || standardizedPath.hasPrefix(root + "/")
}
}

nonisolated static func readMobilePreviewFile(path: String) throws -> (content: String, truncated: Bool) {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
let size = (attributes[.size] as? Int) ?? 0
let byteLimit = 1_500_000
let truncated = size > byteLimit

guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else {
throw MobileRemoteFileReadError.unreadable
}
defer { try? handle.close() }

let data = try handle.read(upToCount: min(size, byteLimit)) ?? Data()
guard let content = String(data: data, encoding: .utf8) else {
throw MobileRemoteFileReadError.binary
}
return (content, truncated)
}
}
Loading
Loading