diff --git a/Packages/iOS/CmuxDiffUI/Package.resolved b/Packages/iOS/CmuxDiffUI/Package.resolved new file mode 100644 index 000000000000..f04401d52772 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "66c39e757c9db21412af9b9ab1286a181fe233a36c2817d3debb096db3b4317e", + "pins" : [ + { + "identity" : "highlighterswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/smittytone/HighlighterSwift", + "state" : { + "revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0", + "version" : "3.1.0" + } + } + ], + "version" : 3 +} diff --git a/Packages/iOS/CmuxDiffUI/Package.swift b/Packages/iOS/CmuxDiffUI/Package.swift new file mode 100644 index 000000000000..00a4d6697a7a --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxDiffUI", + defaultLocalization: "en", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library(name: "CmuxDiffUI", targets: ["CmuxDiffUI"]), + ], + dependencies: [ + .package(path: "../CmuxMobileRPC"), + .package(path: "../CmuxMobileShell"), + .package( + url: "https://github.com/smittytone/HighlighterSwift", + exact: "3.1.0" + ), + ], + targets: [ + .target( + name: "CmuxDiffUI", + dependencies: [ + "CmuxMobileRPC", + "CmuxMobileShell", + .product(name: "Highlighter", package: "HighlighterSwift"), + ], + resources: [.process("Resources")], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxDiffUITests", + dependencies: ["CmuxDiffUI", "CmuxMobileRPC"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightBatcher.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightBatcher.swift new file mode 100644 index 000000000000..e6e0d7393611 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightBatcher.swift @@ -0,0 +1,59 @@ +actor CodeHighlightBatcher { + private let highlighter: any CodeHighlighting + private let capacity: Int + private let batchSize: Int + private var cache: [CodeHighlightCacheKey: CodeHighlightCacheEntry] = [:] + private var accessCounter: UInt64 = 0 + + init(highlighter: any CodeHighlighting, capacity: Int = 512, batchSize: Int = 24) { + self.highlighter = highlighter + self.capacity = max(1, capacity) + self.batchSize = max(1, batchSize) + } + + func highlights(for requests: [CodeHighlightRequest]) async -> [String: HighlightedCode] { + var result: [String: HighlightedCode] = [:] + for batchStart in stride(from: 0, to: requests.count, by: batchSize) { + let batchEnd = min(requests.count, batchStart + batchSize) + for request in requests[batchStart.. HighlightedCode? { + guard var entry = cache[key] else { return nil } + accessCounter &+= 1 + entry.lastAccess = accessCounter + cache[key] = entry + return entry.value + } + + private func insert(_ value: HighlightedCode, for key: CodeHighlightCacheKey) { + accessCounter &+= 1 + cache[key] = CodeHighlightCacheEntry(value: value, lastAccess: accessCounter) + guard cache.count > capacity, + let leastRecent = cache.min(by: { $0.value.lastAccess < $1.value.lastAccess })?.key else { + return + } + cache.removeValue(forKey: leastRecent) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightCacheEntry.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightCacheEntry.swift new file mode 100644 index 000000000000..11b925163d3d --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightCacheEntry.swift @@ -0,0 +1,4 @@ +struct CodeHighlightCacheEntry: Sendable { + let value: HighlightedCode + var lastAccess: UInt64 +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightCacheKey.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightCacheKey.swift new file mode 100644 index 000000000000..fbe344c371ca --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightCacheKey.swift @@ -0,0 +1,5 @@ +struct CodeHighlightCacheKey: Sendable, Hashable { + let language: String? + let line: String + let colorScheme: DiffColorScheme +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightColor+SwiftUI.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightColor+SwiftUI.swift new file mode 100644 index 000000000000..574e2e200d80 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightColor+SwiftUI.swift @@ -0,0 +1,7 @@ +import SwiftUI + +extension CodeHighlightColor { + var swiftUIColor: Color { + Color(red: red, green: green, blue: blue, opacity: alpha) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightColor.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightColor.swift new file mode 100644 index 000000000000..b8e7c677f649 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightColor.swift @@ -0,0 +1,24 @@ +/// A platform-neutral syntax color safe to cross concurrency boundaries. +public struct CodeHighlightColor: Sendable, Equatable { + /// Red component in the range zero through one. + public let red: Double + /// Green component in the range zero through one. + public let green: Double + /// Blue component in the range zero through one. + public let blue: Double + /// Alpha component in the range zero through one. + public let alpha: Double + + /// Creates a platform-neutral RGBA color. + /// - Parameters: + /// - red: Red component. + /// - green: Green component. + /// - blue: Blue component. + /// - alpha: Alpha component. + public init(red: Double, green: Double, blue: Double, alpha: Double) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightRequest.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightRequest.swift new file mode 100644 index 000000000000..70280160b796 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightRequest.swift @@ -0,0 +1,6 @@ +struct CodeHighlightRequest: Identifiable, Sendable, Hashable { + let id: String + let language: String? + let line: String + let colorScheme: DiffColorScheme +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightSpan.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightSpan.swift new file mode 100644 index 000000000000..932a4b3a45b9 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlightSpan.swift @@ -0,0 +1,16 @@ +/// One foreground-color run produced by syntax highlighting. +public struct CodeHighlightSpan: Sendable, Equatable { + /// Text covered by this run. + public let text: String + /// Highlighted foreground color, or `nil` for the view's primary color. + public let foreground: CodeHighlightColor? + + /// Creates a syntax-highlight run. + /// - Parameters: + /// - text: Text covered by the run. + /// - foreground: Optional syntax foreground color. + public init(text: String, foreground: CodeHighlightColor?) { + self.text = text + self.foreground = foreground + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlighting.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlighting.swift new file mode 100644 index 000000000000..ac0aaa389aa9 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/CodeHighlighting.swift @@ -0,0 +1,14 @@ +/// Asynchronously converts one source line into sendable syntax-color runs. +public protocol CodeHighlighting: Sendable { + /// Highlights one line without blocking the main actor. + /// - Parameters: + /// - line: Plain source text. + /// - language: Highlight.js language identifier, when known. + /// - colorScheme: Appearance whose GitHub theme should be used. + /// - Returns: Highlighted runs, or `nil` when highlighting is unavailable. + func highlight( + line: String, + language: String?, + colorScheme: DiffColorScheme + ) async -> HighlightedCode? +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/Color+DiffAdaptive.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/Color+DiffAdaptive.swift new file mode 100644 index 000000000000..210e9fee6cf0 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/Color+DiffAdaptive.swift @@ -0,0 +1,30 @@ +public import SwiftUI + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +extension Color { + /// Creates a dynamic color from explicit light and dark tokens. + /// - Parameters: + /// - light: Color used in light appearance. + /// - dark: Color used in dark appearance. + /// - Returns: A platform-adaptive SwiftUI color. + public static func diffAdaptive(light: Color, dark: Color) -> Color { + #if canImport(UIKit) + return Color(uiColor: UIColor { traits in + traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) + }) + #elseif canImport(AppKit) + return Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? NSColor(dark) + : NSColor(light) + }) + #else + return dark + #endif + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffClipboardWriter.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffClipboardWriter.swift new file mode 100644 index 000000000000..cd3a3b0e194c --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffClipboardWriter.swift @@ -0,0 +1,18 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +struct DiffClipboardWriter: Sendable { + @MainActor func copy(_ value: String) { + #if canImport(UIKit) + UIPasteboard.general.string = value + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + #endif + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffCodeText.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffCodeText.swift new file mode 100644 index 000000000000..ec740b02507a --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffCodeText.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct DiffCodeText: View { + @Environment(\.diffTheme) private var theme + let row: DiffRowSnapshot + let highlighted: HighlightedCode? + + var body: some View { + HStack(spacing: 0) { + if !row.intralineSpans.isEmpty { + ForEach(Array(row.intralineSpans.enumerated()), id: \.offset) { _, span in + Text(span.text) + .background(span.isEmphasized ? emphasisFill : Color.clear) + } + } else if let highlighted, !highlighted.spans.isEmpty { + ForEach(Array(highlighted.spans.enumerated()), id: \.offset) { _, span in + Text(span.text) + .foregroundStyle(span.foreground?.swiftUIColor ?? Color.primary) + } + } else { + Text(row.text.isEmpty ? " " : row.text) + } + } + .font(.system(size: 12, design: .monospaced)) + .fixedSize(horizontal: true, vertical: false) + .textSelection(.enabled) + } + + private var emphasisFill: Color { + row.kind == .deletion ? theme.deletionEmphasisFill : theme.additionEmphasisFill + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffColorScheme+SwiftUI.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffColorScheme+SwiftUI.swift new file mode 100644 index 000000000000..d5402a9df006 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffColorScheme+SwiftUI.swift @@ -0,0 +1,7 @@ +import SwiftUI + +extension DiffColorScheme { + init(_ colorScheme: ColorScheme) { + self = colorScheme == .dark ? .dark : .light + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffColorScheme.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffColorScheme.swift new file mode 100644 index 000000000000..61c37894a170 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffColorScheme.swift @@ -0,0 +1,7 @@ +/// A sendable appearance value used by background highlighting work. +public enum DiffColorScheme: String, Sendable, Hashable { + /// Light interface appearance. + case light + /// Dark interface appearance. + case dark +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffComponentPreviews.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffComponentPreviews.swift new file mode 100644 index 000000000000..357611bce30e --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffComponentPreviews.swift @@ -0,0 +1,75 @@ +#if DEBUG +import SwiftUI + +#Preview("Full diff fixture") { + NavigationStack { + DiffFixtureScreen( + defaults: UserDefaults(suiteName: "DiffFixturePreview") ?? .standard + ) + } +} + +#Preview("Summary header") { + DiffSummaryHeaderView( + fileCount: 11, + additions: 3215, + deletions: 23, + viewedCount: 3, + baseLabel: "main · working tree", + baseKind: .workingTree, + ignoreWhitespace: false, + selectBase: { _ in }, + setIgnoreWhitespace: { _ in } + ) + .padding() +} + +#Preview("File list row") { + let patchSet = DiffFixtureFactory().patchSet() + let file = patchSet.files[0] + let rows = DiffRowBuilder().rows( + path: file.summary.path, + hunks: { + if case let .loaded(hunks) = file.content { hunks } else { [] } + }() + ) + DiffFileListRow( + state: DiffFilePresentationState( + file: file, + isViewed: false, + isCollapsed: false, + rows: rows, + splitRows: SplitDiffPairer().pair(rows: rows) + ), + toggleViewed: {}, + toggleCollapsed: {} + ) + .padding() +} + +#Preview("Unified diff row") { + let row = DiffRowSnapshot( + id: "preview", + kind: .addition, + oldLine: nil, + newLine: 42, + text: "let greeting = \"Hello\"", + hunkIndex: 0 + ) + DiffUnifiedRowView( + row: row, + highlighted: nil, + expand: { _ in }, + quickNoteTarget: DiffQuickNoteTarget( + id: "preview", + path: "App.swift", + oldLineRange: nil, + newLineRange: 42...42, + hunkHeader: nil, + excerpt: "+let greeting = \"Hello\"" + ), + quickNoteAvailable: false, + openQuickNote: { _ in } + ) +} +#endif diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContextExpansionRequest.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContextExpansionRequest.swift new file mode 100644 index 000000000000..d0a9072a58d9 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContextExpansionRequest.swift @@ -0,0 +1,30 @@ +/// A request to reveal additional context around one hunk. +public struct DiffContextExpansionRequest: Sendable, Equatable { + /// The repository-relative file path. + public let path: String + /// The hunk's zero-based index in the rendered file. + public let hunkIndex: Int + /// Which portion of context should be expanded. + public let direction: Direction + + /// The supported context-expansion directions. + public enum Direction: Sendable, Equatable { + /// Reveal context before the hunk. + case up + /// Reveal context after the hunk. + case down + /// Reveal all available context around the hunk. + case all + } + + /// Creates a context-expansion request. + /// - Parameters: + /// - path: Repository-relative file path. + /// - hunkIndex: Zero-based hunk index. + /// - direction: Portion of context to reveal. + public init(path: String, hunkIndex: Int, direction: Direction) { + self.path = path + self.hunkIndex = hunkIndex + self.direction = direction + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContextSplicer.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContextSplicer.swift new file mode 100644 index 000000000000..4c42fd907deb --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContextSplicer.swift @@ -0,0 +1,91 @@ +import CmuxMobileRPC + +struct DiffContextSplicer: Sendable { + private let pageLineCount = 20 + private let practicalEndOfFile = 1_000_000_000 + + func ranges( + for direction: DiffContextExpansionRequest.Direction, + hunkIndex: Int, + hunks: [MobileDiffHunk] + ) -> [ClosedRange] { + guard hunks.indices.contains(hunkIndex) else { return [] } + let hunk = hunks[hunkIndex] + let beforeUpper = hunk.newStart - 1 + let previousEnd = hunkIndex > 0 + ? hunks[hunkIndex - 1].newStart + hunks[hunkIndex - 1].newLines + : 1 + let beforeLower = max(1, previousEnd) + + let afterLower = max(1, hunk.newStart + hunk.newLines) + let afterUpper = hunkIndex + 1 < hunks.count + ? hunks[hunkIndex + 1].newStart - 1 + : practicalEndOfFile + + let allBefore = range(lower: beforeLower, upper: beforeUpper) + let allAfter = range(lower: afterLower, upper: afterUpper) + switch direction { + case .up: + return range( + lower: max(beforeLower, beforeUpper - pageLineCount + 1), + upper: beforeUpper + ).map { [$0] } ?? [] + case .down: + return range( + lower: afterLower, + upper: min(afterUpper, afterLower + pageLineCount - 1) + ).map { [$0] } ?? [] + case .all: + return [allBefore, allAfter].compactMap { $0 } + } + } + + func splice( + rows: [String], + range: ClosedRange, + into hunks: [MobileDiffHunk], + hunkIndex: Int + ) -> [MobileDiffHunk] { + guard !rows.isEmpty, hunks.indices.contains(hunkIndex) else { return hunks } + var result = hunks + let hunk = result[hunkIndex] + let isBefore = range.upperBound < hunk.newStart + let delta = isBefore + ? hunk.newStart - hunk.oldStart + : hunk.newStart - hunk.oldStart + hunk.newLines - hunk.oldLines + let numberedRows = rows.enumerated().map { offset, text in + let newLine = range.lowerBound + offset + return MobileDiffRow( + kind: .context, + oldNo: newLine - delta, + newNo: newLine, + text: text + ) + } + + if isBefore { + result[hunkIndex] = MobileDiffHunk( + oldStart: numberedRows[0].oldNo ?? hunk.oldStart - numberedRows.count, + oldLines: hunk.oldLines + numberedRows.count, + newStart: range.lowerBound, + newLines: hunk.newLines + numberedRows.count, + sectionHeading: hunk.sectionHeading, + rows: numberedRows + hunk.rows + ) + } else { + result[hunkIndex] = MobileDiffHunk( + oldStart: hunk.oldStart, + oldLines: hunk.oldLines + numberedRows.count, + newStart: hunk.newStart, + newLines: hunk.newLines + numberedRows.count, + sectionHeading: hunk.sectionHeading, + rows: hunk.rows + numberedRows + ) + } + return result + } + + private func range(lower: Int, upper: Int) -> ClosedRange? { + lower <= upper ? lower...upper : nil + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContinuousActions.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContinuousActions.swift new file mode 100644 index 000000000000..ab3acb32f326 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContinuousActions.swift @@ -0,0 +1,14 @@ +import CmuxMobileRPC + +struct DiffContinuousActions: Sendable { + let loadFile: @MainActor @Sendable (String, Bool) -> Void + let expandContext: @MainActor @Sendable (DiffContextExpansionRequest) -> Void + let toggleViewed: @MainActor @Sendable (String) -> Void + let toggleCollapsed: @MainActor @Sendable (String) -> Void + let collapseAll: @MainActor @Sendable () -> Void + let selectBase: @MainActor @Sendable (MobileDiffBaseKind) -> Void + let setIgnoreWhitespace: @MainActor @Sendable (Bool) -> Void + let openQuickNote: @MainActor @Sendable (DiffQuickNoteTarget) -> Void + let quickNoteAvailable: Bool + let refresh: @MainActor @Sendable () async -> Void +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContinuousView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContinuousView.swift new file mode 100644 index 000000000000..fc30b9d739ec --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffContinuousView.swift @@ -0,0 +1,171 @@ +import CmuxMobileRPC +import SwiftUI + +struct DiffContinuousView: View { + @Environment(\.colorScheme) private var colorScheme + @State private var highlights: [String: HighlightedCode] = [:] + @State private var highlightBatcher: CodeHighlightBatcher + + let fileStates: [DiffFilePresentationState] + let totalFileCount: Int + let additions: Int + let deletions: Int + let baseLabel: String + let baseKind: MobileDiffBaseKind + let ignoreWhitespace: Bool + let renderMode: DiffRenderMode + let scrollTarget: String? + let scrollRequestID: Int + let showFileIndex: Bool + let actions: DiffContinuousActions + private let clipboard = DiffClipboardWriter() + + init( + fileStates: [DiffFilePresentationState], + totalFileCount: Int, + additions: Int, + deletions: Int, + baseLabel: String, + baseKind: MobileDiffBaseKind, + ignoreWhitespace: Bool, + renderMode: DiffRenderMode, + scrollTarget: String?, + scrollRequestID: Int, + showFileIndex: Bool, + highlighter: any CodeHighlighting, + actions: DiffContinuousActions + ) { + self.fileStates = fileStates + self.totalFileCount = totalFileCount + self.additions = additions + self.deletions = deletions + self.baseLabel = baseLabel + self.baseKind = baseKind + self.ignoreWhitespace = ignoreWhitespace + self.renderMode = renderMode + self.scrollTarget = scrollTarget + self.scrollRequestID = scrollRequestID + self.showFileIndex = showFileIndex + self.actions = actions + _highlightBatcher = State(initialValue: CodeHighlightBatcher(highlighter: highlighter)) + } + + var body: some View { + let requests = highlightRequests(for: DiffColorScheme(colorScheme)) + ScrollViewReader { proxy in + List { + Section { + DiffSummaryHeaderView( + fileCount: totalFileCount, + additions: additions, + deletions: deletions, + viewedCount: viewedCount, + baseLabel: baseLabel, + baseKind: baseKind, + ignoreWhitespace: ignoreWhitespace, + selectBase: actions.selectBase, + setIgnoreWhitespace: actions.setIgnoreWhitespace + ) + } + .listRowSeparator(.hidden) + + if showFileIndex { + Section(filesSectionLabel) { + ForEach(fileStates) { state in + DiffFileListRow( + state: state, + toggleViewed: { actions.toggleViewed(state.file.summary.path) }, + toggleCollapsed: { actions.toggleCollapsed(state.file.summary.path) } + ) + } + } + } + + ForEach(sectionSnapshots) { snapshot in + Section { + if !snapshot.state.isCollapsed { + DiffFileBodyView( + snapshot: snapshot, + renderMode: renderMode, + actions: actions + ) + } + } header: { + DiffFileSectionHeader( + state: snapshot.state, + toggleCollapsed: { + actions.toggleCollapsed(snapshot.state.file.summary.path) + if snapshot.state.isCollapsed { + actions.loadFile(snapshot.state.file.summary.path, false) + } + }, + toggleViewed: { actions.toggleViewed(snapshot.state.file.summary.path) }, + copyPath: { clipboard.copy(snapshot.state.file.summary.path) }, + collapseAll: actions.collapseAll, + quickNoteTarget: DiffQuickNoteTargetFactory().fileTarget(state: snapshot.state), + quickNoteAvailable: actions.quickNoteAvailable, + openQuickNote: actions.openQuickNote + ) + .id(DiffTreeScrollTargetResolver().sectionID( + path: snapshot.state.file.summary.path + )) + } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 1) + #if os(iOS) + .listRowSpacing(0) + #endif + .refreshable { + await actions.refresh() + } + .task(id: scrollRequestID) { + guard let scrollTarget else { return } + await Task.yield() + withAnimation { + proxy.scrollTo(scrollTarget, anchor: .top) + } + } + } + .task(id: requests) { + highlights = await highlightBatcher.highlights(for: requests) + } + } + + private var sectionSnapshots: [DiffFileSectionSnapshot] { + fileStates.map { state in + DiffFileSectionSnapshot(state: state, highlights: highlights) + } + } + + private var viewedCount: Int { + fileStates.count(where: \.isViewed) + } + + private var filesSectionLabel: String { + DiffLocalized().string("diff.files.title", defaultValue: "Files") + } + + private func highlightRequests(for scheme: DiffColorScheme) -> [CodeHighlightRequest] { + let mapper = DiffLanguageMapper() + return fileStates.flatMap { state -> [CodeHighlightRequest] in + let language = mapper.language(for: state.file.summary.path) + return state.rows.compactMap { row in + switch row.kind { + case .context, .addition, .deletion: + CodeHighlightRequest( + id: row.id, + language: language, + line: row.text, + colorScheme: scheme + ) + case .hunkHeader, .noNewline: + nil + } + } + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffErrorBannerView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffErrorBannerView.swift new file mode 100644 index 000000000000..288e6504f0c8 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffErrorBannerView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct DiffErrorBannerView: View { + let kind: DiffScreenErrorKind + let retry: @MainActor () -> Void + let useWorkingTree: (@MainActor () -> Void)? + let dismiss: (@MainActor () -> Void)? + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 4) + if let useWorkingTree { + Button(useWorkingTreeLabel, action: useWorkingTree) + .buttonStyle(.borderedProminent) + } else { + Button(retryLabel, action: retry) + .buttonStyle(.bordered) + } + if let dismiss { + Button(action: dismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .accessibilityLabel(dismissLabel) + } + } + .padding(12) + .background(.regularMaterial) + } + + private var title: String { + let localized = DiffLocalized() + return switch kind { + case .unknownWorkspace: + localized.string("diff.error.workspace.title", defaultValue: "Workspace unavailable") + case .notGitRepository: + localized.string("diff.error.repository.title", defaultValue: "Not a Git repository") + case .baselineMissing: + localized.string("diff.error.baseline.title", defaultValue: "Baseline unavailable") + case .transport: + localized.string("diff.error.transport.title", defaultValue: "Couldn’t load diff") + } + } + + private var message: String { + let localized = DiffLocalized() + return switch kind { + case .unknownWorkspace: + localized.string("diff.error.workspace.message", defaultValue: "This workspace is no longer available on the paired computer.") + case .notGitRepository: + localized.string("diff.error.repository.message", defaultValue: "This workspace is not a Git repository.") + case .baselineMissing: + localized.string("diff.error.baseline.message", defaultValue: "The selected comparison baseline is no longer available.") + case .transport: + localized.string("diff.error.transport.message", defaultValue: "The diff could not be loaded from the paired computer.") + } + } + + private var retryLabel: String { + DiffLocalized().string("diff.action.retry", defaultValue: "Retry") + } + + private var useWorkingTreeLabel: String { + DiffLocalized().string("diff.error.baseline.fallback", defaultValue: "Use Working tree") + } + + private var dismissLabel: String { + DiffLocalized().string("diff.action.dismiss", defaultValue: "Dismiss") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFailureView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFailureView.swift new file mode 100644 index 000000000000..211a36741229 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFailureView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct DiffFailureView: View { + let kind: DiffScreenErrorKind + let retry: @MainActor () -> Void + let useWorkingTree: (@MainActor () -> Void)? + + var body: some View { + VStack { + DiffErrorBannerView( + kind: kind, + retry: retry, + useWorkingTree: useWorkingTree, + dismiss: nil + ) + Spacer() + } + .background(Color.diffAdaptive(light: .white, dark: .black)) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileBodyView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileBodyView.swift new file mode 100644 index 000000000000..21a369c1730d --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileBodyView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct DiffFileBodyView: View { + let snapshot: DiffFileSectionSnapshot + let renderMode: DiffRenderMode + let actions: DiffContinuousActions + + var body: some View { + Group { + switch snapshot.state.file.content { + case .loading: + HStack(spacing: 8) { + ProgressView() + Text(loadingLabel) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .onAppear { + actions.loadFile(snapshot.state.file.summary.path, false) + } + case .binary: + DiffPlaceholderRow(kind: .binary, action: {}) + case .large: + DiffPlaceholderRow(kind: .large) { + actions.loadFile(snapshot.state.file.summary.path, true) + } + case .renameOnly: + DiffPlaceholderRow(kind: .renameOnly, action: {}) + case let .failed(message): + DiffPlaceholderRow(kind: .failed(message)) { + actions.loadFile(snapshot.state.file.summary.path, false) + } + case .loaded: + if renderMode == .unified { + ForEach(snapshot.state.rows) { row in + DiffUnifiedRowView( + row: row, + highlighted: snapshot.highlights[row.id], + expand: { direction in + actions.expandContext(DiffContextExpansionRequest( + path: snapshot.state.file.summary.path, + hunkIndex: row.hunkIndex, + direction: direction + )) + }, + quickNoteTarget: row.kind == .hunkHeader + ? DiffQuickNoteTargetFactory().hunkTarget( + state: snapshot.state, + hunkIndex: row.hunkIndex + ) + : nil, + quickNoteAvailable: actions.quickNoteAvailable, + openQuickNote: actions.openQuickNote + ) + } + } else { + ForEach(snapshot.state.splitRows) { row in + DiffSplitRowView( + row: row, + highlights: snapshot.highlights, + expand: { direction in + actions.expandContext(DiffContextExpansionRequest( + path: snapshot.state.file.summary.path, + hunkIndex: row.spanning?.hunkIndex + ?? row.old?.hunkIndex + ?? row.new?.hunkIndex + ?? 0, + direction: direction + )) + }, + quickNoteTarget: row.spanning?.kind == .hunkHeader + ? DiffQuickNoteTargetFactory().hunkTarget( + state: snapshot.state, + hunkIndex: row.spanning?.hunkIndex ?? 0 + ) + : nil, + quickNoteAvailable: actions.quickNoteAvailable, + openQuickNote: actions.openQuickNote + ) + } + } + } + } + } + + private var loadingLabel: String { + DiffLocalized().string("diff.state.loadingFile", defaultValue: "Loading file diff…") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileContent.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileContent.swift new file mode 100644 index 000000000000..3a9537739b21 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileContent.swift @@ -0,0 +1,17 @@ +public import CmuxMobileRPC + +/// The currently available body state for one changed file. +public enum DiffFileContent: Sendable, Equatable { + /// The file body has not loaded yet or is currently loading. + case loading + /// Parsed hunks ready for rendering. + case loaded([MobileDiffHunk]) + /// Git reported binary content rather than line-oriented hunks. + case binary + /// The patch requires an explicit user action before loading. + case large + /// A rename or copy has no textual changes to display. + case renameOnly + /// Loading failed with a user-presentable message. + case failed(String) +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileListRow.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileListRow.swift new file mode 100644 index 000000000000..2452289c7495 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileListRow.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct DiffFileListRow: View { + let state: DiffFilePresentationState + let toggleViewed: @MainActor () -> Void + let toggleCollapsed: @MainActor () -> Void + + var body: some View { + HStack(spacing: 10) { + DiffStatusBadge(status: state.file.summary.status) + Button(action: toggleCollapsed) { + DiffPathLabel( + path: state.file.summary.path, + oldPath: state.file.summary.oldPath, + showRename: false + ) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + HStack(spacing: 5) { + Text("+\(state.file.summary.additions)") + .foregroundStyle(.green) + Text("−\(state.file.summary.deletions)") + .foregroundStyle(.red) + } + .font(.caption.monospacedDigit()) + DiffViewedButton(isViewed: state.isViewed, action: toggleViewed) + } + .opacity(state.isViewed ? 0.52 : 1) + .padding(.vertical, 2) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFilePresentationState.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFilePresentationState.swift new file mode 100644 index 000000000000..2d10224c508e --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFilePresentationState.swift @@ -0,0 +1,8 @@ +struct DiffFilePresentationState: Identifiable, Sendable, Equatable { + var id: String { file.id } + let file: DiffFileSnapshot + var isViewed: Bool + var isCollapsed: Bool + let rows: [DiffRowSnapshot] + let splitRows: [SplitDiffRow] +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSectionHeader.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSectionHeader.swift new file mode 100644 index 000000000000..72a014d8ebd4 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSectionHeader.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct DiffFileSectionHeader: View { + let state: DiffFilePresentationState + let toggleCollapsed: @MainActor () -> Void + let toggleViewed: @MainActor () -> Void + let copyPath: @MainActor () -> Void + let collapseAll: @MainActor () -> Void + let quickNoteTarget: DiffQuickNoteTarget + let quickNoteAvailable: Bool + let openQuickNote: @MainActor (DiffQuickNoteTarget) -> Void + + var body: some View { + HStack(spacing: 8) { + Button(action: toggleCollapsed) { + Image(systemName: state.isCollapsed ? "chevron.right" : "chevron.down") + .font(.caption.bold()) + .frame(width: 20, height: 28) + } + .buttonStyle(.plain) + .accessibilityLabel(state.isCollapsed ? expandFileLabel : collapseFileLabel) + DiffPathLabel( + path: state.file.summary.path, + oldPath: state.file.summary.oldPath, + showRename: true + ) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 5) { + Text("+\(state.file.summary.additions)").foregroundStyle(.green) + Text("−\(state.file.summary.deletions)").foregroundStyle(.red) + } + .font(.caption.monospacedDigit()) + DiffViewedButton(isViewed: state.isViewed, action: toggleViewed) + Menu { + Button(copyPathLabel, systemImage: "doc.on.doc", action: copyPath) + Button(sendToAgentLabel, systemImage: "paperplane") { + openQuickNote(quickNoteTarget) + } + .disabled(!quickNoteAvailable) + if !quickNoteAvailable { + Text(quickNoteUnavailableHint) + } + Button(collapseAllLabel, systemImage: "rectangle.compress.vertical", action: collapseAll) + } label: { + Image(systemName: "ellipsis") + .frame(width: 24, height: 28) + } + .accessibilityLabel(moreLabel) + } + .opacity(state.isViewed ? 0.58 : 1) + .padding(.vertical, 4) + .background(.background) + } + + private var copyPathLabel: String { + DiffLocalized().string("diff.action.copyPath", defaultValue: "Copy path") + } + + private var collapseAllLabel: String { + DiffLocalized().string("diff.action.collapseAll", defaultValue: "Collapse all") + } + + private var sendToAgentLabel: String { + DiffLocalized().string("diff.quickNote.title", defaultValue: "Send to Agent") + } + + private var quickNoteUnavailableHint: String { + DiffLocalized().string( + "diff.quickNote.unavailable", + defaultValue: "Start an agent chat session in this workspace to send a diff note." + ) + } + + private var moreLabel: String { + DiffLocalized().string("diff.action.fileMenu", defaultValue: "File options") + } + + private var expandFileLabel: String { + DiffLocalized().string("diff.action.expandFile", defaultValue: "Expand file") + } + + private var collapseFileLabel: String { + DiffLocalized().string("diff.action.collapseFile", defaultValue: "Collapse file") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSectionSnapshot.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSectionSnapshot.swift new file mode 100644 index 000000000000..2a81b271562c --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSectionSnapshot.swift @@ -0,0 +1,5 @@ +struct DiffFileSectionSnapshot: Identifiable, Sendable, Equatable { + var id: String { state.id } + let state: DiffFilePresentationState + let highlights: [String: HighlightedCode] +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSnapshot.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSnapshot.swift new file mode 100644 index 000000000000..790fabda5c0c --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileSnapshot.swift @@ -0,0 +1,20 @@ +public import CmuxMobileRPC + +/// Immutable summary and body data for one file in a patch set. +public struct DiffFileSnapshot: Identifiable, Sendable, Equatable { + /// Stable identity within the patch set. + public var id: String { summary.path } + /// Summary metadata returned by the mobile diff RPC. + public let summary: MobileDiffFileSummary + /// The currently available body state. + public let content: DiffFileContent + + /// Creates a file snapshot. + /// - Parameters: + /// - summary: Summary metadata for the file. + /// - content: The file's current body state. + public init(summary: MobileDiffFileSummary, content: DiffFileContent) { + self.summary = summary + self.content = content + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileTreeView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileTreeView.swift new file mode 100644 index 000000000000..bb7575b4429d --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFileTreeView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct DiffFileTreeView: View { + @State private var collapsedPaths: Set = [] + + let nodes: [DiffTreeNode] + let files: [DiffFilePresentationState] + let selectFile: @MainActor (String) -> Void + let refresh: @MainActor @Sendable () async -> Void + + var body: some View { + let rows = DiffTreeProjection().rows( + nodes: nodes, + files: files, + collapsedPaths: collapsedPaths + ) + List { + DiffTreeProgressView( + viewedCount: files.count(where: \.isViewed), + fileCount: files.count + ) + .listRowSeparator(.hidden) + + ForEach(rows) { row in + DiffTreeRowView(row: row) { + switch row.kind { + case .directory: + if collapsedPaths.contains(row.path) { + collapsedPaths.remove(row.path) + } else { + collapsedPaths.insert(row.path) + } + case .file: + selectFile(row.path) + } + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 1) + #if os(iOS) + .listRowSpacing(0) + #endif + .refreshable { + await refresh() + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFixtureFactory.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFixtureFactory.swift new file mode 100644 index 000000000000..8335410ae8dd --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFixtureFactory.swift @@ -0,0 +1,255 @@ +import CmuxMobileRPC + +struct DiffFixtureFactory: Sendable { + func patchSet() -> DiffPatchSet { + DiffPatchSet( + workspaceID: "ndv2-fixture-workspace", + baseLabel: DiffLocalized().string( + "diff.fixture.baseLabel", + defaultValue: "main · working tree" + ), + files: [ + modifiedFile(), + addedFile(), + deletedFile(), + renamedFile(), + copiedFile(), + untrackedFile(), + binaryFile(), + largeFile(), + noNewlineFile(), + unicodeLongLineFile(), + failedFile(), + ] + ) + } + + private func modifiedFile() -> DiffFileSnapshot { + file( + path: "Sources/Greeting.swift", + status: .modified, + additions: 2, + deletions: 2, + digest: "modified-v1", + content: .loaded([ + hunk( + oldStart: 8, + oldLines: 5, + newStart: 8, + newLines: 5, + heading: "func greeting(for name: String) -> String", + rows: [ + row(.context, "func greeting(for name: String) -> String {"), + row(.del, " let message = \"Hello, \\(name)!\""), + row(.del, " return message"), + row(.add, " let message = \"Welcome back, \\(name)!\""), + row(.add, " return message.uppercased()"), + row(.context, "}"), + ] + ), + ]) + ) + } + + private func addedFile() -> DiffFileSnapshot { + file( + path: "Sources/Features/NewPanel.swift", + status: .added, + additions: 4, + deletions: 0, + digest: "added-v1", + content: .loaded([ + hunk(oldStart: 0, oldLines: 0, newStart: 1, newLines: 4, heading: nil, rows: [ + row(.add, "import SwiftUI"), + row(.add, "struct NewPanel: View {"), + row(.add, " var body: some View { Text(verbatim: \"Hello\") }"), + row(.add, "}"), + ]), + ]) + ) + } + + private func deletedFile() -> DiffFileSnapshot { + file( + path: "Sources/Legacy/OldPanel.swift", + status: .deleted, + additions: 0, + deletions: 3, + digest: "deleted-v1", + content: .loaded([ + hunk(oldStart: 1, oldLines: 3, newStart: 0, newLines: 0, heading: nil, rows: [ + row(.del, "struct OldPanel {"), + row(.del, " let title: String"), + row(.del, "}"), + ]), + ]) + ) + } + + private func renamedFile() -> DiffFileSnapshot { + file( + path: "Sources/Models/Session.swift", + oldPath: "Sources/Models/Run.swift", + status: .renamed, + additions: 0, + deletions: 0, + digest: "rename-v1", + content: .renameOnly + ) + } + + private func copiedFile() -> DiffFileSnapshot { + file( + path: "Sources/Templates/CardCopy.swift", + oldPath: "Sources/Templates/Card.swift", + status: .copied, + additions: 1, + deletions: 0, + digest: "copy-v1", + content: .loaded([ + hunk(oldStart: 4, oldLines: 2, newStart: 4, newLines: 3, heading: "var body: some View", rows: [ + row(.context, "VStack {"), + row(.add, " Divider()"), + row(.context, "}"), + ]), + ]) + ) + } + + private func untrackedFile() -> DiffFileSnapshot { + file( + path: "scratch/ideas.txt", + status: .untracked, + additions: 3, + deletions: 0, + digest: "untracked-v1", + content: .loaded([ + hunk(oldStart: 0, oldLines: 0, newStart: 1, newLines: 3, heading: nil, rows: [ + row(.add, "Native diff viewer"), + row(.add, "Fast scrolling"), + row(.add, "Device-local viewed state"), + ]), + ]) + ) + } + + private func binaryFile() -> DiffFileSnapshot { + file( + path: "Assets/AppIcon.png", + status: .modified, + additions: 0, + deletions: 0, + digest: "binary-v1", + content: .binary, + isBinary: true + ) + } + + private func largeFile() -> DiffFileSnapshot { + file( + path: "Generated/API.generated.swift", + status: .modified, + additions: 3200, + deletions: 14, + digest: "large-v1", + content: .large, + isLarge: true + ) + } + + private func noNewlineFile() -> DiffFileSnapshot { + file( + path: "docs/release-note.md", + status: .modified, + additions: 1, + deletions: 1, + digest: "newline-v1", + content: .loaded([ + hunk(oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, heading: nil, rows: [ + row(.del, "Old ending"), + row(.noNewline, ""), + row(.add, "New ending"), + row(.noNewline, ""), + ]), + ]) + ) + } + + private func unicodeLongLineFile() -> DiffFileSnapshot { + file( + path: "Sources/国際化/挨拶.swift", + status: .modified, + additions: 1, + deletions: 1, + digest: "unicode-v1", + content: .loaded([ + hunk(oldStart: 20, oldLines: 1, newStart: 20, newLines: 1, heading: "", rows: [ + row(.del, "let greeting = \"こんにちは\" + String(repeating: \"—very-long-line—\", count: 8)"), + row(.add, "let greeting = \"こんばんは\" + String(repeating: \"—very-long-line—\", count: 8)"), + ]), + ]) + ) + } + + private func failedFile() -> DiffFileSnapshot { + file( + path: "Sources/Remote/Unavailable.swift", + status: .modified, + additions: 2, + deletions: 1, + digest: "failed-v1", + content: .failed(DiffLocalized().string( + "diff.fixture.loadError", + defaultValue: "The fixture could not load this file." + )) + ) + } + + private func file( + path: String, + oldPath: String? = nil, + status: MobileDiffFileStatus, + additions: Int, + deletions: Int, + digest: String, + content: DiffFileContent, + isBinary: Bool = false, + isLarge: Bool = false + ) -> DiffFileSnapshot { + DiffFileSnapshot( + summary: MobileDiffFileSummary( + path: path, + oldPath: oldPath, + status: status, + additions: additions, + deletions: deletions, + isBinary: isBinary, + isLarge: isLarge, + patchDigest: digest + ), + content: content + ) + } + + private func hunk( + oldStart: Int, + oldLines: Int, + newStart: Int, + newLines: Int, + heading: String?, + rows: [MobileDiffRow] + ) -> MobileDiffHunk { + MobileDiffHunk( + oldStart: oldStart, + oldLines: oldLines, + newStart: newStart, + newLines: newLines, + sectionHeading: heading, + rows: rows + ) + } + + private func row(_ kind: MobileDiffRowKind, _ text: String) -> MobileDiffRow { + MobileDiffRow(kind: kind, text: text) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFixtureScreen.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFixtureScreen.swift new file mode 100644 index 000000000000..8ae9f6a0e7e5 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffFixtureScreen.swift @@ -0,0 +1,62 @@ +public import Foundation +public import SwiftUI + +/// DEBUG fixture harness covering every rendering state without network or Git access. +public struct DiffFixtureScreen: View { + @State private var renderMode: DiffRenderMode = .unified + private let defaults: UserDefaults + private let highlighter = HighlighterSwiftCodeHighlighter() + + /// Localized label used by the DEBUG settings entry. + public static var settingsLabel: String { + DiffLocalized().string("diff.fixture.settingsLabel", defaultValue: "Diff rendering (ndv2)") + } + + /// Creates the fixture harness with injected viewed-state persistence. + /// - Parameter defaults: Defaults suite used by the fixture's viewed store. + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// The full-screen fixture surface and unified/split toggle. + public var body: some View { + DiffScreen( + patchSet: DiffFixtureFactory().patchSet(), + renderMode: $renderMode, + viewedStore: DiffViewedStore(defaults: defaults), + highlighter: highlighter, + actions: DiffScreenActions( + loadLargeFile: { _ in }, + retryFile: { _ in }, + expandContext: { _ in } + ) + ) + .navigationTitle(navigationTitle) + .toolbar { + ToolbarItem(placement: .principal) { + Picker(modeLabel, selection: $renderMode) { + Text(unifiedLabel).tag(DiffRenderMode.unified) + Text(splitLabel).tag(DiffRenderMode.split) + } + .pickerStyle(.segmented) + .frame(maxWidth: 220) + } + } + } + + private var navigationTitle: String { + DiffLocalized().string("diff.fixture.title", defaultValue: "Diff rendering") + } + + private var modeLabel: String { + DiffLocalized().string("diff.mode.label", defaultValue: "Layout") + } + + private var unifiedLabel: String { + DiffLocalized().string("diff.mode.unified", defaultValue: "Unified") + } + + private var splitLabel: String { + DiffLocalized().string("diff.mode.split", defaultValue: "Split") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffHunkHeaderView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffHunkHeaderView.swift new file mode 100644 index 000000000000..9e18df6f4570 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffHunkHeaderView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct DiffHunkHeaderView: View { + @Environment(\.diffTheme) private var theme + let row: DiffRowSnapshot + let expand: @MainActor (DiffContextExpansionRequest.Direction) -> Void + let quickNoteTarget: DiffQuickNoteTarget + let quickNoteAvailable: Bool + let openQuickNote: @MainActor (DiffQuickNoteTarget) -> Void + + var body: some View { + HStack(spacing: 6) { + Text(row.text) + .font(.caption.monospaced()) + .foregroundStyle(.blue) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Button { openQuickNote(quickNoteTarget) } label: { + Image(systemName: "paperplane") + .font(.caption) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .disabled(!quickNoteAvailable) + .accessibilityLabel(sendToAgentLabel) + .accessibilityHint(quickNoteAvailable ? "" : unavailableHint) + expansionButton(systemImage: "arrow.up.to.line", direction: .up, label: expandUpLabel) + expansionButton(systemImage: "arrow.down.to.line", direction: .down, label: expandDownLabel) + expansionButton(systemImage: "arrow.up.and.down", direction: .all, label: expandAllLabel) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(theme.hunkFill) + } + + private func expansionButton( + systemImage: String, + direction: DiffContextExpansionRequest.Direction, + label: String + ) -> some View { + Button { expand(direction) } label: { + Image(systemName: systemImage) + .font(.caption) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } + + private var expandUpLabel: String { + DiffLocalized().string("diff.hunk.expandUp", defaultValue: "Expand context above") + } + + private var expandDownLabel: String { + DiffLocalized().string("diff.hunk.expandDown", defaultValue: "Expand context below") + } + + private var expandAllLabel: String { + DiffLocalized().string("diff.hunk.expandAll", defaultValue: "Expand all context") + } + + private var sendToAgentLabel: String { + DiffLocalized().string("diff.quickNote.title", defaultValue: "Send to Agent") + } + + private var unavailableHint: String { + DiffLocalized().string( + "diff.quickNote.unavailable", + defaultValue: "Start an agent chat session in this workspace to send a diff note." + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLanguageMapper.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLanguageMapper.swift new file mode 100644 index 000000000000..7f6f2483a953 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLanguageMapper.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Maps repository filenames to Highlight.js language identifiers. +public struct DiffLanguageMapper: Sendable { + /// Creates a filename mapper. + public init() {} + + /// Returns a Highlight.js identifier for a path when one is known. + /// - Parameter filename: A filename or repository-relative path. + /// - Returns: A language identifier, or `nil` for plain text. + public func language(for filename: String) -> String? { + let basename = URL(fileURLWithPath: filename).lastPathComponent.lowercased() + if let special = specialFilenames[basename] { return special } + let pathExtension = URL(fileURLWithPath: basename).pathExtension.lowercased() + return extensions[pathExtension] + } + + private var specialFilenames: [String: String] { + [ + "dockerfile": "dockerfile", + "makefile": "makefile", + "package.swift": "swift", + "package.resolved": "json", + ] + } + + private var extensions: [String: String] { + [ + "c": "c", "cc": "cpp", "cpp": "cpp", "css": "css", + "go": "go", "h": "c", "hpp": "cpp", "html": "xml", + "java": "java", "js": "javascript", "json": "json", + "jsx": "javascript", "kt": "kotlin", "kts": "kotlin", + "m": "objectivec", "md": "markdown", "mm": "objectivec", + "php": "php", "pl": "perl", "py": "python", "rb": "ruby", + "rs": "rust", "sh": "bash", "sql": "sql", "swift": "swift", + "toml": "ini", "ts": "typescript", "tsx": "typescript", + "xml": "xml", "yaml": "yaml", "yml": "yaml", "zig": "zig", + ] + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLayoutOverride.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLayoutOverride.swift new file mode 100644 index 000000000000..86cb85a4192f --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLayoutOverride.swift @@ -0,0 +1,23 @@ +/// A persisted override for orientation-driven unified or split rendering. +public enum DiffLayoutOverride: String, Sendable, Codable, CaseIterable, Hashable { + /// Uses unified portrait rendering and split landscape rendering. + case automatic + /// Always uses the unified diff layout. + case unified + /// Always uses the side-by-side diff layout. + case split + + /// Resolves the concrete render mode for the current orientation. + /// - Parameter isLandscape: Whether the available diff region is wider than it is tall. + /// - Returns: The effective unified or split mode. + public func renderMode(isLandscape: Bool) -> DiffRenderMode { + switch self { + case .automatic: + isLandscape ? .split : .unified + case .unified: + .unified + case .split: + .split + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLayoutPreferenceStore.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLayoutPreferenceStore.swift new file mode 100644 index 000000000000..e463e3394dcc --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLayoutPreferenceStore.swift @@ -0,0 +1,29 @@ +public import Foundation + +/// Persists the user's manual unified/split override for diff screens. +public struct DiffLayoutPreferenceStore: Sendable { + /// The defaults entry containing the selected layout override. + public static let defaultsKey = "dev.cmux.mobile.diff.layoutOverride.v1" + + // UserDefaults is Apple-documented thread-safe; the dependency is injected. + private nonisolated(unsafe) let defaults: UserDefaults + + /// Creates a layout preference store over an injected defaults suite. + /// - Parameter defaults: The persistence suite; tests should inject an isolated suite. + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// Reads the persisted override, defaulting to orientation-driven layout. + /// - Returns: The stored override or ``DiffLayoutOverride/automatic``. + public func load() -> DiffLayoutOverride { + defaults.string(forKey: Self.defaultsKey) + .flatMap(DiffLayoutOverride.init(rawValue:)) ?? .automatic + } + + /// Persists a layout override. + /// - Parameter override: The override to save. + public func save(_ override: DiffLayoutOverride) { + defaults.set(override.rawValue, forKey: Self.defaultsKey) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLiveScreen.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLiveScreen.swift new file mode 100644 index 000000000000..99ad5e4efe46 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLiveScreen.swift @@ -0,0 +1,332 @@ +public import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +/// Live native diff navigation for iPhone and iPad backed by ``DiffScreenStore``. +public struct DiffLiveScreen: View { + @State private var store: DiffScreenStore + @State private var scrollTarget: String? + @State private var scrollRequestID = 0 + @State private var showingFilesFirstDiff = false + @State private var showingTreeDrawer = false + @State private var splitVisibility: NavigationSplitViewVisibility = .all + @State private var quickNoteTarget: DiffQuickNoteTarget? + + private let navigationModel: DiffNavigationModel + private let highlighter: any CodeHighlighting + private let quickNoteActions: DiffQuickNoteActions + private let dismissViewer: @MainActor () -> Void + + /// Creates a live diff screen around an already-constructed observable store. + /// - Parameters: + /// - store: The screen's service-backed reducer and persisted state. + /// - navigationModel: Files-first or diff-first navigation behavior. + /// - highlighter: The asynchronous syntax-highlighting implementation. + /// - quickNoteActions: Host chat actions for hunk and file notes. + /// - dismissViewer: Dismisses the enclosing viewer after composer prefill. + public init( + store: DiffScreenStore, + navigationModel: DiffNavigationModel = .filesFirst, + highlighter: any CodeHighlighting = HighlighterSwiftCodeHighlighter(), + quickNoteActions: DiffQuickNoteActions = DiffQuickNoteActions( + isAvailable: false, + send: { _ in }, + editInComposer: { _ in } + ), + dismissViewer: @escaping @MainActor () -> Void = {} + ) { + _store = State(initialValue: store) + self.navigationModel = navigationModel + self.highlighter = highlighter + self.quickNoteActions = quickNoteActions + self.dismissViewer = dismissViewer + } + + /// The adaptive loading, failure, phone, and tablet screen hierarchy. + public var body: some View { + Group { + switch store.phase { + case .idle, .loading: + DiffLoadingView() + case let .failed(kind): + failureView(kind: kind) + case .loaded: + loadedNavigation + } + } + .task { + await store.loadInitial() + } + .sheet(item: $quickNoteTarget) { target in + DiffQuickNoteSheet( + target: target, + actions: quickNoteActions, + dismissViewer: dismissViewer + ) + } + } + + @ViewBuilder private func failureView(kind: DiffScreenErrorKind) -> some View { + if kind == .baselineMissing { + DiffFailureView( + kind: kind, + retry: { Task { await store.retryBanner() } }, + useWorkingTree: { Task { await store.useWorkingTree() } } + ) + } else { + DiffFailureView( + kind: kind, + retry: { Task { await store.retryBanner() } }, + useWorkingTree: nil + ) + } + } + + @ViewBuilder private var loadedNavigation: some View { + if isPad { + NavigationSplitView(columnVisibility: $splitVisibility) { + tree + .navigationTitle(filesTitle) + .navigationSplitViewColumnWidth(min: 240, ideal: 290, max: 360) + } detail: { + diffDetail + } + .navigationSplitViewStyle(.balanced) + .banner( + store.errorBanner, + retry: retryBanner, + useWorkingTree: useWorkingTree, + dismiss: store.dismissBanner + ) + } else { + switch navigationModel { + case .filesFirst: + NavigationStack { + tree + .navigationTitle(filesTitle) + .navigationDestination(isPresented: $showingFilesFirstDiff) { + diffDetail + } + } + .banner( + store.errorBanner, + retry: retryBanner, + useWorkingTree: useWorkingTree, + dismiss: store.dismissBanner + ) + case .diffFirst: + NavigationStack { + diffDetail + } + .sheet(isPresented: $showingTreeDrawer) { + NavigationStack { + tree + .navigationTitle(filesTitle) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(doneLabel) { showingTreeDrawer = false } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .banner( + store.errorBanner, + retry: retryBanner, + useWorkingTree: useWorkingTree, + dismiss: store.dismissBanner + ) + } + } + } + + private var tree: some View { + DiffFileTreeView( + nodes: store.treeNodes, + files: store.fileStates, + selectFile: selectFile, + refresh: store.refresh + ) + } + + private var diffDetail: some View { + GeometryReader { geometry in + let renderMode = store.layoutOverride.renderMode( + isLandscape: geometry.size.width > geometry.size.height + ) + DiffContinuousView( + fileStates: store.fileStates, + totalFileCount: store.summary?.totals.files ?? store.fileStates.count, + additions: store.summary?.totals.additions ?? 0, + deletions: store.summary?.totals.deletions ?? 0, + baseLabel: store.summary?.baseInfo.describe ?? "", + baseKind: store.baseSpec.kind, + ignoreWhitespace: store.ignoreWhitespace, + renderMode: renderMode, + scrollTarget: scrollTarget, + scrollRequestID: scrollRequestID, + showFileIndex: false, + highlighter: highlighter, + actions: continuousActions + ) + } + .navigationTitle(changesTitle) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if !isPad, navigationModel == .diffFirst { + ToolbarItem(placement: .primaryAction) { + Button { + showingTreeDrawer = true + } label: { + Label(filesTitle, systemImage: "list.bullet.indent") + } + } + } + ToolbarItem(placement: .secondaryAction) { + layoutMenu + } + } + } + + private var layoutMenu: some View { + Menu { + Picker(layoutLabel, selection: layoutBinding) { + Text(automaticLayoutLabel).tag(DiffLayoutOverride.automatic) + Text(unifiedLayoutLabel).tag(DiffLayoutOverride.unified) + Text(splitLayoutLabel).tag(DiffLayoutOverride.split) + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel(moreLabel) + } + + private var layoutBinding: Binding { + Binding( + get: { store.layoutOverride }, + set: { store.layoutOverride = $0 } + ) + } + + private var continuousActions: DiffContinuousActions { + DiffContinuousActions( + loadFile: { path, force in + Task { await store.loadFile(path: path, force: force) } + }, + expandContext: { request in + Task { await store.expandContext(request) } + }, + toggleViewed: store.toggleViewed, + toggleCollapsed: store.toggleCollapsed, + collapseAll: store.collapseAll, + selectBase: { kind in + Task { await store.selectBase(kind) } + }, + setIgnoreWhitespace: { enabled in + Task { await store.setIgnoreWhitespace(enabled) } + }, + openQuickNote: { target in + quickNoteTarget = target + }, + quickNoteAvailable: quickNoteActions.isAvailable, + refresh: store.refresh + ) + } + + @MainActor private func selectFile(path: String) { + guard let target = DiffTreeScrollTargetResolver().target(path: path, files: store.files) else { + return + } + scrollTarget = target + scrollRequestID &+= 1 + if !isPad, navigationModel == .filesFirst { + showingFilesFirstDiff = true + } + showingTreeDrawer = false + Task { await store.loadFile(path: path) } + } + + @MainActor private func retryBanner() { + Task { await store.retryBanner() } + } + + @MainActor private func useWorkingTree() { + Task { await store.useWorkingTree() } + } + + private var isPad: Bool { + #if canImport(UIKit) + UIDevice.current.userInterfaceIdiom == .pad + #else + false + #endif + } + + private var filesTitle: String { + DiffLocalized().string("diff.files.changedTitle", defaultValue: "Changed files") + } + + private var changesTitle: String { + DiffLocalized().string("diff.screen.title", defaultValue: "Changes") + } + + private var doneLabel: String { + DiffLocalized().string("diff.action.done", defaultValue: "Done") + } + + private var layoutLabel: String { + DiffLocalized().string("diff.mode.label", defaultValue: "Layout") + } + + private var automaticLayoutLabel: String { + DiffLocalized().string("diff.mode.automatic", defaultValue: "Automatic") + } + + private var unifiedLayoutLabel: String { + DiffLocalized().string("diff.mode.unified", defaultValue: "Unified") + } + + private var splitLayoutLabel: String { + DiffLocalized().string("diff.mode.split", defaultValue: "Split") + } + + private var moreLabel: String { + DiffLocalized().string("diff.action.more", defaultValue: "More options") + } +} + +private extension View { + @ViewBuilder + func banner( + _ kind: DiffScreenErrorKind?, + retry: @escaping @MainActor () -> Void, + useWorkingTree: @escaping @MainActor () -> Void, + dismiss: @escaping @MainActor () -> Void + ) -> some View { + if let kind { + safeAreaInset(edge: .top, spacing: 0) { + if kind == .baselineMissing { + DiffErrorBannerView( + kind: kind, + retry: retry, + useWorkingTree: useWorkingTree, + dismiss: dismiss + ) + } else { + DiffErrorBannerView( + kind: kind, + retry: retry, + useWorkingTree: nil, + dismiss: dismiss + ) + } + } + } else { + self + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLoadingView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLoadingView.swift new file mode 100644 index 000000000000..0fb43962bfb5 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLoadingView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct DiffLoadingView: View { + var body: some View { + List { + HStack(spacing: 10) { + ProgressView() + Text(loadingLabel) + .font(.headline) + } + .padding(.vertical, 8) + .listRowSeparator(.hidden) + + ForEach(0..<6, id: \.self) { index in + VStack(alignment: .leading, spacing: 7) { + RoundedRectangle(cornerRadius: 4) + .fill(.quaternary) + .frame(width: index.isMultiple(of: 2) ? 210 : 155, height: 12) + RoundedRectangle(cornerRadius: 3) + .fill(.quaternary) + .frame(height: 9) + } + .padding(.vertical, 7) + .redacted(reason: .placeholder) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 1) + #if os(iOS) + .listRowSpacing(0) + #endif + } + + private var loadingLabel: String { + DiffLocalized().string("diff.state.loadingSummary", defaultValue: "Loading changed files…") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLocalized.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLocalized.swift new file mode 100644 index 000000000000..f6f6496b2582 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffLocalized.swift @@ -0,0 +1,15 @@ +import Foundation + +struct DiffLocalized: Sendable { + func string(_ key: StaticString, defaultValue: String.LocalizationValue) -> String { + String(localized: key, defaultValue: defaultValue, bundle: .module) + } + + func format( + _ key: StaticString, + defaultValue: String.LocalizationValue, + _ arguments: any CVarArg... + ) -> String { + String(format: string(key, defaultValue: defaultValue), arguments: arguments) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffNavigationModel.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffNavigationModel.swift new file mode 100644 index 000000000000..ca2c7f0abdf1 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffNavigationModel.swift @@ -0,0 +1,7 @@ +/// The debug-selectable navigation shell for the native diff viewer. +public enum DiffNavigationModel: String, Sendable, Codable, CaseIterable, Hashable { + /// Shows the changed-file tree before navigating to the continuous diff. + case filesFirst + /// Opens the continuous diff immediately and exposes the tree in a drawer. + case diffFirst +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPatchSet.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPatchSet.swift new file mode 100644 index 000000000000..8d897b377713 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPatchSet.swift @@ -0,0 +1,20 @@ +/// Immutable input for a complete changed-file list and diff body. +public struct DiffPatchSet: Sendable, Equatable { + /// Stable workspace identity used for device-local viewed state. + public let workspaceID: String + /// Human-readable base description shown in the summary header. + public let baseLabel: String + /// Changed files in display order. + public let files: [DiffFileSnapshot] + + /// Creates a renderable patch set. + /// - Parameters: + /// - workspaceID: Stable workspace identity. + /// - baseLabel: Human-readable base description. + /// - files: Changed files in display order. + public init(workspaceID: String, baseLabel: String, files: [DiffFileSnapshot]) { + self.workspaceID = workspaceID + self.baseLabel = baseLabel + self.files = files + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPathLabel.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPathLabel.swift new file mode 100644 index 000000000000..2d1445f60a87 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPathLabel.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct DiffPathLabel: View { + let path: String + let oldPath: String? + let showRename: Bool + + var body: some View { + if showRename, let oldPath, oldPath != path { + HStack(spacing: 4) { + Text(oldPath) + .foregroundStyle(.secondary) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.secondary) + Text(path) + .foregroundStyle(.primary) + } + .font(.subheadline.monospaced()) + } else { + HStack(spacing: 0) { + if !directory.isEmpty { + Text(directory + "/") + .foregroundStyle(.secondary) + } + Text(filename) + .foregroundStyle(.primary) + } + .font(.subheadline.monospaced()) + } + } + + private var directory: String { + path.split(separator: "/").dropLast().joined(separator: "/") + } + + private var filename: String { + path.split(separator: "/").last.map(String.init) ?? path + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPlaceholderKind.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPlaceholderKind.swift new file mode 100644 index 000000000000..99b04a04e1e0 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPlaceholderKind.swift @@ -0,0 +1,6 @@ +enum DiffPlaceholderKind: Sendable, Equatable { + case binary + case large + case renameOnly + case failed(String) +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPlaceholderRow.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPlaceholderRow.swift new file mode 100644 index 000000000000..05840c2273d1 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPlaceholderRow.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct DiffPlaceholderRow: View { + let kind: DiffPlaceholderKind + let action: @MainActor () -> Void + + var body: some View { + VStack(spacing: 8) { + Image(systemName: imageName) + .font(.title3) + .foregroundStyle(.secondary) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + if let buttonLabel { + Button(buttonLabel, action: action) + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } + + private var imageName: String { + switch kind { + case .binary: "doc.fill" + case .large: "doc.badge.ellipsis" + case .renameOnly: "arrow.right" + case .failed: "exclamationmark.triangle" + } + } + + private var message: String { + let localized = DiffLocalized() + return switch kind { + case .binary: + localized.string("diff.state.binary", defaultValue: "Binary file not shown") + case .large: + localized.string("diff.state.large", defaultValue: "This diff is large. Load it when you're ready.") + case .renameOnly: + localized.string("diff.state.renameOnly", defaultValue: "File renamed without content changes") + case let .failed(message): + message + } + } + + private var buttonLabel: String? { + let localized = DiffLocalized() + return switch kind { + case .large: + localized.string("diff.action.load", defaultValue: "Load diff") + case .failed: + localized.string("diff.action.retry", defaultValue: "Retry") + case .binary, .renameOnly: + nil + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPresentationBuilder.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPresentationBuilder.swift new file mode 100644 index 000000000000..c16e68f3d9fd --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPresentationBuilder.swift @@ -0,0 +1,36 @@ +struct DiffPresentationBuilder: Sendable { + private let rowBuilder = DiffRowBuilder() + private let splitPairer = SplitDiffPairer() + + func states(patchSet: DiffPatchSet, viewedStore: DiffViewedStore) -> [DiffFilePresentationState] { + patchSet.files.map { file in + state(file: file, workspaceID: patchSet.workspaceID, viewedStore: viewedStore) + } + } + + func state( + file: DiffFileSnapshot, + workspaceID: String, + viewedStore: DiffViewedStore, + isCollapsed: Bool? = nil + ) -> DiffFilePresentationState { + let rows: [DiffRowSnapshot] + if case let .loaded(hunks) = file.content { + rows = rowBuilder.rows(path: file.summary.path, hunks: hunks) + } else { + rows = [] + } + let isViewed = viewedStore.isViewed( + workspaceID: workspaceID, + path: file.summary.path, + patchDigest: file.summary.patchDigest + ) + return DiffFilePresentationState( + file: file, + isViewed: isViewed, + isCollapsed: isCollapsed ?? isViewed, + rows: rows, + splitRows: splitPairer.pair(rows: rows) + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPromptFormatter.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPromptFormatter.swift new file mode 100644 index 000000000000..43db6549f4b9 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffPromptFormatter.swift @@ -0,0 +1,90 @@ +import Foundation + +struct DiffPromptFormatter: Sendable { + let maximumExcerptCharacters: Int + + init(maximumExcerptCharacters: Int = 4_000) { + self.maximumExcerptCharacters = maximumExcerptCharacters + } + + func format(target: DiffQuickNoteTarget, note: String) -> String { + let localized = DiffLocalized() + let preview = preview(target: target) + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + let noteBody = trimmedNote.isEmpty + ? localized.string("diff.quickNote.noNote", defaultValue: "No additional note.") + : trimmedNote + return [ + localized.string( + "diff.quickNote.promptInstruction", + defaultValue: "Review this exact diff target and respond to the note." + ), + "", + preview, + "", + localized.string("diff.quickNote.noteHeading", defaultValue: "Note:"), + noteBody, + ].joined(separator: "\n") + } + + func preview(target: DiffQuickNoteTarget) -> String { + let localized = DiffLocalized() + let excerpt = truncatedExcerpt(target.excerpt) + let fence = codeFence(for: excerpt) + var lines = [ + "\(localized.string("diff.quickNote.fileHeading", defaultValue: "File:")) \(escapedPath(target.path))", + "\(localized.string("diff.quickNote.newTargetHeading", defaultValue: "New target:")) \(reference(path: target.path, range: target.newLineRange))", + "\(localized.string("diff.quickNote.oldTargetHeading", defaultValue: "Old target:")) \(reference(path: target.path, range: target.oldLineRange))", + ] + if let hunkHeader = target.hunkHeader, !hunkHeader.isEmpty { + lines.append("\(localized.string("diff.quickNote.hunkHeading", defaultValue: "Hunk:")) \(hunkHeader)") + } + lines.append(localized.string("diff.quickNote.excerptHeading", defaultValue: "Excerpt:")) + lines.append("\(fence)diff") + lines.append(excerpt.isEmpty + ? localized.string("diff.quickNote.excerptUnavailable", defaultValue: "Diff excerpt is not loaded.") + : excerpt) + lines.append(fence) + return lines.joined(separator: "\n") + } + + private func truncatedExcerpt(_ excerpt: String) -> String { + guard excerpt.count > maximumExcerptCharacters else { return excerpt } + let suffix = DiffLocalized().string( + "diff.quickNote.excerptTruncated", + defaultValue: "… [excerpt truncated]" + ) + let retainedCount = max(0, maximumExcerptCharacters - suffix.count - 1) + return "\(excerpt.prefix(retainedCount))\n\(suffix)" + } + + private func codeFence(for excerpt: String) -> String { + var longestRun = 0 + var currentRun = 0 + for character in excerpt { + if character == "`" { + currentRun += 1 + longestRun = max(longestRun, currentRun) + } else { + currentRun = 0 + } + } + return String(repeating: "`", count: max(3, longestRun + 1)) + } + + private func reference(path: String, range: ClosedRange?) -> String { + guard let range else { + return "\(escapedPath(path)):\(DiffLocalized().string("diff.quickNote.linesUnavailable", defaultValue: "lines unavailable"))" + } + if range.lowerBound == range.upperBound { + return "\(escapedPath(path)):\(range.lowerBound)" + } + return "\(escapedPath(path)):\(range.lowerBound)-\(range.upperBound)" + } + + private func escapedPath(_ path: String) -> String { + path.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\n", with: "\\n") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteActions.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteActions.swift new file mode 100644 index 000000000000..2dc11f199294 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteActions.swift @@ -0,0 +1,24 @@ +/// Host-owned chat actions available from a diff quick-note sheet. +public struct DiffQuickNoteActions: Sendable { + /// Whether the workspace has an agent chat session that can receive the note. + public let isAvailable: Bool + /// Sends a formatted prompt through the host's composer send path. + public let send: @MainActor @Sendable (String) async -> Void + /// Places a formatted prompt in the host's existing composer. + public let editInComposer: @MainActor @Sendable (String) -> Void + + /// Creates a quick-note action bundle. + /// - Parameters: + /// - isAvailable: Whether an agent chat session can receive or edit the note. + /// - send: Sends the already-formatted prompt. + /// - editInComposer: Prefills the existing composer with the prompt. + public init( + isAvailable: Bool, + send: @escaping @MainActor @Sendable (String) async -> Void, + editInComposer: @escaping @MainActor @Sendable (String) -> Void + ) { + self.isAvailable = isAvailable + self.send = send + self.editInComposer = editInComposer + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteSheet.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteSheet.swift new file mode 100644 index 000000000000..90171dbc5b0c --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteSheet.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct DiffQuickNoteSheet: View { + let target: DiffQuickNoteTarget + let actions: DiffQuickNoteActions + let dismissViewer: @MainActor () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var note = "" + @State private var isSending = false + + var body: some View { + NavigationStack { + Form { + Section(previewLabel) { + ScrollView(.horizontal) { + Text(DiffPromptFormatter().preview(target: target)) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + Section(noteLabel) { + TextField(notePlaceholder, text: $note, axis: .vertical) + .lineLimit(3...8) + } + if !actions.isAvailable { + Section { + Label(unavailableHint, systemImage: "bubble.left") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle(title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(cancelLabel) { dismiss() } + } + ToolbarItemGroup(placement: .confirmationAction) { + Button(editLabel) { editInComposer() } + .disabled(!actions.isAvailable || isSending) + Button(sendLabel) { send() } + .disabled(!actions.isAvailable || isSending) + } + } + } + } + + @MainActor private func send() { + let prompt = DiffPromptFormatter().format(target: target, note: note) + isSending = true + Task { + await actions.send(prompt) + dismiss() + } + } + + @MainActor private func editInComposer() { + actions.editInComposer(DiffPromptFormatter().format(target: target, note: note)) + dismiss() + dismissViewer() + } + + private var title: String { + DiffLocalized().string("diff.quickNote.title", defaultValue: "Send to Agent") + } + + private var previewLabel: String { + DiffLocalized().string("diff.quickNote.preview", defaultValue: "Excerpt preview") + } + + private var noteLabel: String { + DiffLocalized().string("diff.quickNote.note", defaultValue: "Note") + } + + private var notePlaceholder: String { + DiffLocalized().string("diff.quickNote.notePlaceholder", defaultValue: "What should the agent inspect or change?") + } + + private var sendLabel: String { + DiffLocalized().string("diff.quickNote.send", defaultValue: "Send") + } + + private var editLabel: String { + DiffLocalized().string("diff.quickNote.editInComposer", defaultValue: "Edit in composer") + } + + private var cancelLabel: String { + DiffLocalized().string("diff.action.cancel", defaultValue: "Cancel") + } + + private var unavailableHint: String { + DiffLocalized().string( + "diff.quickNote.unavailable", + defaultValue: "Start an agent chat session in this workspace to send a diff note." + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteTarget.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteTarget.swift new file mode 100644 index 000000000000..9a46e19a979b --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteTarget.swift @@ -0,0 +1,8 @@ +struct DiffQuickNoteTarget: Identifiable, Sendable, Equatable { + let id: String + let path: String + let oldLineRange: ClosedRange? + let newLineRange: ClosedRange? + let hunkHeader: String? + let excerpt: String +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteTargetFactory.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteTargetFactory.swift new file mode 100644 index 000000000000..42a967f060d1 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffQuickNoteTargetFactory.swift @@ -0,0 +1,53 @@ +struct DiffQuickNoteTargetFactory: Sendable { + func fileTarget(state: DiffFilePresentationState) -> DiffQuickNoteTarget { + target( + id: "file:\(state.file.summary.path)", + path: state.file.summary.path, + rows: state.rows, + hunkHeader: state.rows.first(where: { $0.kind == .hunkHeader })?.text + ) + } + + func hunkTarget(state: DiffFilePresentationState, hunkIndex: Int) -> DiffQuickNoteTarget { + let rows = state.rows.filter { $0.hunkIndex == hunkIndex } + return target( + id: "hunk:\(state.file.summary.path):\(hunkIndex)", + path: state.file.summary.path, + rows: rows, + hunkHeader: rows.first(where: { $0.kind == .hunkHeader })?.text + ) + } + + private func target( + id: String, + path: String, + rows: [DiffRowSnapshot], + hunkHeader: String? + ) -> DiffQuickNoteTarget { + let oldLines = rows.compactMap(\.oldLine) + let newLines = rows.compactMap(\.newLine) + return DiffQuickNoteTarget( + id: id, + path: path, + oldLineRange: lineRange(oldLines), + newLineRange: lineRange(newLines), + hunkHeader: hunkHeader, + excerpt: rows.map(excerptLine).joined(separator: "\n") + ) + } + + private func lineRange(_ lines: [Int]) -> ClosedRange? { + guard let lower = lines.min(), let upper = lines.max() else { return nil } + return lower...upper + } + + private func excerptLine(_ row: DiffRowSnapshot) -> String { + switch row.kind { + case .hunkHeader: row.text + case .context: " \(row.text)" + case .addition: "+\(row.text)" + case .deletion: "-\(row.text)" + case .noNewline: "\\ \(row.text)" + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRenderMode.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRenderMode.swift new file mode 100644 index 000000000000..427a7b165156 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRenderMode.swift @@ -0,0 +1,7 @@ +/// The visual layout used to render a diff body. +public enum DiffRenderMode: String, Sendable, CaseIterable, Hashable { + /// A single stream with old and new line-number gutters. + case unified + /// Side-by-side old and new code columns with aligned changes. + case split +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRowBuilder.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRowBuilder.swift new file mode 100644 index 000000000000..666277028f23 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRowBuilder.swift @@ -0,0 +1,56 @@ +import CmuxMobileRPC + +struct DiffRowBuilder: Sendable { + func rows(path: String, hunks: [MobileDiffHunk]) -> [DiffRowSnapshot] { + var result: [DiffRowSnapshot] = [] + for (hunkIndex, hunk) in hunks.enumerated() { + result.append(DiffRowSnapshot( + id: "\(path):\(hunkIndex):header", + kind: .hunkHeader, + oldLine: nil, + newLine: nil, + text: headerText(for: hunk), + hunkIndex: hunkIndex + )) + var oldLine = hunk.oldStart + var newLine = hunk.newStart + for (rowIndex, row) in hunk.rows.enumerated() { + let assignment = lineAssignment(kind: row.kind, oldLine: oldLine, newLine: newLine) + result.append(DiffRowSnapshot( + id: "\(path):\(hunkIndex):\(rowIndex)", + kind: assignment.kind, + oldLine: assignment.oldLine, + newLine: assignment.newLine, + text: row.text, + hunkIndex: hunkIndex + )) + oldLine += assignment.oldAdvance + newLine += assignment.newAdvance + } + } + return DiffRowIntralinePairer().apply(to: result) + } + + private func lineAssignment( + kind: MobileDiffRowKind, + oldLine: Int, + newLine: Int + ) -> (kind: DiffRowKind, oldLine: Int?, newLine: Int?, oldAdvance: Int, newAdvance: Int) { + switch kind { + case .context: + (.context, oldLine, newLine, 1, 1) + case .add: + (.addition, nil, newLine, 0, 1) + case .del: + (.deletion, oldLine, nil, 1, 0) + case .noNewline: + (.noNewline, nil, nil, 0, 0) + } + } + + private func headerText(for hunk: MobileDiffHunk) -> String { + let range = "@@ -\(hunk.oldStart),\(hunk.oldLines) +\(hunk.newStart),\(hunk.newLines) @@" + guard let heading = hunk.sectionHeading, !heading.isEmpty else { return range } + return "\(range) \(heading)" + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRowIntralinePairer.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRowIntralinePairer.swift new file mode 100644 index 000000000000..0089e31bf7e2 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffRowIntralinePairer.swift @@ -0,0 +1,32 @@ +struct DiffRowIntralinePairer: Sendable { + private let differ = IntralineDiffer() + + func apply(to input: [DiffRowSnapshot]) -> [DiffRowSnapshot] { + var rows = input + var index = 0 + while index < rows.count { + guard rows[index].kind == .deletion else { + index += 1 + continue + } + let deletionStart = index + while index < rows.count, rows[index].kind == .deletion { index += 1 } + let additionStart = index + while index < rows.count, rows[index].kind == .addition { index += 1 } + let deletionCount = additionStart - deletionStart + let additionCount = index - additionStart + for offset in 0.. DiffRowSnapshot { + DiffRowSnapshot( + id: id, + kind: kind, + oldLine: oldLine, + newLine: newLine, + text: text, + hunkIndex: hunkIndex, + intralineSpans: spans + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreen.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreen.swift new file mode 100644 index 000000000000..782809f19646 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreen.swift @@ -0,0 +1,110 @@ +public import SwiftUI + +/// Native changed-file list and continuous diff body for one workspace patch set. +public struct DiffScreen: View { + @Binding private var renderMode: DiffRenderMode + @State private var fileStates: [DiffFilePresentationState] + + private let patchSet: DiffPatchSet + private let viewedStore: DiffViewedStore + private let highlighter: any CodeHighlighting + private let actions: DiffScreenActions + + /// Creates a complete diff rendering surface. + /// - Parameters: + /// - patchSet: Immutable file summaries and available bodies. + /// - renderMode: Binding that selects unified or split presentation. + /// - viewedStore: Injected device-local viewed persistence. + /// - highlighter: Injected asynchronous syntax highlighter. + /// - actions: Host callbacks for data operations not owned by this slice. + public init( + patchSet: DiffPatchSet, + renderMode: Binding, + viewedStore: DiffViewedStore, + highlighter: any CodeHighlighting, + actions: DiffScreenActions + ) { + self.patchSet = patchSet + _renderMode = renderMode + self.viewedStore = viewedStore + self.highlighter = highlighter + self.actions = actions + _fileStates = State(initialValue: DiffPresentationBuilder().states( + patchSet: patchSet, + viewedStore: viewedStore + )) + } + + /// The native list and its sticky per-file sections. + public var body: some View { + DiffContinuousView( + fileStates: fileStates, + totalFileCount: fileStates.count, + additions: totalAdditions, + deletions: totalDeletions, + baseLabel: patchSet.baseLabel, + baseKind: .workingTree, + ignoreWhitespace: false, + renderMode: renderMode, + scrollTarget: nil, + scrollRequestID: 0, + showFileIndex: true, + highlighter: highlighter, + actions: DiffContinuousActions( + loadFile: { path, force in + guard let state = fileStates.first(where: { $0.file.summary.path == path }) else { + return + } + if force { + actions.loadLargeFile(state.file) + } else if case .failed = state.file.content { + actions.retryFile(state.file) + } + }, + expandContext: actions.expandContext, + toggleViewed: toggleViewed, + toggleCollapsed: toggleCollapsed, + collapseAll: collapseAll, + selectBase: { _ in }, + setIgnoreWhitespace: { _ in }, + openQuickNote: { _ in }, + quickNoteAvailable: false, + refresh: {} + ) + ) + } + + private var totalAdditions: Int { + fileStates.reduce(0) { $0 + $1.file.summary.additions } + } + + private var totalDeletions: Int { + fileStates.reduce(0) { $0 + $1.file.summary.deletions } + } + + @MainActor private func toggleViewed(path: String) { + guard let index = fileStates.firstIndex(where: { $0.file.summary.path == path }) else { return } + fileStates[index].isViewed.toggle() + if fileStates[index].isViewed { + fileStates[index].isCollapsed = true + } + let state = fileStates[index] + viewedStore.setViewed( + state.isViewed, + workspaceID: patchSet.workspaceID, + path: state.file.summary.path, + patchDigest: state.file.summary.patchDigest + ) + } + + @MainActor private func toggleCollapsed(path: String) { + guard let index = fileStates.firstIndex(where: { $0.file.summary.path == path }) else { return } + fileStates[index].isCollapsed.toggle() + } + + @MainActor private func collapseAll() { + for index in fileStates.indices { + fileStates[index].isCollapsed = true + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenActions.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenActions.swift new file mode 100644 index 000000000000..f0108533ce6b --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenActions.swift @@ -0,0 +1,24 @@ +/// Callbacks emitted by the rendering core for data operations owned by its host. +public struct DiffScreenActions: Sendable { + /// Requests loading a gated large file. + public let loadLargeFile: @MainActor @Sendable (DiffFileSnapshot) -> Void + /// Retries loading a failed file. + public let retryFile: @MainActor @Sendable (DiffFileSnapshot) -> Void + /// Requests additional context around a hunk. + public let expandContext: @MainActor @Sendable (DiffContextExpansionRequest) -> Void + + /// Creates the host action bundle. + /// - Parameters: + /// - loadLargeFile: Called when the user opens a large patch. + /// - retryFile: Called when the user retries a failed patch. + /// - expandContext: Called for hunk context expansion controls. + public init( + loadLargeFile: @escaping @MainActor @Sendable (DiffFileSnapshot) -> Void, + retryFile: @escaping @MainActor @Sendable (DiffFileSnapshot) -> Void, + expandContext: @escaping @MainActor @Sendable (DiffContextExpansionRequest) -> Void + ) { + self.loadLargeFile = loadLargeFile + self.retryFile = retryFile + self.expandContext = expandContext + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenErrorKind.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenErrorKind.swift new file mode 100644 index 000000000000..7d6641422fe0 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenErrorKind.swift @@ -0,0 +1,11 @@ +/// A typed failure suitable for a native diff-screen banner. +public enum DiffScreenErrorKind: Sendable, Equatable { + /// The paired Mac no longer recognizes the workspace identifier. + case unknownWorkspace + /// The workspace directory is not a Git repository. + case notGitRepository + /// The selected agent-turn baseline is unavailable. + case baselineMissing + /// The request failed in transport or returned an unexpected response. + case transport +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenPhase.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenPhase.swift new file mode 100644 index 000000000000..a46111c8c5ce --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenPhase.swift @@ -0,0 +1,11 @@ +/// The summary-level loading state of a live diff screen. +public enum DiffScreenPhase: Sendable, Equatable { + /// No summary request has started. + case idle + /// The initial summary request is in flight. + case loading + /// A summary and its changed-file list are available. + case loaded + /// The initial summary request failed. + case failed(DiffScreenErrorKind) +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenStore.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenStore.swift new file mode 100644 index 000000000000..3ba31f1ea66f --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffScreenStore.swift @@ -0,0 +1,371 @@ +public import CmuxMobileRPC +public import CmuxMobileShell +import Foundation +public import Observation + +/// Main-actor state and reducer operations for one live workspace diff screen. +@MainActor +@Observable +public final class DiffScreenStore { + /// The current summary-level loading state. + public private(set) var phase: DiffScreenPhase = .idle + /// The latest decoded summary, when available. + public private(set) var summary: MobileDiffSummaryResponse? + /// A typed transient failure displayed above existing content. + public private(set) var errorBanner: DiffScreenErrorKind? + /// The baseline currently used by summary, file, and context requests. + public private(set) var baseSpec: MobileDiffBaseSpec + /// Whether whitespace-only changes are ignored for this screen session. + public private(set) var ignoreWhitespace: Bool + /// The persisted layout override used by the screen's overflow menu. + public var layoutOverride: DiffLayoutOverride { + didSet { layoutPreferenceStore.save(layoutOverride) } + } + + @ObservationIgnored private let service: any MobileDiffsServing + @ObservationIgnored private let viewedStore: DiffViewedStore + @ObservationIgnored private let layoutPreferenceStore: DiffLayoutPreferenceStore + private let workspaceRef: String + private let workspaceID: String + private(set) var fileStates: [DiffFilePresentationState] = [] + private var loadingPaths: Set = [] + private var generation = 0 + private var retryOperation: RetryOperation = .summary + + /// Creates a live diff store with injected RPC and persistence dependencies. + /// - Parameters: + /// - service: The shell-owned workspace-diffs service seam. + /// - workspaceRef: The workspace identifier sent over RPC. + /// - workspaceID: Stable device-local identity; defaults to `workspaceRef`. + /// - baseSpec: The Git comparison baseline. + /// - ignoreWhitespace: Whether Git ignores whitespace-only changes. + /// - viewedStore: Device-local per-patch viewed persistence. + /// - layoutPreferenceStore: Persistence for manual unified/split selection. + public init( + service: any MobileDiffsServing, + workspaceRef: String, + workspaceID: String? = nil, + baseSpec: MobileDiffBaseSpec = MobileDiffBaseSpec(kind: .workingTree), + ignoreWhitespace: Bool = false, + viewedStore: DiffViewedStore, + layoutPreferenceStore: DiffLayoutPreferenceStore + ) { + self.service = service + self.workspaceRef = workspaceRef + self.workspaceID = workspaceID ?? workspaceRef + self.baseSpec = baseSpec + self.ignoreWhitespace = ignoreWhitespace + self.viewedStore = viewedStore + self.layoutPreferenceStore = layoutPreferenceStore + self.layoutOverride = layoutPreferenceStore.load() + } + + /// Immutable file snapshots currently projected by the store. + public var files: [DiffFileSnapshot] { + fileStates.map(\.file) + } + + /// Collapsed GitHub-style directory roots for the current changed files. + public var treeNodes: [DiffTreeNode] { + DiffTreeBuilder().build(files: files) + } + + /// The number of current patch revisions marked viewed. + public var viewedCount: Int { + fileStates.count(where: \.isViewed) + } + + /// Starts the initial summary request once. + public func loadInitial() async { + guard phase == .idle else { return } + await fetchSummary(isRefresh: false) + } + + /// Refetches summary metadata while preserving viewed state through persistence. + public func refresh() async { + await fetchSummary(isRefresh: true) + } + + /// Selects a new comparison baseline and refetches the summary. + /// - Parameter kind: The baseline strategy to use for subsequent requests. + public func selectBase(_ kind: MobileDiffBaseKind) async { + guard baseSpec.kind != kind else { return } + baseSpec = MobileDiffBaseSpec(kind: kind) + loadingPaths.removeAll() + await fetchSummary(isRefresh: true) + } + + /// Updates whitespace filtering and refetches the summary. + /// - Parameter enabled: Whether Git should ignore whitespace-only changes. + public func setIgnoreWhitespace(_ enabled: Bool) async { + guard ignoreWhitespace != enabled else { return } + ignoreWhitespace = enabled + loadingPaths.removeAll() + await fetchSummary(isRefresh: true) + } + + /// Recovers from an unavailable agent-turn baseline using the worktree comparison. + public func useWorkingTree() async { + if baseSpec.kind == .workingTree { + await refresh() + } else { + await selectBase(.workingTree) + } + } + + /// Drains every cursor page for one file when it appears or is expanded. + /// - Parameters: + /// - path: The repository-relative file path. + /// - force: Whether to bypass the large-diff gate. + public func loadFile(path: String, force: Bool = false) async { + guard let index = fileStates.firstIndex(where: { $0.file.summary.path == path }), + !loadingPaths.contains(path) else { return } + let current = fileStates[index] + if !force { + switch current.file.content { + case .loaded, .binary, .large, .renameOnly: + return + case .loading, .failed: + break + } + } + + loadingPaths.insert(path) + replaceContent(at: index, with: .loading) + let requestGeneration = generation + let file = current.file.summary + do { + var hunks: [MobileDiffHunk] = [] + var cursor: Int? + var seenCursors: Set = [] + var finalResponse: MobileDiffFileResponse? + repeat { + let response = try await service.fileHunks( + workspaceRef: workspaceRef, + path: file.path, + oldPath: file.oldPath, + baseSpec: baseSpec, + ignoreWhitespace: ignoreWhitespace, + cursor: cursor, + force: force + ) + hunks.append(contentsOf: response.hunks) + finalResponse = response + cursor = response.nextCursor + if let cursor, !seenCursors.insert(cursor).inserted { + throw DiffStoreFailure.repeatedCursor + } + } while cursor != nil + + guard requestGeneration == generation, + let freshIndex = fileStates.firstIndex(where: { $0.file.summary.path == path }), + let finalResponse else { return } + if finalResponse.isBinary { + replaceContent(at: freshIndex, with: .binary) + } else if finalResponse.tooLarge { + replaceContent(at: freshIndex, with: .large) + } else { + replaceContent(at: freshIndex, with: .loaded(hunks)) + } + errorBanner = nil + } catch { + guard requestGeneration == generation, + let freshIndex = fileStates.firstIndex(where: { $0.file.summary.path == path }) else { return } + let kind = errorKind(error) + replaceContent(at: freshIndex, with: .failed(message(for: kind))) + showBanner(kind, retry: .file(path: path, force: force)) + } + loadingPaths.remove(path) + } + + /// Fetches and splices additional new-side context around a hunk. + /// - Parameter request: The file, hunk index, and expansion direction. + public func expandContext(_ request: DiffContextExpansionRequest) async { + guard let index = fileStates.firstIndex(where: { $0.file.summary.path == request.path }), + case let .loaded(hunks) = fileStates[index].file.content else { return } + let splicer = DiffContextSplicer() + let ranges = splicer.ranges(for: request.direction, hunkIndex: request.hunkIndex, hunks: hunks) + guard !ranges.isEmpty else { return } + let requestGeneration = generation + do { + var updated = hunks + for range in ranges { + let response = try await service.contextRows( + workspaceRef: workspaceRef, + path: request.path, + startLine: range.lowerBound, + endLine: range.upperBound, + baseSpec: baseSpec, + ignoreWhitespace: ignoreWhitespace + ) + updated = splicer.splice( + rows: response.rows, + range: range, + into: updated, + hunkIndex: request.hunkIndex + ) + } + guard requestGeneration == generation, + let freshIndex = fileStates.firstIndex(where: { $0.file.summary.path == request.path }) else { + return + } + replaceContent(at: freshIndex, with: .loaded(updated)) + errorBanner = nil + } catch { + guard requestGeneration == generation else { return } + showBanner(errorKind(error), retry: .context(request)) + } + } + + /// Toggles the viewed state for an exact patch digest and persists it. + /// - Parameter path: The repository-relative file path. + public func toggleViewed(path: String) { + guard let index = fileStates.firstIndex(where: { $0.file.summary.path == path }) else { return } + fileStates[index].isViewed.toggle() + if fileStates[index].isViewed { + fileStates[index].isCollapsed = true + } + let state = fileStates[index] + viewedStore.setViewed( + state.isViewed, + workspaceID: workspaceID, + path: state.file.summary.path, + patchDigest: state.file.summary.patchDigest + ) + } + + /// Toggles one file section's collapsed state. + /// - Parameter path: The repository-relative file path. + public func toggleCollapsed(path: String) { + guard let index = fileStates.firstIndex(where: { $0.file.summary.path == path }) else { return } + fileStates[index].isCollapsed.toggle() + } + + /// Collapses every file section. + public func collapseAll() { + for index in fileStates.indices { + fileStates[index].isCollapsed = true + } + } + + /// Retries the operation associated with the current banner. + public func retryBanner() async { + switch retryOperation { + case .summary: + await refresh() + case let .file(path, force): + await loadFile(path: path, force: force) + case let .context(request): + await expandContext(request) + } + } + + /// Clears a transient error banner without changing loaded content. + public func dismissBanner() { + errorBanner = nil + } + + private func fetchSummary(isRefresh: Bool) async { + generation += 1 + let requestGeneration = generation + if summary == nil { + phase = .loading + } + do { + let response = try await service.summary( + workspaceRef: workspaceRef, + baseSpec: baseSpec, + ignoreWhitespace: ignoreWhitespace + ) + guard requestGeneration == generation else { return } + applySummary(response) + phase = .loaded + errorBanner = nil + } catch { + guard requestGeneration == generation else { return } + let kind = errorKind(error) + if summary == nil { + phase = .failed(kind) + } else { + phase = .loaded + showBanner(kind, retry: .summary) + } + } + if isRefresh { + loadingPaths.removeAll() + } + } + + private func applySummary(_ response: MobileDiffSummaryResponse) { + let previousCollapse = Dictionary(uniqueKeysWithValues: fileStates.map { + ($0.file.summary.path, $0.isCollapsed) + }) + summary = response + let builder = DiffPresentationBuilder() + fileStates = response.files.map { summary in + let content: DiffFileContent + if summary.isBinary { + content = .binary + } else if summary.isLarge { + content = .large + } else if (summary.status == .renamed || summary.status == .copied), + summary.additions == 0, summary.deletions == 0 { + content = .renameOnly + } else { + content = .loading + } + return builder.state( + file: DiffFileSnapshot(summary: summary, content: content), + workspaceID: workspaceID, + viewedStore: viewedStore, + isCollapsed: previousCollapse[summary.path] + ) + } + } + + private func replaceContent(at index: Int, with content: DiffFileContent) { + let state = fileStates[index] + var replacement = DiffPresentationBuilder().state( + file: DiffFileSnapshot(summary: state.file.summary, content: content), + workspaceID: workspaceID, + viewedStore: viewedStore, + isCollapsed: state.isCollapsed + ) + replacement.isViewed = state.isViewed + fileStates[index] = replacement + } + + private func showBanner(_ kind: DiffScreenErrorKind, retry: RetryOperation) { + errorBanner = kind + retryOperation = retry + } + + private func errorKind(_ error: any Error) -> DiffScreenErrorKind { + guard let serviceError = error as? MobileDiffsServiceError else { return .transport } + return switch serviceError { + case .unknownWorkspace: .unknownWorkspace + case .notGitRepository: .notGitRepository + case .baselineMissing: .baselineMissing + } + } + + private func message(for kind: DiffScreenErrorKind) -> String { + let localized = DiffLocalized() + return switch kind { + case .unknownWorkspace: + localized.string("diff.error.workspace.message", defaultValue: "This workspace is no longer available on the paired computer.") + case .notGitRepository: + localized.string("diff.error.repository.message", defaultValue: "This workspace is not a Git repository.") + case .baselineMissing: + localized.string("diff.error.baseline.message", defaultValue: "The selected comparison baseline is no longer available.") + case .transport: + localized.string("diff.error.transport.message", defaultValue: "The diff could not be loaded from the paired computer.") + } + } + + private enum RetryOperation { + case summary + case file(path: String, force: Bool) + case context(DiffContextExpansionRequest) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSplitCodeColumn.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSplitCodeColumn.swift new file mode 100644 index 000000000000..23d4d946e547 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSplitCodeColumn.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct DiffSplitCodeColumn: View { + @Environment(\.diffTheme) private var theme + let row: DiffRowSnapshot? + let highlighted: HighlightedCode? + let isOldSide: Bool + + var body: some View { + HStack(spacing: 0) { + Text(lineNumber) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(theme.gutterForeground) + .frame(width: 34, alignment: .trailing) + .padding(.trailing, 4) + Text(marker) + .font(.system(size: 11, design: .monospaced)) + .frame(width: 14) + if let row { + DiffCodeText(row: row, highlighted: highlighted) + } else { + Spacer(minLength: 24) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(fill) + .clipped() + } + + private var lineNumber: String { + guard let row else { return "" } + return (isOldSide ? row.oldLine : row.newLine).map(String.init) ?? "" + } + + private var marker: String { + guard let row else { return "" } + if row.kind == .deletion { return "−" } + if row.kind == .addition { return "+" } + return " " + } + + private var fill: Color { + guard let row else { return .clear } + if row.kind == .deletion { return theme.deletionFill } + if row.kind == .addition { return theme.additionFill } + return .clear + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSplitRowView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSplitRowView.swift new file mode 100644 index 000000000000..1900c763ed7b --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSplitRowView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct DiffSplitRowView: View { + let row: SplitDiffRow + let highlights: [String: HighlightedCode] + let expand: @MainActor (DiffContextExpansionRequest.Direction) -> Void + let quickNoteTarget: DiffQuickNoteTarget? + let quickNoteAvailable: Bool + let openQuickNote: @MainActor (DiffQuickNoteTarget) -> Void + + var body: some View { + if let spanning = row.spanning { + if spanning.kind == .hunkHeader, let quickNoteTarget { + DiffHunkHeaderView( + row: spanning, + expand: expand, + quickNoteTarget: quickNoteTarget, + quickNoteAvailable: quickNoteAvailable, + openQuickNote: openQuickNote + ) + } else { + Text(noNewlineLabel) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 5) + } + } else { + HStack(spacing: 0) { + DiffSplitCodeColumn( + row: row.old, + highlighted: row.old.flatMap { highlights[$0.id] }, + isOldSide: true + ) + Divider() + DiffSplitCodeColumn( + row: row.new, + highlighted: row.new.flatMap { highlights[$0.id] }, + isOldSide: false + ) + } + } + } + + private var noNewlineLabel: String { + DiffLocalized().string("diff.row.noNewline", defaultValue: "No newline at end of file") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffStatusBadge.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffStatusBadge.swift new file mode 100644 index 000000000000..a17905acc279 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffStatusBadge.swift @@ -0,0 +1,49 @@ +import CmuxMobileRPC +import SwiftUI + +struct DiffStatusBadge: View { + let status: MobileDiffFileStatus + + var body: some View { + Text(shortLabel) + .font(.caption2.bold().monospaced()) + .foregroundStyle(color) + .frame(width: 18, height: 18) + .background(color.opacity(0.14), in: RoundedRectangle(cornerRadius: 4)) + .accessibilityLabel(accessibilityLabel) + } + + private var shortLabel: String { + let localized = DiffLocalized() + return switch status { + case .added: localized.string("diff.status.added.short", defaultValue: "A") + case .modified: localized.string("diff.status.modified.short", defaultValue: "M") + case .deleted: localized.string("diff.status.deleted.short", defaultValue: "D") + case .renamed: localized.string("diff.status.renamed.short", defaultValue: "R") + case .copied: localized.string("diff.status.copied.short", defaultValue: "C") + case .untracked: localized.string("diff.status.untracked.short", defaultValue: "U") + } + } + + private var accessibilityLabel: String { + let localized = DiffLocalized() + return switch status { + case .added: localized.string("diff.status.added", defaultValue: "Added") + case .modified: localized.string("diff.status.modified", defaultValue: "Modified") + case .deleted: localized.string("diff.status.deleted", defaultValue: "Deleted") + case .renamed: localized.string("diff.status.renamed", defaultValue: "Renamed") + case .copied: localized.string("diff.status.copied", defaultValue: "Copied") + case .untracked: localized.string("diff.status.untracked", defaultValue: "Untracked") + } + } + + private var color: Color { + switch status { + case .added, .untracked: .green + case .modified: .orange + case .deleted: .red + case .renamed: .blue + case .copied: .purple + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffStoreFailure.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffStoreFailure.swift new file mode 100644 index 000000000000..ca8bd7bbe28c --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffStoreFailure.swift @@ -0,0 +1,3 @@ +enum DiffStoreFailure: Error { + case repeatedCursor +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSummaryHeaderView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSummaryHeaderView.swift new file mode 100644 index 000000000000..727c4f3f8bd6 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffSummaryHeaderView.swift @@ -0,0 +1,129 @@ +import CmuxMobileRPC +import SwiftUI + +struct DiffSummaryHeaderView: View { + let fileCount: Int + let additions: Int + let deletions: Int + let viewedCount: Int + let baseLabel: String + let baseKind: MobileDiffBaseKind + let ignoreWhitespace: Bool + let selectBase: @MainActor (MobileDiffBaseKind) -> Void + let setIgnoreWhitespace: @MainActor (Bool) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(changedFilesLabel) + .font(.headline) + Spacer() + Menu { + Toggle(isOn: whitespaceBinding) { + Label(ignoreWhitespaceLabel, systemImage: "textformat") + } + } label: { + Image(systemName: "ellipsis") + .frame(width: 28, height: 28) + } + .accessibilityLabel(moreLabel) + } + HStack(spacing: 12) { + Text("+\(additions)") + .foregroundStyle(.green) + Text("−\(deletions)") + .foregroundStyle(.red) + Text(viewedLabel) + .foregroundStyle(.secondary) + Spacer() + } + .font(.subheadline.monospacedDigit()) + Menu { + Button(workingTreeLabel) { selectBase(.workingTree) } + Button(lastTurnLabel) { selectBase(.lastTurn) } + Button(branchBaseLabel) { selectBase(.branchBase) } + } label: { + HStack(spacing: 6) { + Text(basePrefix) + .foregroundStyle(.secondary) + Text(displayedBaseLabel) + .fontWeight(.medium) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .accessibilityLabel(basePickerLabel) + .font(.caption) + ProgressView(value: fileCount == 0 ? 0 : Double(viewedCount), total: Double(max(fileCount, 1))) + .tint(.green) + } + .padding(.vertical, 6) + } + + private var changedFilesLabel: String { + let localized = DiffLocalized() + let key: StaticString = fileCount == 1 ? "diff.summary.files.one" : "diff.summary.files.other" + let value: String.LocalizationValue = fileCount == 1 ? "%lld changed file" : "%lld changed files" + return localized.format(key, defaultValue: value, Int64(fileCount)) + } + + private var viewedLabel: String { + DiffLocalized().format( + "diff.summary.viewed", + defaultValue: "%lld of %lld viewed", + Int64(viewedCount), + Int64(fileCount) + ) + } + + private var basePrefix: String { + DiffLocalized().string("diff.summary.base", defaultValue: "Base") + } + + private var moreLabel: String { + DiffLocalized().string("diff.action.more", defaultValue: "More options") + } + + private var whitespaceBinding: Binding { + Binding( + get: { ignoreWhitespace }, + set: { enabled in setIgnoreWhitespace(enabled) } + ) + } + + private var selectedBaseLabel: String { + switch baseKind { + case .workingTree: workingTreeLabel + case .lastTurn: lastTurnLabel + case .branchBase: branchBaseLabel + } + } + + private var displayedBaseLabel: String { + guard !baseLabel.isEmpty, baseLabel != selectedBaseLabel else { + return selectedBaseLabel + } + return "\(selectedBaseLabel) · \(baseLabel)" + } + + private var basePickerLabel: String { + DiffLocalized().string("diff.base.picker", defaultValue: "Comparison base") + } + + private var workingTreeLabel: String { + DiffLocalized().string("diff.base.workingTree", defaultValue: "Working tree") + } + + private var lastTurnLabel: String { + DiffLocalized().string("diff.base.lastTurn", defaultValue: "Last agent turn") + } + + private var branchBaseLabel: String { + DiffLocalized().string("diff.base.branchBase", defaultValue: "Branch base") + } + + private var ignoreWhitespaceLabel: String { + DiffLocalized().string("diff.option.ignoreWhitespace", defaultValue: "Ignore whitespace") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTheme.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTheme.swift new file mode 100644 index 000000000000..51cd30ecec45 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTheme.swift @@ -0,0 +1,66 @@ +public import SwiftUI + +/// Adaptive visual tokens for GitHub-style diff rendering. +public struct DiffTheme: Sendable, Equatable { + /// Subtle green fill for added rows. + public var additionFill: Color + /// Stronger green fill for changed spans inside additions. + public var additionEmphasisFill: Color + /// Subtle red fill for deleted rows. + public var deletionFill: Color + /// Stronger red fill for changed spans inside deletions. + public var deletionEmphasisFill: Color + /// Blue fill for hunk headers. + public var hunkFill: Color + /// Hairline used between gutters and code. + public var hairline: Color + /// Secondary foreground for line-number gutters. + public var gutterForeground: Color + + /// Creates diff tokens with adaptive GitHub-like defaults. + /// - Parameters: + /// - additionFill: Subtle addition background. + /// - additionEmphasisFill: Strong addition-span background. + /// - deletionFill: Subtle deletion background. + /// - deletionEmphasisFill: Strong deletion-span background. + /// - hunkFill: Hunk-header background. + /// - hairline: Divider color. + /// - gutterForeground: Line-number foreground. + public init( + additionFill: Color = .diffAdaptive( + light: Color(red: 0.91, green: 1.0, blue: 0.93), + dark: Color(red: 0.05, green: 0.25, blue: 0.12) + ), + additionEmphasisFill: Color = .diffAdaptive( + light: Color(red: 0.67, green: 0.94, blue: 0.72), + dark: Color(red: 0.10, green: 0.42, blue: 0.20) + ), + deletionFill: Color = .diffAdaptive( + light: Color(red: 1.0, green: 0.92, blue: 0.92), + dark: Color(red: 0.31, green: 0.08, blue: 0.09) + ), + deletionEmphasisFill: Color = .diffAdaptive( + light: Color(red: 1.0, green: 0.72, blue: 0.72), + dark: Color(red: 0.52, green: 0.13, blue: 0.14) + ), + hunkFill: Color = .diffAdaptive( + light: Color(red: 0.87, green: 0.94, blue: 1.0), + dark: Color(red: 0.08, green: 0.20, blue: 0.34) + ), + hairline: Color = .diffAdaptive(light: Color(white: 0.82), dark: Color(white: 0.27)), + gutterForeground: Color = .secondary + ) { + self.additionFill = additionFill + self.additionEmphasisFill = additionEmphasisFill + self.deletionFill = deletionFill + self.deletionEmphasisFill = deletionEmphasisFill + self.hunkFill = hunkFill + self.hairline = hairline + self.gutterForeground = gutterForeground + } +} + +extension EnvironmentValues { + /// The active diff rendering tokens. + @Entry public var diffTheme = DiffTheme() +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeAccumulator.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeAccumulator.swift new file mode 100644 index 000000000000..edc1c21409db --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeAccumulator.swift @@ -0,0 +1,50 @@ +import CmuxMobileRPC + +struct DiffTreeAccumulator: Sendable { + let name: String + let path: String + var status: MobileDiffFileStatus? + var children: [String: DiffTreeAccumulator] + + init(name: String = "", path: String = "") { + self.name = name + self.path = path + status = nil + children = [:] + } + + mutating func insert(components: ArraySlice, status: MobileDiffFileStatus) { + guard let component = components.first else { + self.status = status + return + } + let name = String(component) + let childPath = path.isEmpty ? name : "\(path)/\(name)" + var child = children[name] ?? DiffTreeAccumulator(name: name, path: childPath) + child.insert(components: components.dropFirst(), status: status) + children[name] = child + } + + func node() -> DiffTreeNode { + if let status { + return DiffTreeNode(name: name, path: path, kind: .file(status), children: []) + } + var displayName = name + var displayPath = path + var displayChildren = children.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + while displayChildren.count == 1, displayChildren[0].status == nil { + let onlyChild = displayChildren[0] + displayName = displayName.isEmpty ? onlyChild.name : "\(displayName)/\(onlyChild.name)" + displayPath = onlyChild.path + displayChildren = onlyChild.children.values.sorted { + $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + return DiffTreeNode( + name: displayName, + path: displayPath, + kind: .directory, + children: displayChildren.map { $0.node() } + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeBuilder.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeBuilder.swift new file mode 100644 index 000000000000..406c6941eec2 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeBuilder.swift @@ -0,0 +1,20 @@ +/// Builds a collapsed directory tree from flat changed-file paths. +public struct DiffTreeBuilder: Sendable { + /// Creates a tree builder. + public init() {} + + /// Builds sorted root nodes, collapsing single-child directory chains. + /// - Parameter files: File snapshots whose paths and statuses populate the tree. + /// - Returns: Sorted root directory and file nodes. + public func build(files: [DiffFileSnapshot]) -> [DiffTreeNode] { + var root = DiffTreeAccumulator() + for file in files { + let components = file.summary.path.split(separator: "/", omittingEmptySubsequences: true) + guard !components.isEmpty else { continue } + root.insert(components: components[...], status: file.summary.status) + } + return root.children.values + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + .map { $0.node() } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeNode.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeNode.swift new file mode 100644 index 000000000000..48e990e91ab0 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeNode.swift @@ -0,0 +1,26 @@ +/// One immutable directory or file in a GitHub-style changed-file tree. +public struct DiffTreeNode: Identifiable, Sendable, Equatable { + /// Stable repository-relative identity. + public var id: String { path } + /// Display name, including collapsed directory segments when applicable. + public let name: String + /// Full repository-relative path. + public let path: String + /// Directory or file semantics. + public let kind: DiffTreeNodeKind + /// Sorted child nodes for a directory. + public let children: [DiffTreeNode] + + /// Creates an immutable tree node. + /// - Parameters: + /// - name: Display name for the node. + /// - path: Full repository-relative path. + /// - kind: Directory or file semantics. + /// - children: Sorted directory children. + public init(name: String, path: String, kind: DiffTreeNodeKind, children: [DiffTreeNode]) { + self.name = name + self.path = path + self.kind = kind + self.children = children + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeNodeKind.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeNodeKind.swift new file mode 100644 index 000000000000..3e04163c70d6 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeNodeKind.swift @@ -0,0 +1,9 @@ +public import CmuxMobileRPC + +/// The semantic kind of a node in the changed-file tree. +public enum DiffTreeNodeKind: Sendable, Equatable { + /// A directory, possibly representing a collapsed single-child chain. + case directory + /// A changed file with its Git status. + case file(MobileDiffFileStatus) +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeProgressView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeProgressView.swift new file mode 100644 index 000000000000..f316bb40f807 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeProgressView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct DiffTreeProgressView: View { + let viewedCount: Int + let fileCount: Int + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text(progressLabel) + .font(.subheadline.weight(.semibold)) + ProgressView( + value: fileCount == 0 ? 0 : Double(viewedCount), + total: Double(max(fileCount, 1)) + ) + .tint(.green) + } + .padding(.vertical, 4) + } + + private var progressLabel: String { + DiffLocalized().format( + "diff.summary.viewed", + defaultValue: "%lld of %lld viewed", + Int64(viewedCount), + Int64(fileCount) + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeProjection.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeProjection.swift new file mode 100644 index 000000000000..5b52896e9a2e --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeProjection.swift @@ -0,0 +1,79 @@ +struct DiffTreeProjection: Sendable { + func rows( + nodes: [DiffTreeNode], + files: [DiffFilePresentationState], + collapsedPaths: Set + ) -> [DiffTreeRowSnapshot] { + let states = Dictionary(uniqueKeysWithValues: files.map { ($0.file.summary.path, $0) }) + var result: [DiffTreeRowSnapshot] = [] + append( + nodes: nodes, + depth: 0, + states: states, + collapsedPaths: collapsedPaths, + result: &result + ) + return result + } + + private func append( + nodes: [DiffTreeNode], + depth: Int, + states: [String: DiffFilePresentationState], + collapsedPaths: Set, + result: inout [DiffTreeRowSnapshot] + ) { + for node in nodes { + switch node.kind { + case .directory: + let descendants = descendantStates(node: node, states: states) + let isExpanded = !collapsedPaths.contains(node.path) + result.append(DiffTreeRowSnapshot( + name: node.name, + path: node.path, + depth: depth, + kind: .directory(isExpanded: isExpanded), + additions: descendants.reduce(0) { $0 + $1.file.summary.additions }, + deletions: descendants.reduce(0) { $0 + $1.file.summary.deletions }, + fileCount: descendants.count, + isViewed: !descendants.isEmpty && descendants.allSatisfy(\.isViewed) + )) + if isExpanded { + append( + nodes: node.children, + depth: depth + 1, + states: states, + collapsedPaths: collapsedPaths, + result: &result + ) + } + case let .file(status): + guard let state = states[node.path] else { continue } + result.append(DiffTreeRowSnapshot( + name: node.name, + path: node.path, + depth: depth, + kind: .file(status), + additions: state.file.summary.additions, + deletions: state.file.summary.deletions, + fileCount: 1, + isViewed: state.isViewed + )) + } + } + } + + private func descendantStates( + node: DiffTreeNode, + states: [String: DiffFilePresentationState] + ) -> [DiffFilePresentationState] { + node.children.flatMap { child in + switch child.kind { + case .directory: + descendantStates(node: child, states: states) + case .file: + states[child.path].map { [$0] } ?? [] + } + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowKind.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowKind.swift new file mode 100644 index 000000000000..12607f056fb7 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowKind.swift @@ -0,0 +1,6 @@ +import CmuxMobileRPC + +enum DiffTreeRowKind: Sendable, Equatable { + case directory(isExpanded: Bool) + case file(MobileDiffFileStatus) +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowSnapshot.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowSnapshot.swift new file mode 100644 index 000000000000..82eb2ff5c6bc --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowSnapshot.swift @@ -0,0 +1,11 @@ +struct DiffTreeRowSnapshot: Identifiable, Sendable, Equatable { + var id: String { path } + let name: String + let path: String + let depth: Int + let kind: DiffTreeRowKind + let additions: Int + let deletions: Int + let fileCount: Int + let isViewed: Bool +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowView.swift new file mode 100644 index 000000000000..73f124e39961 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeRowView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct DiffTreeRowView: View { + let row: DiffTreeRowSnapshot + let select: @MainActor () -> Void + + var body: some View { + Button(action: select) { + HStack(spacing: 8) { + Color.clear.frame(width: CGFloat(row.depth) * 14, height: 1) + switch row.kind { + case let .directory(isExpanded): + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.bold()) + .frame(width: 14) + Image(systemName: isExpanded ? "folder.fill.badge.minus" : "folder.fill") + .foregroundStyle(.secondary) + case let .file(status): + DiffStatusBadge(status: status) + } + VStack(alignment: .leading, spacing: 2) { + Text(row.name) + .font(.subheadline) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + if case .directory = row.kind { + Text(fileCountLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + HStack(spacing: 4) { + Text("+\(row.additions)").foregroundStyle(.green) + Text("−\(row.deletions)").foregroundStyle(.red) + } + .font(.caption2.monospacedDigit()) + } + .contentShape(Rectangle()) + .opacity(row.isViewed ? 0.5 : 1) + .padding(.vertical, 2) + } + .buttonStyle(.plain) + } + + private var fileCountLabel: String { + DiffLocalized().format( + "diff.tree.fileCount", + defaultValue: "%lld files", + Int64(row.fileCount) + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeScrollTargetResolver.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeScrollTargetResolver.swift new file mode 100644 index 000000000000..0dba8e2bfb04 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffTreeScrollTargetResolver.swift @@ -0,0 +1,21 @@ +/// Resolves file-tree selections into stable continuous-diff section identifiers. +public struct DiffTreeScrollTargetResolver: Sendable { + /// Creates a target resolver. + public init() {} + + /// Resolves a selected file path when it exists in the current patch set. + /// - Parameters: + /// - path: The repository-relative path selected in the tree. + /// - files: The current diff file snapshots. + /// - Returns: The stable section identifier, or `nil` for stale/non-file selections. + public func target(path: String, files: [DiffFileSnapshot]) -> String? { + files.contains(where: { $0.summary.path == path }) ? sectionID(path: path) : nil + } + + /// Produces the stable section identifier for a repository-relative path. + /// - Parameter path: The file path represented by the section. + /// - Returns: An identifier shared by tree navigation and the continuous list. + public func sectionID(path: String) -> String { + "diff-file:\(path)" + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffUnifiedRowView.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffUnifiedRowView.swift new file mode 100644 index 000000000000..b4f6fced9043 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffUnifiedRowView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct DiffUnifiedRowView: View { + @Environment(\.diffTheme) private var theme + let row: DiffRowSnapshot + let highlighted: HighlightedCode? + let expand: @MainActor (DiffContextExpansionRequest.Direction) -> Void + let quickNoteTarget: DiffQuickNoteTarget? + let quickNoteAvailable: Bool + let openQuickNote: @MainActor (DiffQuickNoteTarget) -> Void + + var body: some View { + if row.kind == .hunkHeader, let quickNoteTarget { + DiffHunkHeaderView( + row: row, + expand: expand, + quickNoteTarget: quickNoteTarget, + quickNoteAvailable: quickNoteAvailable, + openQuickNote: openQuickNote + ) + } else if row.kind == .noNewline { + Text(noNewlineLabel) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 5) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + gutter(row.oldLine) + gutter(row.newLine) + Text(marker) + .frame(width: 18) + .font(.system(size: 12, design: .monospaced)) + DiffCodeText(row: row, highlighted: highlighted) + .padding(.trailing, 10) + } + .frame(minWidth: 360, alignment: .leading) + } + .background(rowFill) + } + } + + private func gutter(_ line: Int?) -> some View { + Text(line.map(String.init) ?? "") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(theme.gutterForeground) + .frame(width: 40, alignment: .trailing) + .padding(.trailing, 6) + .overlay(alignment: .trailing) { + Rectangle().fill(theme.hairline).frame(width: 0.5) + } + } + + private var marker: String { + switch row.kind { + case .addition: "+" + case .deletion: "−" + default: " " + } + } + + private var rowFill: Color { + switch row.kind { + case .addition: theme.additionFill + case .deletion: theme.deletionFill + default: .clear + } + } + + private var noNewlineLabel: String { + DiffLocalized().string("diff.row.noNewline", defaultValue: "No newline at end of file") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffViewedButton.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffViewedButton.swift new file mode 100644 index 000000000000..7db9c78c55c9 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffViewedButton.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct DiffViewedButton: View { + let isViewed: Bool + let action: @MainActor () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: isViewed ? "checkmark.square.fill" : "square") + .foregroundStyle(isViewed ? Color.accentColor : .secondary) + } + .buttonStyle(.plain) + .accessibilityLabel( + DiffLocalized().string( + isViewed ? "diff.viewed.markUnviewed" : "diff.viewed.markViewed", + defaultValue: isViewed ? "Mark as unviewed" : "Mark as viewed" + ) + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffViewedStore.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffViewedStore.swift new file mode 100644 index 000000000000..399468798599 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/DiffViewedStore.swift @@ -0,0 +1,64 @@ +public import Foundation + +/// Persists device-local viewed state keyed by workspace, path, and patch digest. +public struct DiffViewedStore: Sendable { + /// The defaults entry containing the viewed-key map. + public static let defaultsKey = "dev.cmux.mobile.diff.viewed.v1" + + // UserDefaults is Apple-documented thread-safe; the dependency is injected. + private nonisolated(unsafe) let defaults: UserDefaults + + /// Creates a viewed store over an injected defaults suite. + /// - Parameter defaults: The persistence suite; tests should inject an isolated suite. + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// Returns whether this exact patch revision is marked viewed. + /// - Parameters: + /// - workspaceID: Stable workspace identity. + /// - path: Repository-relative file path. + /// - patchDigest: Content digest supplied by the diff service. + /// - Returns: `true` only for an exact workspace, path, and digest match. + public func isViewed(workspaceID: String, path: String, patchDigest: String) -> Bool { + viewedKeys().contains(key(workspaceID: workspaceID, path: path, patchDigest: patchDigest)) + } + + /// Updates viewed state for this exact patch revision. + /// - Parameters: + /// - viewed: The new viewed state. + /// - workspaceID: Stable workspace identity. + /// - path: Repository-relative file path. + /// - patchDigest: Content digest supplied by the diff service. + public func setViewed( + _ viewed: Bool, + workspaceID: String, + path: String, + patchDigest: String + ) { + var keys = viewedKeys() + let storageKey = key(workspaceID: workspaceID, path: path, patchDigest: patchDigest) + if viewed { + keys.insert(storageKey) + } else { + keys.remove(storageKey) + } + if let data = try? JSONEncoder().encode(keys) { + defaults.set(data, forKey: Self.defaultsKey) + } + } + + func key(workspaceID: String, path: String, patchDigest: String) -> String { + [workspaceID, path, patchDigest] + .map { "\($0.utf8.count):\($0)" } + .joined(separator: "|") + } + + private func viewedKeys() -> Set { + guard let data = defaults.data(forKey: Self.defaultsKey), + let keys = try? JSONDecoder().decode(Set.self, from: data) else { + return [] + } + return keys + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/HighlightedCode.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/HighlightedCode.swift new file mode 100644 index 000000000000..fe26046ddd82 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/HighlightedCode.swift @@ -0,0 +1,11 @@ +/// Sendable syntax-highlight output for one source line. +public struct HighlightedCode: Sendable, Equatable { + /// Ordered foreground-color runs. + public let spans: [CodeHighlightSpan] + + /// Creates highlighted output from ordered runs. + /// - Parameter spans: Ordered foreground-color runs. + public init(spans: [CodeHighlightSpan]) { + self.spans = spans + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/HighlighterSwiftCodeHighlighter.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/HighlighterSwiftCodeHighlighter.swift new file mode 100644 index 000000000000..f7766ded86b0 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/HighlighterSwiftCodeHighlighter.swift @@ -0,0 +1,79 @@ +@preconcurrency import Highlighter +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Actor-isolated HighlighterSwift adapter using GitHub light and dark themes. +public actor HighlighterSwiftCodeHighlighter: CodeHighlighting { + private var engines: [DiffColorScheme: Highlighter] = [:] + + /// Creates a lazily initialized HighlighterSwift adapter. + public init() {} + + /// Highlights one source line on this actor's executor. + /// - Parameters: + /// - line: Plain source text. + /// - language: Highlight.js language identifier, when known. + /// - colorScheme: Appearance whose GitHub theme should be used. + /// - Returns: Platform-neutral foreground-color runs. + public func highlight( + line: String, + language: String?, + colorScheme: DiffColorScheme + ) async -> HighlightedCode? { + guard let engine = engine(for: colorScheme), + let attributed = engine.highlight(line, as: language) else { + return nil + } + var spans: [CodeHighlightSpan] = [] + var location = 0 + while location < attributed.length { + var range = NSRange(location: location, length: 0) + let attributes = attributed.attributes(at: location, effectiveRange: &range) + let text = attributed.attributedSubstring(from: range).string + spans.append(CodeHighlightSpan( + text: text, + foreground: color(from: attributes[.foregroundColor]) + )) + location = NSMaxRange(range) + } + return HighlightedCode(spans: spans) + } + + private func engine(for colorScheme: DiffColorScheme) -> Highlighter? { + if let existing = engines[colorScheme] { return existing } + guard let created = Highlighter() else { return nil } + let theme = colorScheme == .dark ? "github-dark" : "github" + guard created.setTheme(theme) else { return nil } + engines[colorScheme] = created + return created + } + + private func color(from value: Any?) -> CodeHighlightColor? { + #if canImport(UIKit) + guard let color = value as? UIColor else { return nil } + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + guard color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil } + return CodeHighlightColor( + red: Double(red), green: Double(green), blue: Double(blue), alpha: Double(alpha) + ) + #elseif canImport(AppKit) + guard let color = (value as? NSColor)?.usingColorSpace(.deviceRGB) else { return nil } + return CodeHighlightColor( + red: Double(color.redComponent), + green: Double(color.greenComponent), + blue: Double(color.blueComponent), + alpha: Double(color.alphaComponent) + ) + #else + return nil + #endif + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineDiffer.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineDiffer.swift new file mode 100644 index 000000000000..1db60ea7098d --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineDiffer.swift @@ -0,0 +1,110 @@ +import Foundation + +struct IntralineDiffer: Sendable { + func changedRanges(old: String, new: String) -> (old: TextRange?, new: TextRange?) { + guard old != new else { return (nil, nil) } + let oldTokens = tokens(in: old) + let newTokens = tokens(in: new) + var prefixCount = 0 + while prefixCount < min(oldTokens.count, newTokens.count), + oldTokens[prefixCount].text == newTokens[prefixCount].text { + prefixCount += 1 + } + + var suffixCount = 0 + while suffixCount < min(oldTokens.count - prefixCount, newTokens.count - prefixCount), + oldTokens[oldTokens.count - suffixCount - 1].text + == newTokens[newTokens.count - suffixCount - 1].text { + suffixCount += 1 + } + + return ( + changedRange(tokens: oldTokens, prefixCount: prefixCount, suffixCount: suffixCount, text: old), + changedRange(tokens: newTokens, prefixCount: prefixCount, suffixCount: suffixCount, text: new) + ) + } + + func spans(text: String, emphasizedRange: TextRange?) -> [IntralineSpan] { + guard let emphasizedRange, !emphasizedRange.isEmpty else { + return [IntralineSpan(text: text, isEmphasized: false)] + } + let characters = Array(text) + var result: [IntralineSpan] = [] + if emphasizedRange.lowerBound > 0 { + result.append(IntralineSpan( + text: String(characters[0.. TextRange? { + let characterCount = text.count + let lower = prefixCount < tokens.count ? tokens[prefixCount].range.lowerBound : characterCount + let suffixIndex = tokens.count - suffixCount + let upper = suffixIndex > 0 ? tokens[suffixIndex - 1].range.upperBound : 0 + let range = TextRange(lowerBound: lower, upperBound: max(lower, upper)) + return range.isEmpty ? nil : range + } + + private func tokens(in text: String) -> [IntralineToken] { + var result: [IntralineToken] = [] + var current = "" + var currentCategory: IntralineToken.Category? + var tokenStart = 0 + var offset = 0 + + for character in text { + let category = category(for: character) + if let currentCategory, currentCategory != category { + result.append(IntralineToken( + text: current, + category: currentCategory, + range: TextRange(lowerBound: tokenStart, upperBound: offset) + )) + current = "" + tokenStart = offset + } + currentCategory = category + current.append(character) + offset += 1 + } + + if let currentCategory { + result.append(IntralineToken( + text: current, + category: currentCategory, + range: TextRange(lowerBound: tokenStart, upperBound: offset) + )) + } + return result + } + + private func category(for character: Character) -> IntralineToken.Category { + if character.unicodeScalars.allSatisfy({ CharacterSet.whitespacesAndNewlines.contains($0) }) { + return .whitespace + } + if character.unicodeScalars.allSatisfy({ + CharacterSet.alphanumerics.contains($0) || $0 == "_" + }) { + return .word + } + return .punctuation + } +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineSpan.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineSpan.swift new file mode 100644 index 000000000000..e628fdfeb154 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineSpan.swift @@ -0,0 +1,4 @@ +struct IntralineSpan: Sendable, Equatable { + let text: String + let isEmphasized: Bool +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineToken.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineToken.swift new file mode 100644 index 000000000000..ed67c797b71f --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/IntralineToken.swift @@ -0,0 +1,11 @@ +struct IntralineToken: Sendable, Equatable { + enum Category: Sendable, Equatable { + case word + case whitespace + case punctuation + } + + let text: String + let category: Category + let range: TextRange +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/Resources/Localizable.xcstrings new file mode 100644 index 000000000000..930a95d38461 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/Resources/Localizable.xcstrings @@ -0,0 +1,89 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "diff.action.cancel" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Cancel" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "キャンセル" } } } }, + "diff.base.branchBase" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Branch base" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ブランチベース" } } } }, + "diff.base.lastTurn" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Last agent turn" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントの前回ターン" } } } }, + "diff.base.picker" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Comparison base" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "比較ベース" } } } }, + "diff.base.workingTree" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Working tree" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "作業ツリー" } } } }, + "diff.error.baseline.fallback" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Use Working tree" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "作業ツリーを使用" } } } }, + "diff.option.ignoreWhitespace" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Ignore whitespace" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "空白を無視" } } } }, + "diff.quickNote.editInComposer" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Edit in composer" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "入力欄で編集" } } } }, + "diff.quickNote.excerptHeading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Excerpt:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "抜粋:" } } } }, + "diff.quickNote.excerptTruncated" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "… [excerpt truncated]" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "… [抜粋を省略]" } } } }, + "diff.quickNote.excerptUnavailable" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Diff excerpt is not loaded." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分の抜粋は読み込まれていません。" } } } }, + "diff.quickNote.fileHeading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "File:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイル:" } } } }, + "diff.quickNote.hunkHeading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Hunk:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ハンク:" } } } }, + "diff.quickNote.linesUnavailable" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "lines unavailable" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "行番号なし" } } } }, + "diff.quickNote.newTargetHeading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "New target:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更後の対象:" } } } }, + "diff.quickNote.noNote" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No additional note." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "追加のメモはありません。" } } } }, + "diff.quickNote.note" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Note" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メモ" } } } }, + "diff.quickNote.noteHeading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Note:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メモ:" } } } }, + "diff.quickNote.notePlaceholder" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "What should the agent inspect or change?" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントに何を確認・変更してほしいですか?" } } } }, + "diff.quickNote.oldTargetHeading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Old target:" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更前の対象:" } } } }, + "diff.quickNote.preview" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Excerpt preview" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "抜粋プレビュー" } } } }, + "diff.quickNote.promptInstruction" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Review this exact diff target and respond to the note." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この差分の対象箇所を確認し、メモに対応してください。" } } } }, + "diff.quickNote.send" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Send" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "送信" } } } }, + "diff.quickNote.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Send to Agent" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントに送信" } } } }, + "diff.quickNote.unavailable" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Start an agent chat session in this workspace to send a diff note." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分メモを送るには、このワークスペースでエージェントチャットを開始してください。" } } } }, + "diff.action.collapseFile" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Collapse file" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイルを折りたたむ" } } } }, + "diff.action.dismiss" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Dismiss" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } } } }, + "diff.action.done" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Done" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "完了" } } } }, + "diff.action.collapseAll" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Collapse all" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "すべて折りたたむ" } } } }, + "diff.action.copyPath" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy path" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "パスをコピー" } } } }, + "diff.action.expandFile" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Expand file" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイルを展開" } } } }, + "diff.action.fileMenu" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "File options" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイルオプション" } } } }, + "diff.action.load" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Load diff" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分を読み込む" } } } }, + "diff.action.more" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "More options" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "その他のオプション" } } } }, + "diff.action.retry" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Retry" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "再試行" } } } }, + "diff.error.baseline.message" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The selected comparison baseline is no longer available." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "選択した比較ベースラインは利用できなくなりました。" } } } }, + "diff.error.baseline.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Baseline unavailable" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ベースラインを利用できません" } } } }, + "diff.error.repository.message" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This workspace is not a Git repository." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "このワークスペースはGitリポジトリではありません。" } } } }, + "diff.error.repository.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Not a Git repository" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "Gitリポジトリではありません" } } } }, + "diff.error.transport.message" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The diff could not be loaded from the paired computer." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ペアリングしたコンピュータから差分を読み込めませんでした。" } } } }, + "diff.error.transport.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Couldn’t load diff" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分を読み込めません" } } } }, + "diff.error.workspace.message" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This workspace is no longer available on the paired computer." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "このワークスペースはペアリングしたコンピュータで利用できなくなりました。" } } } }, + "diff.error.workspace.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Workspace unavailable" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ワークスペースを利用できません" } } } }, + "diff.files.changedTitle" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Changed files" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更ファイル" } } } }, + "diff.files.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Files" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイル" } } } }, + "diff.fixture.baseLabel" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "main · working tree" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "main・作業ツリー" } } } }, + "diff.fixture.loadError" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "The fixture could not load this file." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "フィクスチャでこのファイルを読み込めませんでした。" } } } }, + "diff.fixture.settingsLabel" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Diff rendering (ndv2)" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分レンダリング(ndv2)" } } } }, + "diff.fixture.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Diff rendering" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分レンダリング" } } } }, + "diff.hunk.expandAll" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Expand all context" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "すべてのコンテキストを展開" } } } }, + "diff.hunk.expandDown" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Expand context below" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "下のコンテキストを展開" } } } }, + "diff.hunk.expandUp" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Expand context above" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "上のコンテキストを展開" } } } }, + "diff.mode.automatic" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Automatic" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "自動" } } } }, + "diff.mode.label" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Layout" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "レイアウト" } } } }, + "diff.mode.split" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Split" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "分割" } } } }, + "diff.mode.unified" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Unified" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "統合" } } } }, + "diff.row.noNewline" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No newline at end of file" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイル末尾に改行がありません" } } } }, + "diff.screen.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Changes" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更" } } } }, + "diff.state.binary" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Binary file not shown" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "バイナリファイルは表示されません" } } } }, + "diff.state.loadingFile" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Loading file diff…" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイル差分を読み込み中…" } } } }, + "diff.state.loadingSummary" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Loading changed files…" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更ファイルを読み込み中…" } } } }, + "diff.state.large" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This diff is large. Load it when you're ready." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この差分は大きいため、準備ができたら読み込んでください。" } } } }, + "diff.state.renameOnly" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "File renamed without content changes" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "内容の変更なしでファイル名が変更されました" } } } }, + "diff.status.added" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Added" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "追加" } } } }, + "diff.status.added.short" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "A" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "A" } } } }, + "diff.status.copied" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Copied" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "コピー" } } } }, + "diff.status.copied.short" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "C" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "C" } } } }, + "diff.status.deleted" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Deleted" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "削除" } } } }, + "diff.status.deleted.short" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "D" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "D" } } } }, + "diff.status.modified" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Modified" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更" } } } }, + "diff.status.modified.short" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "M" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "M" } } } }, + "diff.status.renamed" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Renamed" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "名前変更" } } } }, + "diff.status.renamed.short" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "R" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "R" } } } }, + "diff.status.untracked" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Untracked" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "未追跡" } } } }, + "diff.status.untracked.short" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "U" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "U" } } } }, + "diff.summary.base" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Base" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ベース" } } } }, + "diff.summary.files.one" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld changed file" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更ファイル%lld件" } } } }, + "diff.summary.files.other" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld changed files" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更ファイル%lld件" } } } }, + "diff.summary.optionsStub" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Diff options coming next" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分オプションは次のスライスで追加予定" } } } }, + "diff.summary.viewed" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld of %lld viewed" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%2$lld件中%1$lld件を確認済み" } } } }, + "diff.tree.fileCount" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld files" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "%lld件のファイル" } } } }, + "diff.viewed.markUnviewed" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Mark as unviewed" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "未確認にする" } } } }, + "diff.viewed.markViewed" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Mark as viewed" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "確認済みにする" } } } } + }, + "version" : "1.0" +} diff --git a/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/SplitDiffPairer.swift b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/SplitDiffPairer.swift new file mode 100644 index 000000000000..cb59a9ac4e34 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Sources/CmuxDiffUI/SplitDiffPairer.swift @@ -0,0 +1,54 @@ +struct SplitDiffPairer: Sendable { + func pair(rows: [DiffRowSnapshot]) -> [SplitDiffRow] { + var result: [SplitDiffRow] = [] + var index = 0 + while index < rows.count { + let row = rows[index] + switch row.kind { + case .hunkHeader, .noNewline: + result.append(SplitDiffRow( + id: "split:\(row.id)", + kind: .spanning, + old: nil, + new: nil, + spanning: row + )) + index += 1 + case .context: + result.append(SplitDiffRow( + id: "split:\(row.id)", + kind: .code, + old: row, + new: row, + spanning: nil + )) + index += 1 + case .deletion, .addition: + let start = index + while index < rows.count, + rows[index].kind == .deletion || rows[index].kind == .addition { + index += 1 + } + appendChangeRun(Array(rows[start..= upperBound } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffLanguageMapperTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffLanguageMapperTests.swift new file mode 100644 index 000000000000..1b2372ed76ca --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffLanguageMapperTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import CmuxDiffUI + +@Suite struct DiffLanguageMapperTests { + @Test(arguments: [ + ("Sources/App.swift", "swift"), + ("web/view.tsx", "typescript"), + ("config/settings.json", "json"), + ("scripts/reload.sh", "bash"), + ("Dockerfile", "dockerfile"), + ("Package.swift", "swift"), + ("README.md", "markdown"), + ]) + func mapsKnownFilenames(filename: String, expected: String) { + #expect(DiffLanguageMapper().language(for: filename) == expected) + } + + @Test func returnsNilForUnknownExtension() { + #expect(DiffLanguageMapper().language(for: "Assets/blob.cmuxunknown") == nil) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffPromptFormatterTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffPromptFormatterTests.swift new file mode 100644 index 000000000000..8aaf4ad88d9f --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffPromptFormatterTests.swift @@ -0,0 +1,74 @@ +import Testing +@testable import CmuxDiffUI + +@Suite struct DiffPromptFormatterTests { + @Test func includesFileLineReferencesFenceExcerptAndMultilineNote() { + let prompt = DiffPromptFormatter().format( + target: target( + path: "Sources/App.swift", + old: 10...12, + new: 10...13, + excerpt: "@@ -10,3 +10,4 @@ render\n-old\n+new" + ), + note: "Check the behavior.\nKeep the API stable." + ) + + #expect(prompt.contains("New target: Sources/App.swift:10-13")) + #expect(prompt.contains("Old target: Sources/App.swift:10-12")) + #expect(prompt.contains("```diff\n@@ -10,3 +10,4 @@ render\n-old\n+new\n```")) + #expect(prompt.hasSuffix("Check the behavior.\nKeep the API stable.")) + } + + @Test func preservesUnicodePathAndEscapesEmbeddedNewline() { + let prompt = DiffPromptFormatter().format( + target: target(path: "資料/こんにちは\n画面.swift", old: nil, new: 7...7, excerpt: "+値"), + note: "確認" + ) + + #expect(prompt.contains("資料/こんにちは\\n画面.swift:7")) + #expect(prompt.contains("+値")) + #expect(prompt.hasSuffix("確認")) + } + + @Test func fenceExpandsPastBackticksInsideExcerpt() { + let prompt = DiffPromptFormatter().format( + target: target(path: "README.md", old: 1...1, new: 1...1, excerpt: "+```swift\n+let x = 1\n+```"), + note: "" + ) + + #expect(prompt.contains("````diff\n+```swift")) + #expect(prompt.contains("+```\n````")) + } + + @Test func truncatesLongExcerptWithoutSplittingUnicodeCharacters() { + let prompt = DiffPromptFormatter(maximumExcerptCharacters: 80).format( + target: target( + path: "Sources/長い.swift", + old: 1...200, + new: 1...200, + excerpt: String(repeating: "+🧪test\n", count: 100) + ), + note: "short" + ) + + #expect(prompt.contains("… [excerpt truncated]")) + #expect(!prompt.contains(String(repeating: "+🧪test\n", count: 20))) + #expect(prompt.hasSuffix("short")) + } + + private func target( + path: String, + old: ClosedRange?, + new: ClosedRange?, + excerpt: String + ) -> DiffQuickNoteTarget { + DiffQuickNoteTarget( + id: "target", + path: path, + oldLineRange: old, + newLineRange: new, + hunkHeader: "@@ target @@", + excerpt: excerpt + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffRowBuilderTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffRowBuilderTests.swift new file mode 100644 index 000000000000..fee8cecfc069 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffRowBuilderTests.swift @@ -0,0 +1,46 @@ +import CmuxMobileRPC +import Testing +@testable import CmuxDiffUI + +@Suite struct DiffRowBuilderTests { + @Test func assignsLineNumbersAndLeavesMarkerUnnumbered() { + let hunk = MobileDiffHunk( + oldStart: 10, + oldLines: 3, + newStart: 20, + newLines: 3, + sectionHeading: "body", + rows: [ + MobileDiffRow(kind: .context, oldNo: 999, newNo: 999, text: "context"), + MobileDiffRow(kind: .del, text: "old"), + MobileDiffRow(kind: .noNewline, text: ""), + MobileDiffRow(kind: .add, text: "new"), + MobileDiffRow(kind: .context, text: "tail"), + ] + ) + + let rows = DiffRowBuilder().rows(path: "file.swift", hunks: [hunk]) + + #expect(rows[0].text == "@@ -10,3 +20,3 @@ body") + #expect(rows[1].oldLine == 10 && rows[1].newLine == 20) + #expect(rows[2].oldLine == 11 && rows[2].newLine == nil) + #expect(rows[3].oldLine == nil && rows[3].newLine == nil) + #expect(rows[4].oldLine == nil && rows[4].newLine == 21) + #expect(rows[5].oldLine == 12 && rows[5].newLine == 22) + } + + @Test func omitsEmptySectionHeadingFromHeader() { + let hunk = MobileDiffHunk( + oldStart: 1, + oldLines: 0, + newStart: 1, + newLines: 1, + sectionHeading: "", + rows: [MobileDiffRow(kind: .add, text: "new")] + ) + + let rows = DiffRowBuilder().rows(path: "file.swift", hunks: [hunk]) + + #expect(rows.first?.text == "@@ -1,0 +1,1 @@") + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffScreenStoreTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffScreenStoreTests.swift new file mode 100644 index 000000000000..a9d19bda9e5f --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffScreenStoreTests.swift @@ -0,0 +1,307 @@ +import CmuxMobileRPC +import CmuxMobileShell +import Foundation +import Testing +@testable import CmuxDiffUI + +@MainActor +@Suite struct DiffScreenStoreTests { + @Test func summaryBuildsFileStatesAndRespectsStaticGates() async throws { + let summaries = [ + file(path: "Sources/App.swift", status: .modified, additions: 2, deletions: 1), + file(path: "Assets/icon.png", status: .modified, isBinary: true), + file(path: "Generated/API.swift", status: .modified, additions: 4000, isLarge: true), + file(path: "New.swift", oldPath: "Old.swift", status: .renamed), + ] + let fake = FakeMobileDiffsService(summaries: [.success(summary(files: summaries))]) + let store = try makeStore(fake: fake, name: "summary") + + await store.loadInitial() + + #expect(store.phase == .loaded) + #expect(store.files.map(\.summary.path) == summaries.map(\.path)) + #expect(store.fileStates.map(\.file.content) == [.loading, .binary, .large, .renameOnly]) + } + + @Test func fileLoadDrainsEveryCursorPage() async throws { + let first = hunk(oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, text: "one") + let second = hunk(oldStart: 5, oldLines: 1, newStart: 5, newLines: 1, text: "two") + let fake = FakeMobileDiffsService( + summaries: [.success(summary(files: [file(path: "App.swift", additions: 1)]))], + files: [ + .success(MobileDiffFileResponse( + hunks: [first], isBinary: false, tooLarge: false, nextCursor: 7 + )), + .success(MobileDiffFileResponse(hunks: [second], isBinary: false, tooLarge: false)), + ] + ) + let store = try makeStore(fake: fake, name: "paging") + await store.loadInitial() + + await store.loadFile(path: "App.swift") + + let loaded = try #require(store.fileStates.first?.file.content) + guard case let .loaded(hunks) = loaded else { + Issue.record("Expected loaded hunks") + return + } + #expect(hunks == [first, second]) + #expect(await fake.requestedCursors == [nil, 7]) + #expect(await fake.requestedForces == [false, false]) + } + + @Test func largeFileRequiresForceBeforeMakingARequest() async throws { + let fake = FakeMobileDiffsService( + summaries: [.success(summary(files: [ + file(path: "Large.swift", additions: 4000, isLarge: true), + ]))], + files: [.success(MobileDiffFileResponse(hunks: [], isBinary: false, tooLarge: false))] + ) + let store = try makeStore(fake: fake, name: "force") + await store.loadInitial() + + await store.loadFile(path: "Large.swift") + #expect(await fake.requestedForces.isEmpty) + + await store.loadFile(path: "Large.swift", force: true) + #expect(await fake.requestedForces == [true]) + } + + @Test func contextExpansionSplicesRowsAndUsesCumulativeHunkDelta() async throws { + let first = MobileDiffHunk( + oldStart: 1, + oldLines: 2, + newStart: 1, + newLines: 3, + rows: [ + MobileDiffRow(kind: .context, text: "a"), + MobileDiffRow(kind: .add, text: "inserted"), + MobileDiffRow(kind: .context, text: "b"), + ] + ) + let second = MobileDiffHunk( + oldStart: 6, + oldLines: 1, + newStart: 7, + newLines: 1, + rows: [MobileDiffRow(kind: .context, text: "target")] + ) + let fake = FakeMobileDiffsService( + summaries: [.success(summary(files: [file(path: "App.swift", additions: 1)]))], + files: [.success(MobileDiffFileResponse( + hunks: [first, second], isBinary: false, tooLarge: false + ))], + contexts: [.success(MobileDiffContextResponse(rows: ["c", "d", "e"]))] + ) + let store = try makeStore(fake: fake, name: "context") + await store.loadInitial() + await store.loadFile(path: "App.swift") + + await store.expandContext(DiffContextExpansionRequest( + path: "App.swift", hunkIndex: 1, direction: .up + )) + + #expect(await fake.requestedContextRanges == [4...6]) + let content = try #require(store.fileStates.first?.file.content) + guard case let .loaded(hunks) = content else { + Issue.record("Expected context-expanded hunks") + return + } + #expect(hunks[1].oldStart == 3) + #expect(hunks[1].newStart == 4) + #expect(hunks[1].oldLines == 4) + #expect(hunks[1].newLines == 4) + #expect(hunks[1].rows.prefix(3).map(\.oldNo) == [3, 4, 5]) + #expect(hunks[1].rows.prefix(3).map(\.newNo) == [4, 5, 6]) + + let rendered = DiffRowBuilder().rows(path: "App.swift", hunks: [hunks[1]]) + #expect(rendered[1].oldLine == 3 && rendered[1].newLine == 4) + #expect(rendered[4].oldLine == 6 && rendered[4].newLine == 7) + } + + @Test(arguments: [ + (MobileDiffsServiceError.unknownWorkspace, DiffScreenErrorKind.unknownWorkspace), + (MobileDiffsServiceError.notGitRepository, DiffScreenErrorKind.notGitRepository), + (MobileDiffsServiceError.baselineMissing, DiffScreenErrorKind.baselineMissing), + ]) + func mapsStructuredSummaryErrors( + serviceError: MobileDiffsServiceError, + expected: DiffScreenErrorKind + ) async throws { + let fake = FakeMobileDiffsService(summaries: [.serviceFailure(serviceError)]) + let store = try makeStore(fake: fake, name: "error-\(expected)") + + await store.loadInitial() + + #expect(store.phase == .failed(expected)) + } + + @Test func mapsUnexpectedSummaryErrorsToTransport() async throws { + let fake = FakeMobileDiffsService(summaries: [.transportFailure]) + let store = try makeStore(fake: fake, name: "transport") + + await store.loadInitial() + + #expect(store.phase == .failed(.transport)) + } + + @Test func viewedStatePersistsAcrossSummaryRefetch() async throws { + let response = summary(files: [file(path: "App.swift", additions: 1, digest: "same")]) + let fake = FakeMobileDiffsService(summaries: [.success(response), .success(response)]) + let store = try makeStore(fake: fake, name: "viewed") + await store.loadInitial() + + store.toggleViewed(path: "App.swift") + #expect(store.viewedCount == 1) + + await store.refresh() + #expect(store.viewedCount == 1) + #expect(store.fileStates.first?.isViewed == true) + } + + @Test func basePickerFallsBackFromMissingLastTurnAndPreservesViewedPatch() async throws { + let response = summary(files: [file(path: "App.swift", additions: 1, digest: "stable")]) + let fake = FakeMobileDiffsService(summaries: [ + .success(response), + .serviceFailure(.baselineMissing), + .success(response), + ]) + let store = try makeStore(fake: fake, name: "base-fallback") + await store.loadInitial() + store.toggleViewed(path: "App.swift") + + await store.selectBase(.lastTurn) + + #expect(store.baseSpec.kind == .lastTurn) + #expect(store.phase == .loaded) + #expect(store.errorBanner == .baselineMissing) + #expect(store.viewedCount == 1) + + await store.useWorkingTree() + + #expect(store.baseSpec.kind == .workingTree) + #expect(store.errorBanner == nil) + #expect(store.viewedCount == 1) + #expect(await fake.requestedSummaryBases.map(\.kind) == [ + .workingTree, .lastTurn, .workingTree, + ]) + } + + @Test func whitespaceSelectionPropagatesToSummaryFileAndContextRequests() async throws { + let loadedHunk = hunk(oldStart: 2, oldLines: 1, newStart: 2, newLines: 1, text: "line") + let response = summary(files: [file(path: "App.swift", additions: 1)]) + let fake = FakeMobileDiffsService( + summaries: [.success(response), .success(response)], + files: [.success(MobileDiffFileResponse( + hunks: [loadedHunk], isBinary: false, tooLarge: false + ))], + contexts: [.success(MobileDiffContextResponse(rows: ["context"]))] + ) + let store = try makeStore(fake: fake, name: "whitespace") + await store.loadInitial() + + await store.setIgnoreWhitespace(true) + await store.loadFile(path: "App.swift") + await store.expandContext(DiffContextExpansionRequest( + path: "App.swift", hunkIndex: 0, direction: .up + )) + + #expect(store.ignoreWhitespace) + #expect(await fake.requestedSummaryWhitespace == [false, true]) + #expect(await fake.requestedFileWhitespace == [true]) + #expect(await fake.requestedContextWhitespace == [true]) + #expect(await fake.requestedFileBases.map(\.kind) == [.workingTree]) + #expect(await fake.requestedContextBases.map(\.kind) == [.workingTree]) + } + + @Test func layoutOverridePersistsAndAutomaticResolvesByOrientation() throws { + let defaults = try defaults(name: "layout") + let fake = FakeMobileDiffsService(summaries: []) + let store = DiffScreenStore( + service: fake, + workspaceRef: "workspace", + viewedStore: DiffViewedStore(defaults: defaults), + layoutPreferenceStore: DiffLayoutPreferenceStore(defaults: defaults) + ) + #expect(store.layoutOverride == .automatic) + #expect(store.layoutOverride.renderMode(isLandscape: false) == .unified) + #expect(store.layoutOverride.renderMode(isLandscape: true) == .split) + + store.layoutOverride = .split + let reloaded = DiffScreenStore( + service: fake, + workspaceRef: "workspace", + viewedStore: DiffViewedStore(defaults: defaults), + layoutPreferenceStore: DiffLayoutPreferenceStore(defaults: defaults) + ) + #expect(reloaded.layoutOverride == .split) + } + + private func makeStore(fake: FakeMobileDiffsService, name: String) throws -> DiffScreenStore { + let defaults = try defaults(name: name) + return DiffScreenStore( + service: fake, + workspaceRef: "workspace", + viewedStore: DiffViewedStore(defaults: defaults), + layoutPreferenceStore: DiffLayoutPreferenceStore(defaults: defaults) + ) + } + + private func defaults(name: String) throws -> UserDefaults { + let suite = "DiffScreenStoreTests.\(name)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return defaults + } + + private func summary(files: [MobileDiffFileSummary]) -> MobileDiffSummaryResponse { + MobileDiffSummaryResponse( + baseInfo: MobileDiffBaseInfo(kind: .workingTree, resolvedRef: "HEAD", describe: "HEAD"), + totals: MobileDiffTotals( + files: files.count, + additions: files.reduce(0) { $0 + $1.additions }, + deletions: files.reduce(0) { $0 + $1.deletions } + ), + files: files, + truncatedFileCount: 0 + ) + } + + private func file( + path: String, + oldPath: String? = nil, + status: MobileDiffFileStatus = .modified, + additions: Int = 0, + deletions: Int = 0, + isBinary: Bool = false, + isLarge: Bool = false, + digest: String = "digest" + ) -> MobileDiffFileSummary { + MobileDiffFileSummary( + path: path, + oldPath: oldPath, + status: status, + additions: additions, + deletions: deletions, + isBinary: isBinary, + isLarge: isLarge, + patchDigest: digest + ) + } + + private func hunk( + oldStart: Int, + oldLines: Int, + newStart: Int, + newLines: Int, + text: String + ) -> MobileDiffHunk { + MobileDiffHunk( + oldStart: oldStart, + oldLines: oldLines, + newStart: newStart, + newLines: newLines, + rows: [MobileDiffRow(kind: .context, text: text)] + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffTreeBuilderTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffTreeBuilderTests.swift new file mode 100644 index 000000000000..05da85684757 --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffTreeBuilderTests.swift @@ -0,0 +1,54 @@ +import CmuxMobileRPC +import Testing +@testable import CmuxDiffUI + +@Suite struct DiffTreeBuilderTests { + @Test func buildsDirectoriesAndFilesFromPathComponents() throws { + let nodes = DiffTreeBuilder().build(files: [ + file("Sources/App.swift", .modified), + file("Tests/AppTests.swift", .added), + ]) + + #expect(nodes.map(\.name) == ["Sources", "Tests"]) + let sources = try #require(nodes.first) + #expect(sources.children.first?.name == "App.swift") + #expect(sources.children.first?.kind == .file(.modified)) + } + + @Test func collapsesSingleChildDirectoryChainsButNotTheFile() throws { + let nodes = DiffTreeBuilder().build(files: [ + file("Sources/Feature/Models/Item.swift", .modified), + ]) + + let directory = try #require(nodes.first) + #expect(directory.name == "Sources/Feature/Models") + #expect(directory.path == "Sources/Feature/Models") + #expect(directory.children.map(\.name) == ["Item.swift"]) + } + + @Test func keepsBranchingDirectoriesExpanded() throws { + let nodes = DiffTreeBuilder().build(files: [ + file("Sources/Feature/A.swift", .added), + file("Sources/Feature/B.swift", .deleted), + ]) + + let directory = try #require(nodes.first) + #expect(directory.name == "Sources/Feature") + #expect(directory.children.map(\.name) == ["A.swift", "B.swift"]) + } + + private func file(_ path: String, _ status: MobileDiffFileStatus) -> DiffFileSnapshot { + DiffFileSnapshot( + summary: MobileDiffFileSummary( + path: path, + status: status, + additions: 0, + deletions: 0, + isBinary: false, + isLarge: false, + patchDigest: "digest" + ), + content: .renameOnly + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffTreeScrollTargetResolverTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffTreeScrollTargetResolverTests.swift new file mode 100644 index 000000000000..869ad8bde43e --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffTreeScrollTargetResolverTests.swift @@ -0,0 +1,27 @@ +import CmuxMobileRPC +import Testing +@testable import CmuxDiffUI + +@Suite struct DiffTreeScrollTargetResolverTests { + @Test func resolvesCurrentFileAndRejectsStaleOrDirectoryPaths() { + let files = [ + DiffFileSnapshot( + summary: MobileDiffFileSummary( + path: "Sources/App.swift", + status: .modified, + additions: 1, + deletions: 0, + isBinary: false, + isLarge: false, + patchDigest: "digest" + ), + content: .loading + ), + ] + let resolver = DiffTreeScrollTargetResolver() + + #expect(resolver.target(path: "Sources/App.swift", files: files) == "diff-file:Sources/App.swift") + #expect(resolver.target(path: "Sources", files: files) == nil) + #expect(resolver.target(path: "Deleted.swift", files: files) == nil) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffViewedStoreTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffViewedStoreTests.swift new file mode 100644 index 000000000000..5a2e4350f47d --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/DiffViewedStoreTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import CmuxDiffUI + +@Suite struct DiffViewedStoreTests { + @Test func exactWorkspacePathAndDigestRoundTrip() throws { + let environment = try makeEnvironment() + defer { environment.defaults.removePersistentDomain(forName: environment.suiteName) } + let store = DiffViewedStore(defaults: environment.defaults) + + store.setViewed(true, workspaceID: "workspace", path: "Sources/App.swift", patchDigest: "v1") + + #expect(store.isViewed(workspaceID: "workspace", path: "Sources/App.swift", patchDigest: "v1")) + #expect(!store.isViewed(workspaceID: "other", path: "Sources/App.swift", patchDigest: "v1")) + #expect(!store.isViewed(workspaceID: "workspace", path: "Other.swift", patchDigest: "v1")) + } + + @Test func changedPatchDigestInvalidatesViewedState() throws { + let environment = try makeEnvironment() + defer { environment.defaults.removePersistentDomain(forName: environment.suiteName) } + let store = DiffViewedStore(defaults: environment.defaults) + + store.setViewed(true, workspaceID: "workspace", path: "App.swift", patchDigest: "old") + + #expect(store.isViewed(workspaceID: "workspace", path: "App.swift", patchDigest: "old")) + #expect(!store.isViewed(workspaceID: "workspace", path: "App.swift", patchDigest: "new")) + } + + @Test func clearingViewedStatePersists() throws { + let environment = try makeEnvironment() + defer { environment.defaults.removePersistentDomain(forName: environment.suiteName) } + let store = DiffViewedStore(defaults: environment.defaults) + store.setViewed(true, workspaceID: "workspace", path: "App.swift", patchDigest: "v1") + + store.setViewed(false, workspaceID: "workspace", path: "App.swift", patchDigest: "v1") + let reloaded = DiffViewedStore(defaults: environment.defaults) + + #expect(!reloaded.isViewed(workspaceID: "workspace", path: "App.swift", patchDigest: "v1")) + } + + private func makeEnvironment() throws -> (defaults: UserDefaults, suiteName: String) { + let suiteName = "DiffViewedStoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return (defaults, suiteName) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeDiffResponse.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeDiffResponse.swift new file mode 100644 index 000000000000..171324d9108b --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeDiffResponse.swift @@ -0,0 +1,7 @@ +import CmuxMobileShell + +enum FakeDiffResponse: Sendable { + case success(Value) + case serviceFailure(MobileDiffsServiceError) + case transportFailure +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeDiffTransportError.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeDiffTransportError.swift new file mode 100644 index 000000000000..ae003eb0088c --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeDiffTransportError.swift @@ -0,0 +1 @@ +struct FakeDiffTransportError: Error, Sendable {} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeMobileDiffsService.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeMobileDiffsService.swift new file mode 100644 index 000000000000..76bdf7cf108f --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/FakeMobileDiffsService.swift @@ -0,0 +1,78 @@ +import CmuxMobileRPC +import CmuxMobileShell + +actor FakeMobileDiffsService: MobileDiffsServing { + private var summaries: [FakeDiffResponse] + private var files: [FakeDiffResponse] + private var contexts: [FakeDiffResponse] + private(set) var requestedCursors: [Int?] = [] + private(set) var requestedForces: [Bool] = [] + private(set) var requestedContextRanges: [ClosedRange] = [] + private(set) var requestedSummaryBases: [MobileDiffBaseSpec] = [] + private(set) var requestedFileBases: [MobileDiffBaseSpec] = [] + private(set) var requestedContextBases: [MobileDiffBaseSpec] = [] + private(set) var requestedSummaryWhitespace: [Bool] = [] + private(set) var requestedFileWhitespace: [Bool] = [] + private(set) var requestedContextWhitespace: [Bool] = [] + + init( + summaries: [FakeDiffResponse], + files: [FakeDiffResponse] = [], + contexts: [FakeDiffResponse] = [] + ) { + self.summaries = summaries + self.files = files + self.contexts = contexts + } + + func summary( + workspaceRef: String, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool + ) async throws -> MobileDiffSummaryResponse { + requestedSummaryBases.append(baseSpec) + requestedSummaryWhitespace.append(ignoreWhitespace) + return try resolve(summaries.removeFirst()) + } + + func fileHunks( + workspaceRef: String, + path: String, + oldPath: String?, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool, + cursor: Int?, + force: Bool + ) async throws -> MobileDiffFileResponse { + requestedFileBases.append(baseSpec) + requestedFileWhitespace.append(ignoreWhitespace) + requestedCursors.append(cursor) + requestedForces.append(force) + return try resolve(files.removeFirst()) + } + + func contextRows( + workspaceRef: String, + path: String, + startLine: Int, + endLine: Int, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool + ) async throws -> MobileDiffContextResponse { + requestedContextBases.append(baseSpec) + requestedContextWhitespace.append(ignoreWhitespace) + requestedContextRanges.append(startLine...endLine) + return try resolve(contexts.removeFirst()) + } + + private func resolve(_ response: FakeDiffResponse) throws -> Value { + switch response { + case let .success(value): + value + case let .serviceFailure(error): + throw error + case .transportFailure: + throw FakeDiffTransportError() + } + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/IntralineDifferTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/IntralineDifferTests.swift new file mode 100644 index 000000000000..679b39514bba --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/IntralineDifferTests.swift @@ -0,0 +1,81 @@ +import Testing +@testable import CmuxDiffUI + +@Suite struct IntralineDifferTests { + @Test func pureAdditionAndDeletionEmphasizeTheWholePresentSide() { + let differ = IntralineDiffer() + let addition = differ.changedRanges(old: "", new: "hello world") + let deletion = differ.changedRanges(old: "goodbye", new: "") + + #expect(addition.old == nil) + #expect(addition.new == TextRange(lowerBound: 0, upperBound: 11)) + #expect(deletion.old == TextRange(lowerBound: 0, upperBound: 7)) + #expect(deletion.new == nil) + } + + @Test func commonPrefixAndSuffixLeaveOnlyChangedWords() { + let ranges = IntralineDiffer().changedRanges( + old: "let greeting = hello world", + new: "let greeting = welcome world" + ) + + #expect(ranges.old == TextRange(lowerBound: 15, upperBound: 20)) + #expect(ranges.new == TextRange(lowerBound: 15, upperBound: 22)) + } + + @Test func equalDeletionAndAdditionCountsPairByPosition() { + let rows = [ + row(id: "d1", kind: .deletion, text: "let value = old"), + row(id: "d2", kind: .deletion, text: "return old"), + row(id: "a1", kind: .addition, text: "let value = new"), + row(id: "a2", kind: .addition, text: "return new"), + ] + + let paired = DiffRowIntralinePairer().apply(to: rows) + + #expect(paired.allSatisfy { !$0.intralineSpans.isEmpty }) + #expect(paired[0].intralineSpans.contains { $0.isEmphasized && $0.text == "old" }) + #expect(paired[2].intralineSpans.contains { $0.isEmphasized && $0.text == "new" }) + } + + @Test func unpairedPureAdditionAndDeletionRunsRemainUnemphasized() { + let deletion = DiffRowIntralinePairer().apply(to: [ + row(id: "d", kind: .deletion, text: "removed"), + ]) + let addition = DiffRowIntralinePairer().apply(to: [ + row(id: "a", kind: .addition, text: "added"), + ]) + + #expect(deletion[0].intralineSpans.isEmpty) + #expect(addition[0].intralineSpans.isEmpty) + } + + @Test func separateChangeRunsDoNotPairAcrossContext() { + let rows = [ + row(id: "d1", kind: .deletion, text: "old one"), + row(id: "a1", kind: .addition, text: "new one"), + row(id: "c", kind: .context, text: "separator"), + row(id: "d2", kind: .deletion, text: "old two"), + row(id: "a2", kind: .addition, text: "new two"), + ] + + let paired = DiffRowIntralinePairer().apply(to: rows) + + #expect(paired[0].intralineSpans.contains { $0.isEmphasized && $0.text == "old" }) + #expect(paired[1].intralineSpans.contains { $0.isEmphasized && $0.text == "new" }) + #expect(paired[2].intralineSpans.isEmpty) + #expect(paired[3].intralineSpans.contains { $0.isEmphasized && $0.text == "old" }) + #expect(paired[4].intralineSpans.contains { $0.isEmphasized && $0.text == "new" }) + } + + private func row(id: String, kind: DiffRowKind, text: String) -> DiffRowSnapshot { + DiffRowSnapshot( + id: id, + kind: kind, + oldLine: nil, + newLine: nil, + text: text, + hunkIndex: 0 + ) + } +} diff --git a/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/SplitDiffPairerTests.swift b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/SplitDiffPairerTests.swift new file mode 100644 index 000000000000..1f4b00a3c63a --- /dev/null +++ b/Packages/iOS/CmuxDiffUI/Tests/CmuxDiffUITests/SplitDiffPairerTests.swift @@ -0,0 +1,50 @@ +import Testing +@testable import CmuxDiffUI + +@Suite struct SplitDiffPairerTests { + @Test func alignsUnequalDeletionAndAdditionRuns() { + let rows = [ + row("d1", .deletion, old: 4, new: nil), + row("d2", .deletion, old: 5, new: nil), + row("a1", .addition, old: nil, new: 4), + ] + + let split = SplitDiffPairer().pair(rows: rows) + + #expect(split.count == 2) + #expect(split[0].old?.id == "d1" && split[0].new?.id == "a1") + #expect(split[1].old?.id == "d2" && split[1].new == nil) + } + + @Test func contextAppearsOnBothSides() { + let context = row("context", .context, old: 7, new: 8) + + let split = SplitDiffPairer().pair(rows: [context]) + + #expect(split.first?.old == context) + #expect(split.first?.new == context) + } + + @Test func hunkAndNoNewlineRowsSpanBothColumns() { + let rows = [ + row("header", .hunkHeader, old: nil, new: nil), + row("marker", .noNewline, old: nil, new: nil), + ] + + let split = SplitDiffPairer().pair(rows: rows) + + #expect(split.map(\.kind) == [.spanning, .spanning]) + #expect(split.map { $0.spanning?.id } == ["header", "marker"]) + } + + private func row(_ id: String, _ kind: DiffRowKind, old: Int?, new: Int?) -> DiffRowSnapshot { + DiffRowSnapshot( + id: id, + kind: kind, + oldLine: old, + newLine: new, + text: id, + hunkIndex: 0 + ) + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseInfo.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseInfo.swift new file mode 100644 index 000000000000..f0c7a5ed0481 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseInfo.swift @@ -0,0 +1,20 @@ +/// The resolved baseline returned by a workspace-diff summary. +public struct MobileDiffBaseInfo: Codable, Sendable, Equatable { + /// The requested baseline strategy. + public let kind: MobileDiffBaseKind + /// The concrete Git object used for the comparison. + public let resolvedRef: String + /// A concise description of the resolved baseline. + public let describe: String + + /// Creates resolved baseline metadata. + /// - Parameters: + /// - kind: The requested baseline strategy. + /// - resolvedRef: The concrete Git object used for the comparison. + /// - describe: A concise baseline description. + public init(kind: MobileDiffBaseKind, resolvedRef: String, describe: String) { + self.kind = kind + self.resolvedRef = resolvedRef + self.describe = describe + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseKind.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseKind.swift new file mode 100644 index 000000000000..9dfde4fe55e7 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseKind.swift @@ -0,0 +1,9 @@ +/// A comparison baseline accepted by the workspace-diffs RPCs. +public enum MobileDiffBaseKind: String, Codable, Sendable, Equatable { + /// Compares the worktree with `HEAD`. + case workingTree + /// Compares the worktree with the latest agent-turn baseline. + case lastTurn + /// Compares the worktree with the default branch's merge base. + case branchBase +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseSpec.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseSpec.swift new file mode 100644 index 000000000000..8116b0f595d8 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffBaseSpec.swift @@ -0,0 +1,16 @@ +/// Selects the baseline for a workspace-diffs request. +public struct MobileDiffBaseSpec: Codable, Sendable, Equatable { + /// The requested baseline strategy. + public let kind: MobileDiffBaseKind + /// An optional baseline value, such as a branch override. + public let value: String? + + /// Creates a workspace-diffs baseline selection. + /// - Parameters: + /// - kind: The requested baseline strategy. + /// - value: An optional baseline value. + public init(kind: MobileDiffBaseKind, value: String? = nil) { + self.kind = kind + self.value = value + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffContextResponse.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffContextResponse.swift new file mode 100644 index 000000000000..6b996c76c8c0 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffContextResponse.swift @@ -0,0 +1,11 @@ +/// The decoded result of `mobile.workspace.diffs.context`. +public struct MobileDiffContextResponse: Codable, Sendable, Equatable { + /// Requested new-side source rows in file order. + public let rows: [String] + + /// Creates an expanded-context response. + /// - Parameter rows: Requested new-side source rows in file order. + public init(rows: [String]) { + self.rows = rows + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileResponse.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileResponse.swift new file mode 100644 index 000000000000..d123b727991c --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileResponse.swift @@ -0,0 +1,29 @@ +/// The decoded result of `mobile.workspace.diffs.file`. +public struct MobileDiffFileResponse: Codable, Sendable, Equatable { + /// Unified-diff hunks included in this cursor page. + public let hunks: [MobileDiffHunk] + /// Whether the file contains binary content. + public let isBinary: Bool + /// Whether a large textual diff remains gated behind `force`. + public let tooLarge: Bool + /// The next opaque row cursor, or `nil` at the end. + public let nextCursor: Int? + + /// Creates a cursor-paged file-diff response. + /// - Parameters: + /// - hunks: Unified-diff hunks in this page. + /// - isBinary: Whether the file contains binary content. + /// - tooLarge: Whether a large textual diff remains gated. + /// - nextCursor: The next row cursor, or `nil` at the end. + public init( + hunks: [MobileDiffHunk], + isBinary: Bool, + tooLarge: Bool, + nextCursor: Int? = nil + ) { + self.hunks = hunks + self.isBinary = isBinary + self.tooLarge = tooLarge + self.nextCursor = nextCursor + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileStatus.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileStatus.swift new file mode 100644 index 000000000000..290fe18cf595 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileStatus.swift @@ -0,0 +1,15 @@ +/// The wire status of a changed file. +public enum MobileDiffFileStatus: String, Codable, Sendable, Equatable { + /// A tracked file was added. + case added + /// A tracked file was modified. + case modified + /// A tracked file was deleted. + case deleted + /// A tracked file was renamed. + case renamed + /// A tracked file was copied. + case copied + /// A file is not tracked by Git. + case untracked +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileSummary.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileSummary.swift new file mode 100644 index 000000000000..6ab40a16784f --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffFileSummary.swift @@ -0,0 +1,49 @@ +/// Summary metadata for one changed file. +public struct MobileDiffFileSummary: Codable, Sendable, Equatable { + /// The new-side repository-relative path. + public let path: String + /// The old-side path for a rename or copy. + public let oldPath: String? + /// The file's change status. + public let status: MobileDiffFileStatus + /// The number of added lines. + public let additions: Int + /// The number of deleted lines. + public let deletions: Int + /// Whether the file contains binary content. + public let isBinary: Bool + /// Whether the file exceeds the large-diff threshold. + public let isLarge: Bool + /// The lowercase SHA-256 digest of the file patch. + public let patchDigest: String + + /// Creates changed-file summary metadata. + /// - Parameters: + /// - path: The new-side repository-relative path. + /// - oldPath: The old-side path for a rename or copy. + /// - status: The file's change status. + /// - additions: The number of added lines. + /// - deletions: The number of deleted lines. + /// - isBinary: Whether the file contains binary content. + /// - isLarge: Whether the file exceeds the large-diff threshold. + /// - patchDigest: The lowercase SHA-256 digest of the file patch. + public init( + path: String, + oldPath: String? = nil, + status: MobileDiffFileStatus, + additions: Int, + deletions: Int, + isBinary: Bool, + isLarge: Bool, + patchDigest: String + ) { + self.path = path + self.oldPath = oldPath + self.status = status + self.additions = additions + self.deletions = deletions + self.isBinary = isBinary + self.isLarge = isLarge + self.patchDigest = patchDigest + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffHunk.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffHunk.swift new file mode 100644 index 000000000000..8e06b8db55dc --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffHunk.swift @@ -0,0 +1,39 @@ +/// One decoded unified-diff hunk. +public struct MobileDiffHunk: Codable, Sendable, Equatable { + /// The first old-side line covered by the hunk. + public let oldStart: Int + /// The number of old-side lines covered by the hunk. + public let oldLines: Int + /// The first new-side line covered by the hunk. + public let newStart: Int + /// The number of new-side lines covered by the hunk. + public let newLines: Int + /// Git's optional section heading. + public let sectionHeading: String? + /// Rows in wire order. + public let rows: [MobileDiffRow] + + /// Creates a unified-diff hunk. + /// - Parameters: + /// - oldStart: The first old-side line. + /// - oldLines: The number of old-side lines. + /// - newStart: The first new-side line. + /// - newLines: The number of new-side lines. + /// - sectionHeading: Git's optional section heading. + /// - rows: Rows in display order. + public init( + oldStart: Int, + oldLines: Int, + newStart: Int, + newLines: Int, + sectionHeading: String? = nil, + rows: [MobileDiffRow] + ) { + self.oldStart = oldStart + self.oldLines = oldLines + self.newStart = newStart + self.newLines = newLines + self.sectionHeading = sectionHeading + self.rows = rows + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffRow.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffRow.swift new file mode 100644 index 000000000000..551187ca8929 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffRow.swift @@ -0,0 +1,24 @@ +/// One decoded row inside a unified-diff hunk. +public struct MobileDiffRow: Codable, Sendable, Equatable { + /// The semantic row kind. + public let kind: MobileDiffRowKind + /// The one-based old-side line number, when present. + public let oldNo: Int? + /// The one-based new-side line number, when present. + public let newNo: Int? + /// Row text without the unified-diff prefix marker. + public let text: String + + /// Creates a unified-diff row. + /// - Parameters: + /// - kind: The semantic row kind. + /// - oldNo: The old-side line number, when present. + /// - newNo: The new-side line number, when present. + /// - text: Row text without its prefix marker. + public init(kind: MobileDiffRowKind, oldNo: Int? = nil, newNo: Int? = nil, text: String) { + self.kind = kind + self.oldNo = oldNo + self.newNo = newNo + self.text = text + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffRowKind.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffRowKind.swift new file mode 100644 index 000000000000..edc30674ebd4 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffRowKind.swift @@ -0,0 +1,11 @@ +/// The semantic wire kind of a unified-diff row. +public enum MobileDiffRowKind: String, Codable, Sendable, Equatable { + /// An unchanged context line. + case context + /// A new-side addition. + case add + /// An old-side deletion. + case del + /// Git's no-trailing-newline marker. + case noNewline +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffSummaryResponse.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffSummaryResponse.swift new file mode 100644 index 000000000000..b178cf414d05 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffSummaryResponse.swift @@ -0,0 +1,29 @@ +/// The decoded result of `mobile.workspace.diffs.summary`. +public struct MobileDiffSummaryResponse: Codable, Sendable, Equatable { + /// The resolved comparison baseline. + public let baseInfo: MobileDiffBaseInfo + /// Aggregate file and line counts. + public let totals: MobileDiffTotals + /// Changed files in host-provided order. + public let files: [MobileDiffFileSummary] + /// The number of changed files omitted from ``files``. + public let truncatedFileCount: Int + + /// Creates a workspace-diff summary response. + /// - Parameters: + /// - baseInfo: The resolved comparison baseline. + /// - totals: Aggregate file and line counts. + /// - files: Changed files in display order. + /// - truncatedFileCount: The number of omitted changed files. + public init( + baseInfo: MobileDiffBaseInfo, + totals: MobileDiffTotals, + files: [MobileDiffFileSummary], + truncatedFileCount: Int + ) { + self.baseInfo = baseInfo + self.totals = totals + self.files = files + self.truncatedFileCount = truncatedFileCount + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffTotals.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffTotals.swift new file mode 100644 index 000000000000..8d1ce05d41f8 --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileDiffTotals.swift @@ -0,0 +1,20 @@ +/// Aggregate counts returned by a workspace-diff summary. +public struct MobileDiffTotals: Codable, Sendable, Equatable { + /// The number of changed files. + public let files: Int + /// The total number of added lines. + public let additions: Int + /// The total number of deleted lines. + public let deletions: Int + + /// Creates aggregate diff counts. + /// - Parameters: + /// - files: The number of changed files. + /// - additions: The total number of added lines. + /// - deletions: The total number of deleted lines. + public init(files: Int, additions: Int, deletions: Int) { + self.files = files + self.additions = additions + self.deletions = deletions + } +} diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileDiffDTODecodeTests.swift b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileDiffDTODecodeTests.swift new file mode 100644 index 000000000000..73532c23b47a --- /dev/null +++ b/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileDiffDTODecodeTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing +@testable import CmuxMobileRPC + +@Suite struct MobileDiffDTODecodeTests { + @Test func decodesCapturedSummaryShape() throws { + let data = Data(#""" + { + "baseInfo": {"kind":"branchBase","resolvedRef":"abc123","describe":"main"}, + "totals": {"files":2,"additions":13,"deletions":5}, + "files": [ + { + "path":"Sources/New Name.swift", + "oldPath":"Sources/Old Name.swift", + "status":"renamed", + "additions":3, + "deletions":2, + "isBinary":false, + "isLarge":false, + "patchDigest":"0123456789abcdef" + }, + { + "path":"Assets/icon.bin", + "oldPath":null, + "status":"untracked", + "additions":10, + "deletions":3, + "isBinary":true, + "isLarge":true, + "patchDigest":"fedcba9876543210" + } + ], + "truncatedFileCount":4 + } + """#.utf8) + + let response = try JSONDecoder().decode(MobileDiffSummaryResponse.self, from: data) + + #expect(response.baseInfo == MobileDiffBaseInfo( + kind: .branchBase, + resolvedRef: "abc123", + describe: "main" + )) + #expect(response.totals == MobileDiffTotals(files: 2, additions: 13, deletions: 5)) + #expect(response.files.count == 2) + #expect(response.files[0].path == "Sources/New Name.swift") + #expect(response.files[0].oldPath == "Sources/Old Name.swift") + #expect(response.files[0].status == .renamed) + #expect(response.files[0].patchDigest == "0123456789abcdef") + #expect(response.files[1].oldPath == nil) + #expect(response.files[1].status == .untracked) + #expect(response.files[1].isBinary && response.files[1].isLarge) + #expect(response.truncatedFileCount == 4) + } + + @Test func decodesCapturedFileShape() throws { + let data = Data(#""" + { + "hunks": [ + { + "oldStart":7, + "oldLines":3, + "newStart":7, + "newLines":4, + "sectionHeading":"func render()", + "rows":[ + {"kind":"context","oldNo":7,"newNo":7,"text":"let a = 1"}, + {"kind":"del","oldNo":8,"newNo":null,"text":"return old"}, + {"kind":"add","oldNo":null,"newNo":8,"text":"return new"}, + {"kind":"noNewline","oldNo":null,"newNo":null,"text":"No newline at end of file"} + ] + } + ], + "isBinary":false, + "tooLarge":false, + "nextCursor":120 + } + """#.utf8) + + let response = try JSONDecoder().decode(MobileDiffFileResponse.self, from: data) + + #expect(response.hunks.count == 1) + let hunk = response.hunks[0] + #expect(hunk.oldStart == 7 && hunk.oldLines == 3) + #expect(hunk.newStart == 7 && hunk.newLines == 4) + #expect(hunk.sectionHeading == "func render()") + #expect(hunk.rows.map(\.kind) == [.context, .del, .add, .noNewline]) + #expect(hunk.rows[1].oldNo == 8 && hunk.rows[1].newNo == nil) + #expect(hunk.rows[2].oldNo == nil && hunk.rows[2].newNo == 8) + #expect(response.isBinary == false && response.tooLarge == false) + #expect(response.nextCursor == 120) + } + + @Test func decodesCapturedContextShape() throws { + let data = Data(#"{"rows":["first line","","third line"]}"#.utf8) + + let response = try JSONDecoder().decode(MobileDiffContextResponse.self, from: data) + + #expect(response.rows == ["first line", "", "third line"]) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsService.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsService.swift new file mode 100644 index 000000000000..5ac5943814c9 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsService.swift @@ -0,0 +1,142 @@ +public import CmuxMobileRPC +internal import Foundation + +/// Performs read-only workspace-diff RPCs over an existing authenticated Mac connection. +/// +/// Instances borrow the shell's persistent client. They never disconnect or replace it; +/// ``MobileShellComposite`` remains the sole owner of connection lifecycle. +public actor MobileDiffsService: MobileDiffsServing { + private let client: MobileCoreRPCClient + + /// Creates a service that borrows an already-connected RPC client. + /// - Parameter client: The shell-owned authenticated client. + init(client: MobileCoreRPCClient) { + self.client = client + } + + /// Fetches changed-file metadata and aggregate line counts. + /// - Parameters: + /// - workspaceRef: The workspace handle or identifier to inspect. + /// - baseSpec: The baseline selection for the comparison. + /// - ignoreWhitespace: Whether Git should ignore whitespace changes. + /// - Returns: The decoded workspace-diff summary. + /// - Throws: ``MobileDiffsServiceError`` for structured domain failures, or a transport error. + public func summary( + workspaceRef: String, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool = false + ) async throws -> MobileDiffSummaryResponse { + try await send( + method: "mobile.workspace.diffs.summary", + params: requestParams( + workspaceRef: workspaceRef, + baseSpec: baseSpec, + ignoreWhitespace: ignoreWhitespace + ) + ) + } + + /// Fetches one cursor page of parsed hunks for a changed file. + /// - Parameters: + /// - workspaceRef: The workspace handle or identifier to inspect. + /// - path: The new-side repository-relative path. + /// - oldPath: The old-side path for a rename or copy. + /// - baseSpec: The baseline selection for the comparison. + /// - ignoreWhitespace: Whether Git should ignore whitespace changes. + /// - cursor: The host-provided row cursor, or `nil` for the first page. + /// - force: Whether to load a textual diff that exceeded the large-file gate. + /// - Returns: The decoded cursor-paged file response. + /// - Throws: ``MobileDiffsServiceError`` for structured domain failures, or a transport error. + public func fileHunks( + workspaceRef: String, + path: String, + oldPath: String? = nil, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool = false, + cursor: Int? = nil, + force: Bool = false + ) async throws -> MobileDiffFileResponse { + var params = requestParams( + workspaceRef: workspaceRef, + baseSpec: baseSpec, + ignoreWhitespace: ignoreWhitespace + ) + params["path"] = path + if let oldPath { + params["oldPath"] = oldPath + } + if let cursor { + params["cursor"] = cursor + } + params["force"] = force + return try await send(method: "mobile.workspace.diffs.file", params: params) + } + + /// Fetches new-side source rows for an expanded context range. + /// - Parameters: + /// - workspaceRef: The workspace handle or identifier to inspect. + /// - path: The new-side repository-relative path. + /// - startLine: The first one-based new-side line to return. + /// - endLine: The last one-based new-side line to return. + /// - baseSpec: The baseline selection for the comparison. + /// - ignoreWhitespace: Whether Git should ignore whitespace changes. + /// - Returns: The decoded new-side source rows. + /// - Throws: ``MobileDiffsServiceError`` for structured domain failures, or a transport error. + public func contextRows( + workspaceRef: String, + path: String, + startLine: Int, + endLine: Int, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool = false + ) async throws -> MobileDiffContextResponse { + var params = requestParams( + workspaceRef: workspaceRef, + baseSpec: baseSpec, + ignoreWhitespace: ignoreWhitespace + ) + params["path"] = path + params["startLine"] = startLine + params["endLine"] = endLine + return try await send(method: "mobile.workspace.diffs.context", params: params) + } + + private func requestParams( + workspaceRef: String, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool + ) -> [String: Any] { + var encodedBase: [String: Any] = ["kind": baseSpec.kind.rawValue] + if let value = baseSpec.value { + encodedBase["value"] = value + } + return [ + "workspaceRef": workspaceRef, + "baseSpec": encodedBase, + "ignoreWhitespace": ignoreWhitespace, + ] + } + + private func send( + method: String, + params: [String: Any] + ) async throws -> Response { + do { + let request = try MobileCoreRPCClient.requestData(method: method, params: params) + let response = try await client.sendRequest(request) + return try JSONDecoder().decode(Response.self, from: response) + } catch let error as MobileShellConnectionError { + guard case let .rpcError(code, _) = error else { throw error } + switch code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "workspace_not_found": + throw MobileDiffsServiceError.unknownWorkspace + case "not_git_repository": + throw MobileDiffsServiceError.notGitRepository + case "baseline_unavailable": + throw MobileDiffsServiceError.baselineMissing + default: + throw error + } + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsServiceError.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsServiceError.swift new file mode 100644 index 000000000000..40129578105e --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsServiceError.swift @@ -0,0 +1,9 @@ +/// Structured workspace-diffs failures that callers can handle without parsing messages. +public enum MobileDiffsServiceError: Error, Sendable, Equatable { + /// The requested workspace does not exist on the connected Mac. + case unknownWorkspace + /// The requested workspace directory is not a Git repository. + case notGitRepository + /// The requested agent-turn baseline is unavailable. + case baselineMissing +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsServing.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsServing.swift new file mode 100644 index 000000000000..93e0e8d265f1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiffsServing.swift @@ -0,0 +1,32 @@ +public import CmuxMobileRPC + +/// Read-only workspace-diff operations consumed by the native diff screen. +public protocol MobileDiffsServing: Sendable { + /// Fetches changed-file metadata and aggregate counts. + func summary( + workspaceRef: String, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool + ) async throws -> MobileDiffSummaryResponse + + /// Fetches one cursor page of parsed hunks for a changed file. + func fileHunks( + workspaceRef: String, + path: String, + oldPath: String?, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool, + cursor: Int?, + force: Bool + ) async throws -> MobileDiffFileResponse + + /// Fetches new-side source rows for an expanded context range. + func contextRows( + workspaceRef: String, + path: String, + startLine: Int, + endLine: Int, + baseSpec: MobileDiffBaseSpec, + ignoreWhitespace: Bool + ) async throws -> MobileDiffContextResponse +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift index 085d49319d25..a07335607a11 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift @@ -1,4 +1,6 @@ extension MobileShellComposite { + private static let workspaceDiffsCapability = "workspace.diffs.v1" + /// Whether the Mac supports workspace close requests. public var supportsWorkspaceCloseActions: Bool { supportedHostCapabilities.contains(Self.workspaceCloseCapability) } /// Whether the Mac supports workspace move/reorder requests. @@ -19,4 +21,6 @@ extension MobileShellComposite { } /// Whether the Mac supports terminal artifact scan/stat/fetch/thumbnail RPCs. public var supportsTerminalArtifacts: Bool { supportedHostCapabilities.contains(Self.terminalArtifactCapability) } + /// Whether the Mac supports read-only native workspace-diff RPCs. + public var supportsWorkspaceDiffs: Bool { supportedHostCapabilities.contains(Self.workspaceDiffsCapability) } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Diffs.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Diffs.swift new file mode 100644 index 000000000000..01990887c536 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Diffs.swift @@ -0,0 +1,8 @@ +/// Workspace-diffs access bound to the shell's current Mac connection. +extension MobileShellComposite { + /// A workspace-diffs service over the current connection, or `nil` when not connected. + public func makeDiffsService() -> MobileDiffsService? { + guard connectionState == .connected, let client = remoteClient else { return nil } + return MobileDiffsService(client: client) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsServiceTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsServiceTests.swift new file mode 100644 index 000000000000..8fa0c1895c3a --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsServiceTests.swift @@ -0,0 +1,136 @@ +import CMUXMobileCore +import CmuxMobileRPC +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShell + +@MainActor +@Suite struct MobileDiffsServiceTests { + @Test func connectedClientWithoutRouteMetadataVendsWorkingService() async throws { + let environment = try makeEnvironment() + let service = try #require(environment.store.makeDiffsService()) + + let summary = try await service.summary( + workspaceRef: "workspace:restored", + baseSpec: MobileDiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + + #expect(summary.totals == MobileDiffTotals(files: 1, additions: 2, deletions: 1)) + #expect(summary.files.first?.path == "Sources/App.swift") + let request = try #require(await environment.host.recordedRequests().first) + #expect(request.workspaceRef == "workspace:restored") + } + + @Test func sendsExactFileAndContextRequestShapes() async throws { + let environment = try makeEnvironment() + let service = try #require(environment.store.makeDiffsService()) + let baseSpec = MobileDiffBaseSpec(kind: .branchBase, value: "origin/trunk") + + _ = try await service.fileHunks( + workspaceRef: "workspace:42", + path: "Sources/New.swift", + oldPath: "Sources/Old.swift", + baseSpec: baseSpec, + ignoreWhitespace: true, + cursor: 80, + force: true + ) + let context = try await service.contextRows( + workspaceRef: "workspace:42", + path: "Sources/New.swift", + startLine: 4, + endLine: 5, + baseSpec: baseSpec, + ignoreWhitespace: true + ) + + let requests = await environment.host.recordedRequests() + let file = try #require(requests.first { $0.method == "mobile.workspace.diffs.file" }) + #expect(file.workspaceRef == "workspace:42") + #expect(file.baseKind == "branchBase" && file.baseValue == "origin/trunk") + #expect(file.ignoreWhitespace == true) + #expect(file.path == "Sources/New.swift" && file.oldPath == "Sources/Old.swift") + #expect(file.cursor == 80 && file.force == true) + let contextRequest = try #require(requests.first { $0.method == "mobile.workspace.diffs.context" }) + #expect(contextRequest.startLine == 4 && contextRequest.endLine == 5) + #expect(context.rows == ["line 4", "line 5"]) + } + + @Test func workspaceNotFoundMapsToTypedError() async throws { + try await expectMappedError(code: "workspace_not_found", expected: .unknownWorkspace) + } + + @Test func nonRepositoryMapsToTypedError() async throws { + try await expectMappedError(code: "not_git_repository", expected: .notGitRepository) + } + + @Test func unavailableBaselineMapsToDistinctTypedError() async throws { + try await expectMappedError(code: "baseline_unavailable", expected: .baselineMissing) + } + + @Test func workspaceDiffCapabilityUsesAdvertisedToken() { + let store = MobileShellComposite.preview() + #expect(store.supportsWorkspaceDiffs == false) + + store.supportedHostCapabilities = ["workspace.diffs.v1"] + + #expect(store.supportsWorkspaceDiffs) + } + + private func expectMappedError( + code: String, + expected: MobileDiffsServiceError + ) async throws { + let environment = try makeEnvironment() + await environment.host.setErrorCode(code) + let service = try #require(environment.store.makeDiffsService()) + do { + _ = try await service.summary( + workspaceRef: "workspace:error", + baseSpec: MobileDiffBaseSpec(kind: .workingTree) + ) + Issue.record("Expected \(code) to throw") + } catch let error as MobileDiffsServiceError { + #expect(error == expected) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private func makeEnvironment() throws -> ( + store: MobileShellComposite, + host: MobileDiffsTestHost + ) { + let host = MobileDiffsTestHost() + let runtime = MobileDiffsTestRuntime( + transportFactory: MobileDiffsTestTransportFactory(host: host) + ) + let store = MobileShellComposite( + runtime: runtime, + isSignedIn: true, + connectionState: .connected + ) + let route = try CmxAttachRoute( + id: "diffs-test", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56590) + ) + let ticket = try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + macDeviceID: "diffs-test-mac", + macDisplayName: "Diffs Test Mac", + routes: [route], + authToken: "test-attach-token" + ) + store.remoteClient = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + return (store, host) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestHost.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestHost.swift new file mode 100644 index 000000000000..156d92c71bc9 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestHost.swift @@ -0,0 +1,75 @@ +import CMUXMobileCore +import Foundation + +actor MobileDiffsTestHost { + private var requests: [MobileDiffsTestRequest] = [] + private var errorCode: String? + + func setErrorCode(_ code: String?) { + errorCode = code + } + + func recordedRequests() -> [MobileDiffsTestRequest] { + requests + } + + func response(to request: MobileDiffsTestRequest) -> Data? { + requests.append(request) + if let errorCode { + return try? Self.errorFrame(id: request.id, code: errorCode) + } + let result: [String: Any] + switch request.method { + case "mobile.workspace.diffs.summary": + result = [ + "baseInfo": [ + "kind": "workingTree", + "resolvedRef": "HEAD", + "describe": "HEAD", + ], + "totals": ["files": 1, "additions": 2, "deletions": 1], + "files": [[ + "path": "Sources/App.swift", + "oldPath": NSNull(), + "status": "modified", + "additions": 2, + "deletions": 1, + "isBinary": false, + "isLarge": false, + "patchDigest": "abc123", + ]], + "truncatedFileCount": 0, + ] + case "mobile.workspace.diffs.file": + result = [ + "hunks": [], + "isBinary": false, + "tooLarge": false, + "nextCursor": NSNull(), + ] + case "mobile.workspace.diffs.context": + result = ["rows": ["line 4", "line 5"]] + default: + return try? Self.errorFrame(id: request.id, code: "method_not_found") + } + return try? Self.resultFrame(id: request.id, result: result) + } + + private static func resultFrame(id: String?, result: [String: Any]) throws -> Data { + let envelope: [String: Any] = [ + "id": id ?? UUID().uuidString, + "ok": true, + "result": result, + ] + return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) + } + + private static func errorFrame(id: String?, code: String) throws -> Data { + let envelope: [String: Any] = [ + "id": id ?? UUID().uuidString, + "ok": false, + "error": ["code": code, "message": code], + ] + return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestRequest.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestRequest.swift new file mode 100644 index 000000000000..d69048859811 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestRequest.swift @@ -0,0 +1,14 @@ +struct MobileDiffsTestRequest: Sendable, Equatable { + let method: String? + let id: String? + let workspaceRef: String? + let baseKind: String? + let baseValue: String? + let ignoreWhitespace: Bool? + let path: String? + let oldPath: String? + let cursor: Int? + let force: Bool? + let startLine: Int? + let endLine: Int? +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestRuntime.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestRuntime.swift new file mode 100644 index 000000000000..af3a93876117 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestRuntime.swift @@ -0,0 +1,14 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation + +struct MobileDiffsTestRuntime: MobileSyncRuntime { + let transportFactory: any CmxByteTransportFactory + let supportedRouteKinds: [CmxAttachTransportKind] = [.debugLoopback] + let rpcRequestTimeoutNanoseconds: UInt64 = 5_000_000_000 + let pairingRequestTimeoutNanoseconds: UInt64 = 5_000_000_000 + let supportsServerPushEvents = false + let now: @Sendable () -> Date = Date.init + let stackAccessTokenProvider: @Sendable () async throws -> String = { "test-stack-token" } + let stackAccessTokenForceRefresher: @Sendable () async throws -> String = { "test-stack-token" } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestTransport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestTransport.swift new file mode 100644 index 000000000000..0e584792a3b6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestTransport.swift @@ -0,0 +1,72 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation + +actor MobileDiffsTestTransport: CmxByteTransport { + private let host: MobileDiffsTestHost + private var pendingFrames: [Data] = [] + private var receiveWaiters: [CheckedContinuation] = [] + private var isClosed = false + + init(host: MobileDiffsTestHost) { + self.host = host + } + + func connect() async throws {} + + func receive() async throws -> Data? { + if !pendingFrames.isEmpty { + return pendingFrames.removeFirst() + } + if isClosed { + return nil + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + var buffer = data + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &buffer) + for payload in payloads { + let object = try JSONSerialization.jsonObject(with: payload) as? [String: Any] + let params = object?["params"] as? [String: Any] + let baseSpec = params?["baseSpec"] as? [String: Any] + let request = MobileDiffsTestRequest( + method: object?["method"] as? String, + id: object?["id"] as? String, + workspaceRef: params?["workspaceRef"] as? String, + baseKind: baseSpec?["kind"] as? String, + baseValue: baseSpec?["value"] as? String, + ignoreWhitespace: params?["ignoreWhitespace"] as? Bool, + path: params?["path"] as? String, + oldPath: params?["oldPath"] as? String, + cursor: (params?["cursor"] as? NSNumber)?.intValue, + force: params?["force"] as? Bool, + startLine: (params?["startLine"] as? NSNumber)?.intValue, + endLine: (params?["endLine"] as? NSNumber)?.intValue + ) + if let response = await host.response(to: request) { + deliver(response) + } + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters = [] + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + private func deliver(_ frame: Data) { + guard !receiveWaiters.isEmpty else { + pendingFrames.append(frame) + return + } + receiveWaiters.removeFirst().resume(returning: frame) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestTransportFactory.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestTransportFactory.swift new file mode 100644 index 000000000000..34fc1966d825 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileDiffsTestTransportFactory.swift @@ -0,0 +1,9 @@ +import CMUXMobileCore + +struct MobileDiffsTestTransportFactory: CmxByteTransportFactory { + let host: MobileDiffsTestHost + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + MobileDiffsTestTransport(host: host) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Package.resolved b/Packages/iOS/CmuxMobileShellUI/Package.resolved index f7cc217c9778..7961bfa24baf 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.resolved +++ b/Packages/iOS/CmuxMobileShellUI/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "680f016588d1919fc59e09829d71907c229c473ea64dcd38f9013a99e7c9560e", + "originHash" : "560f58e9c0374a715b8396c96949db9cd68c0deb5a8166cdc4588bc140bf2a69", "pins" : [ + { + "identity" : "highlighterswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/smittytone/HighlighterSwift", + "state" : { + "revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0", + "version" : "3.1.0" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/Packages/iOS/CmuxMobileShellUI/Package.swift b/Packages/iOS/CmuxMobileShellUI/Package.swift index 0c5a93c828b0..6b35909812c1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.swift +++ b/Packages/iOS/CmuxMobileShellUI/Package.swift @@ -19,6 +19,7 @@ let package = Package( .package(path: "../../Shared/CmuxAgentChat"), .package(path: "../CmuxAgentChatUI"), .package(path: "../../Shared/CmuxAuthRuntime"), + .package(path: "../CmuxDiffUI"), .package(path: "../CmuxMobileBrowser"), .package(path: "../CmuxMobileCamera"), .package(path: "../CmuxMobileDiagnostics"), @@ -39,6 +40,7 @@ let package = Package( "CmuxAgentChat", "CmuxAgentChatUI", "CmuxAuthRuntime", + "CmuxDiffUI", "CmuxMobileBrowser", "CmuxMobileCamera", "CmuxMobileDiagnostics", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index 0a6063d43bcd..0ab24eb6dd19 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -1,3 +1,4 @@ +import CmuxDiffUI import Foundation import Observation @@ -26,6 +27,8 @@ public final class MobileDisplaySettings { private static let unreadIndicatorLeftShiftKey = "cmux.mobile.debug.unreadIndicatorLeftShift.v2" private static let profilePictureLeftShiftKey = "cmux.mobile.debug.profilePictureLeftShift" private static let profilePictureSizeKey = "cmux.mobile.debug.profilePictureSize" + private static let diffNavigationModelKey = "cmux.mobile.debug.diffNavigationModel.v1" + private static let showDiffEntryPointsKey = "cmux.mobile.debug.showDiffEntryPoints.v1" /// The preview line counts the "Preview Lines" setting offers. public static let workspacePreviewLineCountRange = 1...2 @@ -99,6 +102,16 @@ public final class MobileDisplaySettings { } } + /// DEBUG-only navigation model for native diff screens. Defaults to files-first. + public var diffNavigationModel: DiffNavigationModel { + didSet { defaults.set(diffNavigationModel.rawValue, forKey: Self.diffNavigationModelKey) } + } + + /// Whether DEBUG-only diff placements appear in chat-derived surfaces. + public var showDiffEntryPoints: Bool { + didSet { defaults.set(showDiffEntryPoints, forKey: Self.showDiffEntryPointsKey) } + } + /// Creates the display settings, seeding stored values from `defaults`. /// - Parameter defaults: The store backing the persisted preferences. /// Defaults to `.standard`; tests pass a scoped suite. Stored properties @@ -127,6 +140,9 @@ public final class MobileDisplaySettings { storedProfilePictureSize ?? Self.defaultProfilePictureSize, to: Self.profilePictureSizeRange ) + self.diffNavigationModel = defaults.string(forKey: Self.diffNavigationModelKey) + .flatMap(DiffNavigationModel.init(rawValue:)) ?? .filesFirst + self.showDiffEntryPoints = defaults.bool(forKey: Self.showDiffEntryPointsKey) } /// Clamps a stored or assigned preview line count to the supported range. diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 4e879737fe19..de2bd77dc82b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -1,5 +1,8 @@ #if os(iOS) import CmuxAuthRuntime +#if DEBUG +import CmuxDiffUI +#endif import CmuxMobileShell import CmuxMobileSupport import CmuxMobileWorkspace @@ -169,6 +172,42 @@ struct MobileSettingsView: View { #if DEBUG Section(L10n.string("mobile.settings.developer", defaultValue: "Developer")) { + Picker(selection: $displaySettings.diffNavigationModel) { + Text(String(localized: + "mobile.settings.diffNavigation.filesFirst", + defaultValue: "Files first", + bundle: .module + )) + .tag(DiffNavigationModel.filesFirst) + Text(String(localized: + "mobile.settings.diffNavigation.diffFirst", + defaultValue: "Diff first", + bundle: .module + )) + .tag(DiffNavigationModel.diffFirst) + } label: { + Text(String(localized: + "mobile.settings.diffNavigation", + defaultValue: "Diff Navigation", + bundle: .module + )) + } + .accessibilityIdentifier("MobileSettingsDiffNavigation") + + Toggle(isOn: $displaySettings.showDiffEntryPoints) { + Text(String(localized: + "mobile.settings.diffEntryPoints", + defaultValue: "Diff Debug Entry Points", + bundle: .module + )) + } + .accessibilityIdentifier("MobileSettingsDiffEntryPoints") + + NavigationLink { + DiffFixtureScreen(defaults: .standard) + } label: { + Label(DiffFixtureScreen.settingsLabel, systemImage: "doc.text.magnifyingglass") + } Button { showingChatDemo = true } label: { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index f992b8a15def..caabe806de43 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -1,6 +1,62 @@ { "sourceLanguage" : "en", "strings" : { + "mobile.settings.diffEntryPoints" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Diff Debug Entry Points" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分デバッグ入口" } } + } + }, + "mobile.workspace.changes" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Changes" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更" } } + } + }, + "mobile.workspace.changesUnavailable.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Reconnect to a compatible Mac to view workspace changes." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ワークスペースの変更を表示するには、対応するMacに再接続してください。" } } + } + }, + "mobile.workspace.changesUnavailable.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Changes unavailable" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "変更を表示できません" } } + } + }, + "mobile.workspace.fileEditChanges" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "View workspace changes" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ワークスペースの変更を表示" } } + } + }, + "mobile.settings.diffNavigation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Diff Navigation" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分ナビゲーション" } } + } + }, + "mobile.settings.diffNavigation.diffFirst" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Diff first" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "差分を先に表示" } } + } + }, + "mobile.settings.diffNavigation.filesFirst" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Files first" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ファイルを先に表示" } } + } + }, "^[%lld file](inflect: true)" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift index fa9700d97d00..15e41ff067f1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift @@ -19,6 +19,10 @@ struct WorkspaceChatPane: View { /// Composer draft, owned by the parent so it survives toggling back to /// the terminal and returning mid-thought. @Binding var draft: String + /// DEBUG placement shown when the transcript contains a file-edit card. + let showsDiffDebugEntry: Bool + /// Opens the workspace's shared diff presentation path. + let openDiff: () -> Void /// Flips chat mode off (the toggle's "back to terminal" path). let onExitChat: () -> Void @@ -41,6 +45,25 @@ struct WorkspaceChatPane: View { onOpenTerminal: openTerminal ) .environment(\.chatArtifactLoader, artifactLoader) + .safeAreaInset(edge: .top, spacing: 0) { + if showsDiffDebugEntry, containsFileEdit { + Button(action: openDiff) { + Label( + L10n.string( + "mobile.workspace.fileEditChanges", + defaultValue: "View workspace changes" + ), + systemImage: "doc.text.magnifyingglass" + ) + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + .background(.thinMaterial) + .accessibilityIdentifier("MobileChatFileEditChangesButton") + } + } } .sheet(isPresented: $isShowingShortcutSettings) { TerminalShortcutsSettingsView(scope: .agentChat) @@ -53,6 +76,14 @@ struct WorkspaceChatPane: View { } } + private var containsFileEdit: Bool { + conversation.rows.contains { row in + guard case let .message(snapshot) = row, + case .fileEdit = snapshot.message.kind else { return false } + return true + } + } + private var artifactLoader: ChatArtifactLoader { let key = artifactLoaderKey if let cachedArtifactLoader, cachedArtifactLoader.key == key { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift index b32cc2b8c7b8..4e774e3d16ad 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift @@ -14,7 +14,7 @@ extension WorkspaceDetailView { /// store still has the last authoritative GUI-history snapshot for this /// workspace. Use that immediately so the toolbar does not flicker while the /// refresh task reconnects. - private var visibleChatSessions: [ChatSessionDescriptor] { + var visibleChatSessions: [ChatSessionDescriptor] { if chatSessionsWorkspaceID == workspace.id.rawValue { return chatSessions } @@ -96,6 +96,8 @@ extension WorkspaceDetailView { get: { chatDrafts[session.id] ?? "" }, set: { chatDrafts[session.id] = $0 } ), + showsDiffDebugEntry: showsDebugDiffEntryPoints, + openDiff: presentWorkspaceDiff, onExitChat: { withAnimation(.snappy(duration: 0.28)) { isChatMode = false @@ -309,7 +311,7 @@ extension WorkspaceDetailView { return } - if let selectedTerminalID { + if selectedTerminalID != nil { cachedChatToggleTerminalID = nil return } @@ -324,7 +326,7 @@ extension WorkspaceDetailView { /// called from tasks/actions, not from `body`, so the body remains a pure /// projection of state. @discardableResult - private func ensureChatConversationStore( + func ensureChatConversationStore( for session: ChatSessionDescriptor, requiresCurrentSource: Bool = false ) -> ChatConversationStore? { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Diffs.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Diffs.swift new file mode 100644 index 000000000000..13ebca2eed1c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Diffs.swift @@ -0,0 +1,105 @@ +import CmuxAgentChat +import CmuxDiffUI +import CmuxMobileShell +import CmuxMobileSupport +import SwiftUI + +#if os(iOS) +extension WorkspaceDetailView { + var workspaceDiffEntryGate: WorkspaceDiffEntryGate { + WorkspaceDiffEntryGate( + supportsWorkspaceDiffs: store.supportsWorkspaceDiffs, + isConnected: store.connectionState == .connected + ) + } + + var showsDebugDiffEntryPoints: Bool { + #if DEBUG + displaySettings.showDiffEntryPoints && workspaceDiffEntryGate.canPresent + #else + false + #endif + } + + @MainActor func presentWorkspaceDiff() { + guard workspaceDiffEntryGate.canPresent else { return } + isWorkspaceDiffPresented = true + } + + @ViewBuilder + var workspaceDiffPresentation: some View { + if let service = store.makeDiffsService() { + DiffLiveScreen( + store: DiffScreenStore( + service: service, + workspaceRef: workspace.id.rawValue, + viewedStore: DiffViewedStore(defaults: .standard), + layoutPreferenceStore: DiffLayoutPreferenceStore(defaults: .standard) + ), + navigationModel: displaySettings.diffNavigationModel, + quickNoteActions: workspaceDiffQuickNoteActions, + dismissViewer: { isWorkspaceDiffPresented = false } + ) + } else { + ContentUnavailableView( + workspaceChangesUnavailableTitle, + systemImage: "wifi.slash", + description: Text(workspaceChangesUnavailableMessage) + ) + } + } + + var workspaceDiffQuickNoteActions: DiffQuickNoteActions { + DiffQuickNoteActions( + isAvailable: diffAgentSession != nil, + send: { prompt in await sendDiffPrompt(prompt) }, + editInComposer: { prompt in editDiffPrompt(prompt) } + ) + } + + private var diffAgentSession: ChatSessionDescriptor? { + let openable = ChatSessionDescriptor.openable( + visibleChatSessions.filter { $0.kind == .agent } + ) + if let chosenChatSession, + openable.contains(where: { $0.id == chosenChatSession.id }) { + return chosenChatSession + } + return openable.first + } + + @MainActor private func sendDiffPrompt(_ prompt: String) async { + guard let session = diffAgentSession, + let conversation = ensureChatConversationStore(for: session) + else { return } + await conversation.send(text: prompt, attachments: []) + } + + @MainActor private func editDiffPrompt(_ prompt: String) { + guard let session = diffAgentSession, + ensureChatConversationStore(for: session) != nil + else { return } + chatDrafts[session.id] = prompt + pinnedChatSessionID = session.id + withAnimation(.snappy(duration: 0.28)) { + isChatMode = true + isWorkspaceDiffPresented = false + } + } + + var workspaceChangesLabel: String { + L10n.string("mobile.workspace.changes", defaultValue: "Changes") + } + + private var workspaceChangesUnavailableTitle: String { + L10n.string("mobile.workspace.changesUnavailable.title", defaultValue: "Changes unavailable") + } + + private var workspaceChangesUnavailableMessage: String { + L10n.string( + "mobile.workspace.changesUnavailable.message", + defaultValue: "Reconnect to a compatible Mac to view workspace changes." + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Feedback.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Feedback.swift new file mode 100644 index 000000000000..331e92101e1c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Feedback.swift @@ -0,0 +1,128 @@ +#if canImport(UIKit) +import CmuxMobileDiagnostics +import CmuxMobileSupport +import CmuxMobileTerminal +import SwiftUI +@preconcurrency import UIKit + +extension WorkspaceDetailView { + func openFeedbackComposerFromMenu() { + feedbackText = "" + feedbackErrorMessage = nil + // A prior submission may still be in flight if the user dismissed the + // sheet mid-send; reset so the reopened composer does not stay disabled. + isSubmittingFeedback = false + feedbackEmail = store.signedInUserEmail ?? "" + isFeedbackComposerPresented = true + } + + /// Release-safe feedback composer for the agent and email routes. + var feedbackComposer: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 12) { + Text(feedbackComposerExplanation) + .font(.footnote) + .foregroundStyle(.secondary) + TextField( + L10n.string("mobile.feedback.placeholder", defaultValue: "What happened?"), + text: $feedbackText, + axis: .vertical + ) + .lineLimit(3...8) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("MobileFeedbackComposerField") + if !feedbackRoutesToAgent { + TextField( + L10n.string("mobile.feedback.emailPlaceholder", defaultValue: "Your email"), + text: $feedbackEmail + ) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("MobileFeedbackComposerEmailField") + } + if let feedbackErrorMessage { + Text(feedbackErrorMessage) + .font(.footnote) + .foregroundStyle(.red) + .accessibilityIdentifier("MobileFeedbackComposerError") + } + Spacer() + } + .padding(16) + .navigationTitle(L10n.string("mobile.feedback.send", defaultValue: "Send Feedback")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.string("mobile.feedback.cancel", defaultValue: "Cancel")) { + isFeedbackComposerPresented = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button( + L10n.string("mobile.feedback.sendAction", defaultValue: "Send"), + action: submitFeedbackFromComposer + ) + .disabled(isSubmittingFeedback || !isFeedbackSubmittable) + .accessibilityIdentifier("MobileFeedbackComposerSend") + } + } + } + .presentationDetents([.medium]) + } + + private var feedbackRoutesToAgent: Bool { + store.currentFeedbackRoute == .privilegedAgent + } + + private var feedbackComposerExplanation: String { + if feedbackRoutesToAgent { + return L10n.string( + "mobile.feedback.explanation.agent", + defaultValue: "Sends diagnostics (debug log + visible terminal) and your note straight to the paired Mac." + ) + } + return L10n.string( + "mobile.feedback.explanation.email", + defaultValue: "Emails your feedback to the cmux team, stamped with your app version and device." + ) + } + + private var isFeedbackSubmittable: Bool { + let messageOK = !feedbackText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return feedbackRoutesToAgent ? messageOK : messageOK && feedbackEmail.contains("@") + } + + private func submitFeedbackFromComposer() { + guard !isSubmittingFeedback, isFeedbackSubmittable else { return } + isSubmittingFeedback = true + feedbackErrorMessage = nil + let note = feedbackText + let email = feedbackEmail + let routesToAgent = feedbackRoutesToAgent + Task { @MainActor in + let terminalText = routesToAgent ? await GhosttySurfaceView.visibleTerminalSnapshot() : "" + let debugLogText = routesToAgent ? await MobileDebugLog.shared.sink.snapshotWithCount().1 : "" + let outcome = await store.submitFeedback( + message: note, + emailOverride: email, + debugLogText: debugLogText, + terminalText: terminalText + ) + isSubmittingFeedback = false + switch outcome { + case .sentToAgent, .emailed: + UINotificationFeedbackGenerator().notificationOccurred(.success) + isFeedbackComposerPresented = false + case .failed: + UINotificationFeedbackGenerator().notificationOccurred(.error) + feedbackErrorMessage = L10n.string( + "mobile.feedback.error", + defaultValue: "Could not send feedback. Check your connection and try again." + ) + } + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift index 41b2d84065b0..f284b2eddaf0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift @@ -6,6 +6,41 @@ import CmuxMobileTerminal import SwiftUI extension WorkspaceDetailView { + func terminalArtifactLoader(workspaceID: String, surfaceID: String) -> ChatArtifactLoader { + guard let source = store.makeChatEventSource() else { + return .unsupported(cache: terminalArtifactThumbnailCache) + } + return ChatArtifactLoader( + terminalWorkspaceID: workspaceID, + terminalSurfaceID: surfaceID, + supportsArtifacts: store.supportsTerminalArtifacts, + cache: terminalArtifactThumbnailCache, + stat: { path in + try await source.terminalArtifactStat( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path + ) + }, + fetch: { path, progress in + try await source.terminalArtifactFetch( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path, + progress: progress + ) + }, + thumbnail: { path, maxDimension in + try await source.terminalArtifactThumbnail( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path, + maxDimension: maxDimension + ) + } + ) + } + @ViewBuilder func terminalArtifactSurface(terminalID: String) -> some View { let shouldAutoFocus = activeSurface == .terminal diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 3695df00ed96..98a45d3f4c53 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -33,15 +33,15 @@ struct WorkspaceDetailView: View { let backButtonConfiguration: WorkspaceBackButtonConfiguration? let signOut: (() -> Void)? @Environment(BrowserSurfaceStore.self) var browserStore - @Environment(MobileDisplaySettings.self) private var displaySettings + @Environment(MobileDisplaySettings.self) var displaySettings /// Drives the destructive close-workspace confirmation dialog. @State var isConfirmingClose = false #if canImport(UIKit) - @State private var isFeedbackComposerPresented = false - @State private var feedbackText = "" - @State private var feedbackEmail = "" - @State private var isSubmittingFeedback = false - @State private var feedbackErrorMessage: String? + @State var isFeedbackComposerPresented = false + @State var feedbackText = "" + @State var feedbackEmail = "" + @State var isSubmittingFeedback = false + @State var feedbackErrorMessage: String? @State private var isTextSheetPresented = false /// Drives the rename-workspace dialog launched from the picker menu, and its /// editable text (seeded with the current name when presented). @@ -68,6 +68,8 @@ struct WorkspaceDetailView: View { @State var chatConversationStores: [String: ChatConversationStore] = [:] /// Per-session composer drafts, surviving toggles back to the terminal. @State var chatDrafts: [String: String] = [:] + /// Drives the one shared native-diff presentation path. + @State var isWorkspaceDiffPresented = false @State var terminalArtifactFilesContext: TerminalArtifactContext? @State var selectedTerminalArtifact: TerminalArtifactSelection? @State var terminalArtifactThumbnailCache = ChatArtifactThumbnailCache() @@ -120,6 +122,9 @@ struct WorkspaceDetailView: View { .sheet(isPresented: $isTextSheetPresented) { TerminalTextSheetView(surfaceID: textSheetSurfaceID) } + .sheet(isPresented: $isWorkspaceDiffPresented) { + workspaceDiffPresentation + } .workspaceRenameDialog( isPresented: $isRenamePresented, text: $renameText, @@ -150,6 +155,15 @@ struct WorkspaceDetailView: View { ToolbarItem(id: "workspace-title", placement: .topBarLeading) { workspaceTitleToolbarMenu } + if workspaceDiffEntryGate.canPresent { + ToolbarItem(id: "workspace-changes", placement: .topBarTrailing) { + Button(action: presentWorkspaceDiff) { + Label(workspaceChangesLabel, systemImage: "doc.text.magnifyingglass") + .labelStyle(.iconOnly) + } + .accessibilityIdentifier("MobileWorkspaceChangesButton") + } + } if let selectedTerminalID, store.isAlternateScreen(surfaceID: selectedTerminalID), displaySettings.showAltScreenNotice { @@ -181,14 +195,24 @@ struct WorkspaceDetailView: View { if isChatMode, let session = chosenChatSession, let conversation = chatConversationStores[session.id] { - ChatSessionHeaderView( - descriptor: conversation.descriptor, - agentState: conversation.agentState, - isConnected: conversation.isConnected, - titleOverride: workspace.name, - subtitle: tabName(for: session), - style: .toolbarCompact - ) + HStack(spacing: 4) { + ChatSessionHeaderView( + descriptor: conversation.descriptor, + agentState: conversation.agentState, + isConnected: conversation.isConnected, + titleOverride: workspace.name, + subtitle: tabName(for: session), + style: .toolbarCompact + ) + if showsDebugDiffEntryPoints { + Button(action: presentWorkspaceDiff) { + Image(systemName: "doc.text.magnifyingglass") + } + .buttonStyle(.plain) + .accessibilityLabel(workspaceChangesLabel) + .accessibilityIdentifier("MobileSessionSummaryChangesButton") + } + } } else if let browser = activeBrowser { Text(browser.title ?? workspace.name) .font(.headline) @@ -288,43 +312,6 @@ struct WorkspaceDetailView: View { #endif } - #if os(iOS) - func terminalArtifactLoader(workspaceID: String, surfaceID: String) -> ChatArtifactLoader { - guard let source = store.makeChatEventSource() else { - return .unsupported(cache: terminalArtifactThumbnailCache) - } - return ChatArtifactLoader( - terminalWorkspaceID: workspaceID, - terminalSurfaceID: surfaceID, - supportsArtifacts: store.supportsTerminalArtifacts, - cache: terminalArtifactThumbnailCache, - stat: { path in - try await source.terminalArtifactStat( - workspaceID: workspaceID, - surfaceID: surfaceID, - path: path - ) - }, - fetch: { path, progress in - try await source.terminalArtifactFetch( - workspaceID: workspaceID, - surfaceID: surfaceID, - path: path, - progress: progress - ) - }, - thumbnail: { path, maxDimension in - try await source.terminalArtifactThumbnail( - workspaceID: workspaceID, - surfaceID: surfaceID, - path: path, - maxDimension: maxDimension - ) - } - ) - } - #endif - @ViewBuilder private var terminalToolbarButtons: some View { newWorkspaceToolbarButton @@ -422,142 +409,6 @@ struct WorkspaceDetailView: View { isTextSheetPresented = true } - private func openFeedbackComposerFromMenu() { - feedbackText = "" - feedbackErrorMessage = nil - // A prior submission may still be in flight if the user dismissed the - // sheet mid-send (Cancel stays enabled); reset so the reopened composer - // does not render Send permanently disabled until that task times out. - isSubmittingFeedback = false - // Prefill the reply-to address with the signed-in email on the email - // path; the privileged agent path never reads it. - feedbackEmail = store.signedInUserEmail ?? "" - isFeedbackComposerPresented = true - } - - /// Whether the current submission will go straight to the agent (privileged - /// `@manaflow.ai` user on an active connection) vs the email inbox. - private var feedbackRoutesToAgent: Bool { - store.currentFeedbackRoute == .privilegedAgent - } - - // Release-safe Send Feedback composer. Privileged @manaflow.ai users on an - // active connection ship a diagnostic bundle straight to the paired Mac's - // agent sink; everyone else emails the feedback inbox. Either way the - // submission is stamped with build type + version + device. - private var feedbackComposer: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 12) { - Text(feedbackComposerExplanation) - .font(.footnote) - .foregroundStyle(.secondary) - TextField( - L10n.string("mobile.feedback.placeholder", defaultValue: "What happened?"), - text: $feedbackText, - axis: .vertical - ) - .lineLimit(3...8) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("MobileFeedbackComposerField") - if !feedbackRoutesToAgent { - TextField( - L10n.string("mobile.feedback.emailPlaceholder", defaultValue: "Your email"), - text: $feedbackEmail - ) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("MobileFeedbackComposerEmailField") - } - if let feedbackErrorMessage { - Text(feedbackErrorMessage) - .font(.footnote) - .foregroundStyle(.red) - .accessibilityIdentifier("MobileFeedbackComposerError") - } - Spacer() - } - .padding(16) - .navigationTitle(L10n.string("mobile.feedback.send", defaultValue: "Send Feedback")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.string("mobile.feedback.cancel", defaultValue: "Cancel")) { - isFeedbackComposerPresented = false - } - } - ToolbarItem(placement: .confirmationAction) { - Button(L10n.string("mobile.feedback.sendAction", defaultValue: "Send"), action: submitFeedbackFromComposer) - .disabled(isSubmittingFeedback || !isFeedbackSubmittable) - .accessibilityIdentifier("MobileFeedbackComposerSend") - } - } - } - .presentationDetents([.medium]) - } - - private var feedbackComposerExplanation: String { - if feedbackRoutesToAgent { - // Intentionally does not promise the structured event log: that log - // is only captured in DEBUG builds, so a Release agent bundle carries - // the debug log + visible terminal + your note, not the event trace. - return L10n.string( - "mobile.feedback.explanation.agent", - defaultValue: "Sends diagnostics (debug log + visible terminal) and your note straight to the paired Mac." - ) - } - return L10n.string( - "mobile.feedback.explanation.email", - defaultValue: "Emails your feedback to the cmux team, stamped with your app version and device." - ) - } - - private var isFeedbackSubmittable: Bool { - let messageOK = !feedbackText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - if feedbackRoutesToAgent { - return messageOK - } - // The email route requires a valid reply-to address; the web route's - // zod schema rejects an empty/invalid email with a 400. - return messageOK && feedbackEmail.contains("@") - } - - private func submitFeedbackFromComposer() { - guard !isSubmittingFeedback, isFeedbackSubmittable else { return } - isSubmittingFeedback = true - feedbackErrorMessage = nil - let note = feedbackText - let email = feedbackEmail - let routesToAgent = feedbackRoutesToAgent - // Only the agent path reads the terminal/debug snapshots; reading them is - // cheap and harmless on the email path, but skip the work when unused. - // `visibleTerminalSnapshot()` reads off the output queue with a bounded - // async deadline (never a main-thread `ghostty_surface_read_text`, which blanks the - // terminal). The debug-log snapshot is awaited from its actor. - Task { @MainActor in - let terminalText = routesToAgent ? await GhosttySurfaceView.visibleTerminalSnapshot() : "" - let debugLogText = routesToAgent ? await MobileDebugLog.shared.sink.snapshotWithCount().1 : "" - let outcome = await store.submitFeedback( - message: note, - emailOverride: email, - debugLogText: debugLogText, - terminalText: terminalText - ) - isSubmittingFeedback = false - switch outcome { - case .sentToAgent, .emailed: - UINotificationFeedbackGenerator().notificationOccurred(.success) - isFeedbackComposerPresented = false - case .failed: - UINotificationFeedbackGenerator().notificationOccurred(.error) - feedbackErrorMessage = L10n.string( - "mobile.feedback.error", - defaultValue: "Could not send feedback. Check your connection and try again." - ) - } - } - } #endif private func createWorkspaceFromToolbar() { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDiffEntryGate.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDiffEntryGate.swift new file mode 100644 index 000000000000..cb5a87f4be15 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDiffEntryGate.swift @@ -0,0 +1,9 @@ +/// Capability and connection gate shared by every workspace-diff entry point. +struct WorkspaceDiffEntryGate: Sendable, Equatable { + let supportsWorkspaceDiffs: Bool + let isConnected: Bool + + var canPresent: Bool { + supportsWorkspaceDiffs && isConnected + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 1b6bd15a8d64..3f3ff289bda9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -1,3 +1,4 @@ +import CmuxDiffUI import Foundation import Testing @testable import CmuxMobileShellUI @@ -104,4 +105,27 @@ import Testing #expect(reloaded.profilePictureLeftShift == 24) #expect(reloaded.profilePictureSize == 36) } + + @Test func diffNavigationModelDefaultsToFilesFirstAndReadsPersistedValue() throws { + let defaults = try makeDefaults("diffNavigationModel") + #expect(MobileDisplaySettings(defaults: defaults).diffNavigationModel == .filesFirst) + #expect(defaults.object(forKey: "cmux.mobile.debug.diffNavigationModel.v1") == nil) + + let settings = MobileDisplaySettings(defaults: defaults) + settings.diffNavigationModel = .diffFirst + #expect(MobileDisplaySettings(defaults: defaults).diffNavigationModel == .diffFirst) + + defaults.set("invalid", forKey: "cmux.mobile.debug.diffNavigationModel.v1") + #expect(MobileDisplaySettings(defaults: defaults).diffNavigationModel == .filesFirst) + } + + @Test func diffDebugEntryPointsDefaultOffAndPersist() throws { + let defaults = try makeDefaults("diffEntryPoints") + #expect(MobileDisplaySettings(defaults: defaults).showDiffEntryPoints == false) + #expect(defaults.object(forKey: "cmux.mobile.debug.showDiffEntryPoints.v1") == nil) + + let settings = MobileDisplaySettings(defaults: defaults) + settings.showDiffEntryPoints = true + #expect(MobileDisplaySettings(defaults: defaults).showDiffEntryPoints) + } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceDiffEntryGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceDiffEntryGateTests.swift new file mode 100644 index 000000000000..c2047feba4df --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceDiffEntryGateTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import CmuxMobileShellUI + +@Suite struct WorkspaceDiffEntryGateTests { + @Test(arguments: [ + (true, true, true), + (true, false, false), + (false, true, false), + (false, false, false), + ]) + func requiresCapabilityAndConnection( + supportsWorkspaceDiffs: Bool, + isConnected: Bool, + expected: Bool + ) { + let gate = WorkspaceDiffEntryGate( + supportsWorkspaceDiffs: supportsWorkspaceDiffs, + isConnected: isConnected + ) + #expect(gate.canPresent == expected) + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Package.swift b/Packages/macOS/CmuxDiffEngine/Package.swift new file mode 100644 index 000000000000..0e465a36be26 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxDiffEngine", + platforms: [ + .macOS(.v14), + ], + products: [ + .library(name: "CmuxDiffEngine", targets: ["CmuxDiffEngine"]), + ], + dependencies: [ + .package(path: "../CmuxFoundation"), + ], + targets: [ + .target( + name: "CmuxDiffEngine", + dependencies: [ + .product(name: "CmuxFoundation", package: "CmuxFoundation"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxDiffEngineTests", + dependencies: ["CmuxDiffEngine"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/CmuxDiffEngine.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/CmuxDiffEngine.swift new file mode 100644 index 000000000000..c96ddd251499 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/CmuxDiffEngine.swift @@ -0,0 +1,229 @@ +public import CmuxFoundation +import Foundation + +/// Computes read-only, cursor-paged working-tree diffs for cmux clients. +/// +/// Git execution is injected through ``CommandRunning``. Production callers use +/// ``CommandRunner`` while tests can provide deterministic failures or inspect +/// the exact hardened Git arguments. +public struct CmuxDiffEngine: Sendable { + private let commandRunner: any CommandRunning + private let rowLimit: Int + + /// Creates a diff engine. + /// - Parameters: + /// - commandRunner: The injected Git process runner. + /// - rowLimit: The maximum number of parsed rows returned in one file page. + public init( + commandRunner: any CommandRunning = CommandRunner(), + rowLimit: Int = 2_000 + ) { + self.commandRunner = commandRunner + self.rowLimit = max(rowLimit, 1) + } + + /// Computes repository totals and per-file metadata for a working tree. + /// - Parameters: + /// - repositoryPath: A repository root or a directory inside its worktree. + /// - baseSpec: The requested comparison baseline. + /// - ignoreWhitespace: Whether Git should ignore whitespace changes with `-w`. + /// - Returns: The resolved baseline, totals, and all changed files. + /// - Throws: ``DiffEngineError`` when the repository or baseline cannot be resolved. + public nonisolated func summary( + repositoryPath: String, + baseSpec: DiffBaseSpec, + ignoreWhitespace: Bool + ) async throws -> DiffSummary { + let context = try await context(repositoryPath: repositoryPath, baseSpec: baseSpec) + let snapshot = try await GitDiffSnapshotLoader( + commands: context.commands, + repositoryRoot: context.repositoryRoot + ).load(base: context.base, ignoreWhitespace: ignoreWhitespace) + let files = snapshot.files.map(\.summary) + return DiffSummary( + baseInfo: snapshot.base.info, + totals: DiffTotals( + files: files.count, + additions: files.reduce(0) { $0 + $1.additions }, + deletions: files.reduce(0) { $0 + $1.deletions } + ), + files: files, + truncatedFileCount: 0 + ) + } + + /// Parses and pages the patch for one changed file. + /// - Parameters: + /// - repositoryPath: A repository root or a directory inside its worktree. + /// - path: The new-side repository-relative path. + /// - oldPath: The old-side path for a rename or copy; both paths are passed as Git pathspecs. + /// - baseSpec: The requested comparison baseline. + /// - ignoreWhitespace: Whether Git should ignore whitespace changes with `-w`. + /// - cursor: A row offset returned by a previous page. + /// - force: Whether to parse a file marked large instead of returning the load gate. + /// - Returns: Parsed hunks, binary/large state, and an optional next cursor. + /// - Throws: ``DiffEngineError`` when the repository, path, baseline, or cursor is invalid. + public nonisolated func fileHunks( + repositoryPath: String, + path: String, + oldPath: String? = nil, + baseSpec: DiffBaseSpec, + ignoreWhitespace: Bool, + cursor: Int? = nil, + force: Bool = false + ) async throws -> DiffFilePage { + guard cursor == nil || cursor! >= 0 else { throw DiffEngineError.invalidRange } + let context = try await context(repositoryPath: repositoryPath, baseSpec: baseSpec) + let reader = WorkingTreeFileReader(repositoryRoot: context.repositoryRoot) + _ = try reader.regularFileData(path: path) + let pathspecs = Array(Set([oldPath, path].compactMap { $0 })).sorted() + let snapshot = try await GitDiffSnapshotLoader( + commands: context.commands, + repositoryRoot: context.repositoryRoot + ).load(base: context.base, ignoreWhitespace: ignoreWhitespace, pathspecs: pathspecs) + guard let file = snapshot.files.first(where: { + $0.summary.path == path && (oldPath == nil || $0.summary.oldPath == oldPath) + }) else { + throw DiffEngineError.fileNotFound(path) + } + if file.summary.isBinary { + return DiffFilePage(hunks: [], isBinary: true, tooLarge: false, nextCursor: nil) + } + if file.summary.isLarge, !force { + return DiffFilePage(hunks: [], isBinary: false, tooLarge: true, nextCursor: nil) + } + let hunks = UnifiedDiffParser().parse(file.patch) + return try DiffPageBuilder(rowLimit: rowLimit).page(hunks: hunks, cursor: cursor) + } + + /// Reads a one-based inclusive new-side context range from the worktree. + /// + /// When the path is deleted, this falls back to the path's `HEAD` blob. + /// - Parameters: + /// - repositoryPath: A repository root or a directory inside its worktree. + /// - path: The repository-relative path. + /// - startLine: The first one-based line to return. + /// - endLine: The last one-based line to return. + /// - Returns: The requested text lines, clipped at end of file. + /// - Throws: ``DiffEngineError`` when the repository, path, or range is invalid. + public nonisolated func contextRows( + repositoryPath: String, + path: String, + startLine: Int, + endLine: Int + ) async throws -> [String] { + guard startLine >= 1, endLine >= startLine else { + throw DiffEngineError.invalidRange + } + let initial = GitCommandExecutor(repositoryDirectory: repositoryPath, commandRunner: commandRunner) + let repositoryRoot = try await initial.repositoryRoot() + let reader = WorkingTreeFileReader(repositoryRoot: repositoryRoot) + if let data = try reader.regularFileData(path: path) { + return reader.selectedLines(data: data, startLine: startLine, endLine: endLine) + } + guard !path.isEmpty, !path.hasPrefix("/"), !path.split(separator: "/").contains("..") else { + throw DiffEngineError.invalidPath(path) + } + let commands = GitCommandExecutor(repositoryDirectory: repositoryRoot, commandRunner: commandRunner) + guard let blob = try await commands.run(["cat-file", "blob", "HEAD:./\(path)"], allowFailure: true) else { + throw DiffEngineError.fileNotFound(path) + } + return reader.selectedLines(data: Data(blob.utf8), startLine: startLine, endLine: endLine) + } + + private nonisolated func context( + repositoryPath: String, + baseSpec: DiffBaseSpec + ) async throws -> (repositoryRoot: String, commands: GitCommandExecutor, base: ResolvedDiffBase) { + let initial = GitCommandExecutor(repositoryDirectory: repositoryPath, commandRunner: commandRunner) + let repositoryRoot = try await initial.repositoryRoot() + let commands = GitCommandExecutor(repositoryDirectory: repositoryRoot, commandRunner: commandRunner) + return (repositoryRoot, commands, try await resolveBase(baseSpec, commands: commands)) + } + + private nonisolated func resolveBase( + _ spec: DiffBaseSpec, + commands: GitCommandExecutor + ) async throws -> ResolvedDiffBase { + switch spec.kind { + case .workingTree: + if let head = try await verifiedObject("HEAD^{commit}", commands: commands) { + return ResolvedDiffBase( + info: DiffBaseInfo(kind: .workingTree, resolvedRef: head, describe: "HEAD"), + object: head + ) + } + let emptyTree = try await requiredTrimmed(["hash-object", "-t", "tree", "/dev/null"], commands: commands) + return ResolvedDiffBase( + info: DiffBaseInfo(kind: .workingTree, resolvedRef: emptyTree, describe: emptyTree), + object: emptyTree + ) + case .lastTurn: + guard let value = normalized(spec.value), + let resolved = try await verifiedObject(value, commands: commands) else { + throw DiffEngineError.baselineUnavailable + } + return ResolvedDiffBase( + info: DiffBaseInfo(kind: .lastTurn, resolvedRef: resolved, describe: resolved), + object: resolved + ) + case .branchBase: + guard let head = try await verifiedObject("HEAD^{commit}", commands: commands) else { + let emptyTree = try await requiredTrimmed(["hash-object", "-t", "tree", "/dev/null"], commands: commands) + return ResolvedDiffBase( + info: DiffBaseInfo(kind: .branchBase, resolvedRef: emptyTree, describe: emptyTree), + object: emptyTree + ) + } + let branch = try await resolveDefaultBranch(override: spec.value, commands: commands) + let mergeBase = try await requiredTrimmed(["merge-base", head, branch], commands: commands) + return ResolvedDiffBase( + info: DiffBaseInfo(kind: .branchBase, resolvedRef: mergeBase, describe: "merge-base(\(branch))"), + object: mergeBase + ) + } + } + + private nonisolated func resolveDefaultBranch( + override: String?, + commands: GitCommandExecutor + ) async throws -> String { + if let override = normalized(override), try await verifiedObject(override, commands: commands) != nil { + return override + } + if let symbolic = try await commands.run( + ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], + allowFailure: true + ).flatMap(normalized), try await verifiedObject(symbolic, commands: commands) != nil { + return symbolic + } + for candidate in ["main", "master", "origin/main", "origin/master"] { + if try await verifiedObject(candidate, commands: commands) != nil { + return candidate + } + } + throw DiffEngineError.defaultBranchUnavailable + } + + private nonisolated func verifiedObject( + _ object: String, + commands: GitCommandExecutor + ) async throws -> String? { + try await commands.run(["rev-parse", "--verify", object], allowFailure: true).flatMap(normalized) + } + + private nonisolated func requiredTrimmed( + _ arguments: [String], + commands: GitCommandExecutor + ) async throws -> String { + guard let value = try await commands.run(arguments).flatMap(normalized) else { + throw DiffEngineError.commandFailed(arguments: arguments, diagnostic: "Git returned no object identifier") + } + return value + } + + private nonisolated func normalized(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseInfo.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseInfo.swift new file mode 100644 index 000000000000..7baef0ba49f8 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseInfo.swift @@ -0,0 +1,20 @@ +/// The concrete Git object selected for a comparison. +public struct DiffBaseInfo: Sendable, Codable, Equatable { + /// The requested baseline kind. + public let kind: DiffBaseKind + /// The resolved Git object identifier used by diff commands. + public let resolvedRef: String + /// A concise description suitable for a diff header. + public let describe: String + + /// Creates resolved baseline metadata. + /// - Parameters: + /// - kind: The requested baseline kind. + /// - resolvedRef: The Git object identifier used by diff commands. + /// - describe: A concise human-readable description. + public init(kind: DiffBaseKind, resolvedRef: String, describe: String) { + self.kind = kind + self.resolvedRef = resolvedRef + self.describe = describe + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseKind.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseKind.swift new file mode 100644 index 000000000000..c136b68a1615 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseKind.swift @@ -0,0 +1,9 @@ +/// A supported comparison baseline for working-tree diffs. +public enum DiffBaseKind: String, Sendable, Codable, Equatable { + /// Compares the worktree with `HEAD`, or the empty tree before the first commit. + case workingTree + /// Compares the worktree with the commit captured at the start of the latest agent turn. + case lastTurn + /// Compares the worktree with the merge base of `HEAD` and the default branch. + case branchBase +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseSpec.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseSpec.swift new file mode 100644 index 000000000000..6825b3880eb1 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffBaseSpec.swift @@ -0,0 +1,16 @@ +/// Describes the baseline requested by a diff caller. +public struct DiffBaseSpec: Sendable, Codable, Equatable { + /// The baseline selection strategy. + public let kind: DiffBaseKind + /// A resolved last-turn commit or an optional branch override. + public let value: String? + + /// Creates a baseline specification. + /// - Parameters: + /// - kind: The baseline selection strategy. + /// - value: For `lastTurn`, the resolved baseline object; for `branchBase`, an optional branch ref. + public init(kind: DiffBaseKind, value: String? = nil) { + self.kind = kind + self.value = value + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffEngineError.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffEngineError.swift new file mode 100644 index 000000000000..8a14730a3467 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffEngineError.swift @@ -0,0 +1,17 @@ +/// Errors produced while resolving or reading a repository diff. +public enum DiffEngineError: Error, Sendable, Equatable { + /// The requested directory is not inside a Git worktree. + case notGitRepository + /// A last-turn request did not include a resolved baseline object. + case baselineUnavailable + /// No usable default branch could be resolved. + case defaultBranchUnavailable + /// A repository-relative path was empty or escaped the repository root. + case invalidPath(String) + /// A requested changed file was not present in the selected diff. + case fileNotFound(String) + /// A context range or file-page cursor was invalid. + case invalidRange + /// A Git command failed, with its arguments and captured diagnostic. + case commandFailed(arguments: [String], diagnostic: String) +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFilePage.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFilePage.swift new file mode 100644 index 000000000000..609762fd966d --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFilePage.swift @@ -0,0 +1,24 @@ +/// One cursor-paged file-diff response. +public struct DiffFilePage: Sendable, Codable, Equatable { + /// Parsed hunks included in this page. + public let hunks: [DiffHunk] + /// Whether the file is binary and therefore has no textual hunks. + public let isBinary: Bool + /// Whether a large textual diff is gated until the caller passes `force`. + public let tooLarge: Bool + /// The opaque row offset for the next page, or `nil` at the end. + public let nextCursor: Int? + + /// Creates a file-diff page. + /// - Parameters: + /// - hunks: Parsed hunks included in the page. + /// - isBinary: Whether the file is binary. + /// - tooLarge: Whether a large diff remains gated. + /// - nextCursor: The next row offset, or `nil` at the end. + public init(hunks: [DiffHunk], isBinary: Bool, tooLarge: Bool, nextCursor: Int?) { + self.hunks = hunks + self.isBinary = isBinary + self.tooLarge = tooLarge + self.nextCursor = nextCursor + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFileStatus.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFileStatus.swift new file mode 100644 index 000000000000..b5e10b754227 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFileStatus.swift @@ -0,0 +1,15 @@ +/// The working-tree status of one changed path. +public enum DiffFileStatus: String, Sendable, Codable, Equatable { + /// A tracked file was added. + case added + /// A tracked file was modified or changed type. + case modified + /// A tracked file was deleted. + case deleted + /// A tracked file moved from ``DiffFileSummary/oldPath``. + case renamed + /// A tracked file was copied from ``DiffFileSummary/oldPath``. + case copied + /// A regular file is not tracked by Git. + case untracked +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFileSummary.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFileSummary.swift new file mode 100644 index 000000000000..9179553682d7 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffFileSummary.swift @@ -0,0 +1,49 @@ +/// Summary metadata for one changed file. +public struct DiffFileSummary: Sendable, Codable, Equatable { + /// The new-side repository-relative path. + public let path: String + /// The old-side path for renames and copies. + public let oldPath: String? + /// The file's working-tree status. + public let status: DiffFileStatus + /// The number of added lines reported by Git or counted for an untracked file. + public let additions: Int + /// The number of deleted lines reported by Git. + public let deletions: Int + /// Whether Git numstat, or in-process untracked-file inspection, identified binary content. + public let isBinary: Bool + /// Whether line churn exceeds 3,000 or the file patch exceeds one MiB. + public let isLarge: Bool + /// A lowercase SHA-256 digest of this file's patch bytes. + public let patchDigest: String + + /// Creates changed-file summary metadata. + /// - Parameters: + /// - path: The new-side repository-relative path. + /// - oldPath: The old-side path for a rename or copy. + /// - status: The file's working-tree status. + /// - additions: The number of added lines. + /// - deletions: The number of deleted lines. + /// - isBinary: Whether the file has binary content. + /// - isLarge: Whether the file exceeds a large-diff threshold. + /// - patchDigest: The lowercase SHA-256 digest of the patch bytes. + public init( + path: String, + oldPath: String?, + status: DiffFileStatus, + additions: Int, + deletions: Int, + isBinary: Bool, + isLarge: Bool, + patchDigest: String + ) { + self.path = path + self.oldPath = oldPath + self.status = status + self.additions = additions + self.deletions = deletions + self.isBinary = isBinary + self.isLarge = isLarge + self.patchDigest = patchDigest + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffHunk.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffHunk.swift new file mode 100644 index 000000000000..eaa2c3c0b55e --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffHunk.swift @@ -0,0 +1,39 @@ +/// A parsed unified-diff hunk. +public struct DiffHunk: Sendable, Codable, Equatable { + /// The first old-side line covered by the hunk. + public let oldStart: Int + /// The number of old-side lines covered by the hunk. + public let oldLines: Int + /// The first new-side line covered by the hunk. + public let newStart: Int + /// The number of new-side lines covered by the hunk. + public let newLines: Int + /// Git's optional section heading following the second `@@` marker. + public let sectionHeading: String? + /// Parsed rows in display order. + public let rows: [DiffRow] + + /// Creates a parsed diff hunk. + /// - Parameters: + /// - oldStart: The hunk's first old-side line. + /// - oldLines: The number of old-side lines in the hunk. + /// - newStart: The hunk's first new-side line. + /// - newLines: The number of new-side lines in the hunk. + /// - sectionHeading: Git's optional hunk section heading. + /// - rows: Parsed rows in display order. + public init( + oldStart: Int, + oldLines: Int, + newStart: Int, + newLines: Int, + sectionHeading: String?, + rows: [DiffRow] + ) { + self.oldStart = oldStart + self.oldLines = oldLines + self.newStart = newStart + self.newLines = newLines + self.sectionHeading = sectionHeading + self.rows = rows + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffPageBuilder.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffPageBuilder.swift new file mode 100644 index 000000000000..f750ff602ca9 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/DiffPageBuilder.swift @@ -0,0 +1,39 @@ +/// Applies the transport row cap while retaining each hunk's coordinates. +struct DiffPageBuilder: Sendable { + let rowLimit: Int + + func page(hunks: [DiffHunk], cursor: Int?) throws -> DiffFilePage { + let offset = cursor ?? 0 + let totalRows = hunks.reduce(0) { $0 + $1.rows.count } + guard offset >= 0, offset <= totalRows else { + throw DiffEngineError.invalidRange + } + let upperBound = min(offset + rowLimit, totalRows) + var globalIndex = 0 + var output: [DiffHunk] = [] + for hunk in hunks { + let hunkStart = globalIndex + let hunkEnd = hunkStart + hunk.rows.count + globalIndex = hunkEnd + let selectionStart = max(offset, hunkStart) + let selectionEnd = min(upperBound, hunkEnd) + guard selectionStart < selectionEnd else { continue } + let localStart = selectionStart - hunkStart + let localEnd = selectionEnd - hunkStart + output.append(DiffHunk( + oldStart: hunk.oldStart, + oldLines: hunk.oldLines, + newStart: hunk.newStart, + newLines: hunk.newLines, + sectionHeading: hunk.sectionHeading, + rows: Array(hunk.rows[localStart.. String? { + let fullArguments = ["-c", "core.quotepath=false"] + arguments + let result = await commandRunner.run( + directory: repositoryDirectory, + executable: "/usr/bin/git", + arguments: fullArguments, + timeout: 30 + ) + if result.executionError == nil, + !result.timedOut, + result.exitStatus == 0, + let stdout = result.stdout { + return stdout + } + if allowFailure { + return nil + } + let diagnostic = result.executionError + ?? (result.timedOut ? "Git command timed out" : result.stderr) + ?? "Git exited with status \(result.exitStatus.map(String.init) ?? "unknown")" + throw DiffEngineError.commandFailed(arguments: fullArguments, diagnostic: diagnostic) + } + + func repositoryRoot() async throws -> String { + guard let output = try await run(["rev-parse", "--show-toplevel"], allowFailure: true) else { + throw DiffEngineError.notGitRepository + } + let root = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !root.isEmpty else { + throw DiffEngineError.notGitRepository + } + return URL(fileURLWithPath: root, isDirectory: true).standardizedFileURL.path + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffFile.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffFile.swift new file mode 100644 index 000000000000..db8165c52e4e --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffFile.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Internal file metadata plus the exact patch bytes used for hashing and parsing. +struct GitDiffFile: Sendable, Equatable { + let summary: DiffFileSummary + let patch: Data +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffSnapshot.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffSnapshot.swift new file mode 100644 index 000000000000..6b5de163bd44 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffSnapshot.swift @@ -0,0 +1,5 @@ +/// A fully batched working-tree snapshot against one resolved baseline. +struct GitDiffSnapshot: Sendable, Equatable { + let base: ResolvedDiffBase + let files: [GitDiffFile] +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffSnapshotLoader.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffSnapshotLoader.swift new file mode 100644 index 000000000000..550f568046cf --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitDiffSnapshotLoader.swift @@ -0,0 +1,99 @@ +import CryptoKit +import Foundation + +/// Loads tracked Git metadata in batches and merges in untracked regular files. +struct GitDiffSnapshotLoader: Sendable { + private static let patchByteLimit = 1_048_576 + private static let changedLineLimit = 3_000 + + let commands: GitCommandExecutor + let repositoryRoot: String + + func load( + base: ResolvedDiffBase, + ignoreWhitespace: Bool, + pathspecs: [String] = [] + ) async throws -> GitDiffSnapshot { + let whitespace = ignoreWhitespace ? ["-w"] : [] + let common = [ + "--no-pager", "diff", "--no-color", "--no-ext-diff", + "--find-renames", "--find-copies-harder", + ] + whitespace + let suffix = [base.object, "--"] + pathspecs + + async let rawOutput = commands.run(common + ["--raw", "-z"] + suffix) + async let numstatOutput = commands.run(common + ["--numstat", "-z"] + suffix) + async let patchOutput = commands.run(common + ["--binary", "--full-index", "--patch", "-z"] + suffix) + async let untrackedOutput = commands.run( + ["ls-files", "--others", "--exclude-standard", "-z", "--"] + pathspecs + ) + + let parser = GitOutputParser() + let changes = parser.rawChanges(try await rawOutput ?? "") + let numstats = parser.numstats(try await numstatOutput ?? "") + let patches = parser.patchSections(try await patchOutput ?? "") + let trackedFiles = numstats.enumerated().map { index, numstat in + let change = changes.first { record in + record.path == numstat.path && + (numstat.oldPath == nil || numstat.oldPath == record.oldPath) + } + let patch = index < patches.count ? patches[index] : Data() + return makeFile( + path: numstat.path, + oldPath: change?.oldPath ?? numstat.oldPath, + status: change?.status ?? .modified, + additions: numstat.additions, + deletions: numstat.deletions, + isBinary: numstat.isBinary, + patch: patch + ) + } + + let reader = WorkingTreeFileReader(repositoryRoot: repositoryRoot) + let untrackedPaths = (try await untrackedOutput ?? "") + .split(separator: "\0", omittingEmptySubsequences: true) + .map(String.init) + var untrackedFiles: [GitDiffFile] = [] + untrackedFiles.reserveCapacity(untrackedPaths.count) + for path in untrackedPaths where !trackedFiles.contains(where: { $0.summary.path == path }) { + guard let data = try reader.regularFileData(path: path) else { continue } + let generated = reader.untrackedPatch(path: path, data: data) + untrackedFiles.append(makeFile( + path: path, + oldPath: nil, + status: .untracked, + additions: generated.additions, + deletions: 0, + isBinary: generated.isBinary, + patch: generated.patch + )) + } + return GitDiffSnapshot(base: base, files: trackedFiles + untrackedFiles) + } + + private func makeFile( + path: String, + oldPath: String?, + status: DiffFileStatus, + additions: Int, + deletions: Int, + isBinary: Bool, + patch: Data + ) -> GitDiffFile { + let digest = SHA256.hash(data: patch).map { String(format: "%02x", $0) }.joined() + let isLarge = additions + deletions > Self.changedLineLimit || patch.count > Self.patchByteLimit + return GitDiffFile( + summary: DiffFileSummary( + path: path, + oldPath: oldPath, + status: status, + additions: additions, + deletions: deletions, + isBinary: isBinary, + isLarge: isLarge, + patchDigest: digest + ), + patch: patch + ) + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitNumstat.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitNumstat.swift new file mode 100644 index 000000000000..1f73917126b9 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitNumstat.swift @@ -0,0 +1,8 @@ +/// One `git diff --numstat -z` record. +struct GitNumstat: Sendable, Equatable { + let path: String + let oldPath: String? + let additions: Int + let deletions: Int + let isBinary: Bool +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitOutputParser.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitOutputParser.swift new file mode 100644 index 000000000000..a282145841cc --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitOutputParser.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Parses NUL-delimited Git plumbing output and batched patch sections. +struct GitOutputParser: Sendable { + func rawChanges(_ output: String) -> [GitRawChange] { + let fields = output.split(separator: "\0", omittingEmptySubsequences: true).map(String.init) + var changes: [GitRawChange] = [] + var index = 0 + while index < fields.count { + let header = fields[index] + index += 1 + guard let token = header.split(separator: " ").last else { break } + let statusCode = token.first ?? "M" + guard index < fields.count else { break } + let firstPath = fields[index] + index += 1 + if statusCode == "R" || statusCode == "C" { + guard index < fields.count else { break } + let newPath = fields[index] + index += 1 + changes.append(GitRawChange( + path: newPath, + oldPath: firstPath, + status: statusCode == "R" ? .renamed : .copied + )) + } else { + changes.append(GitRawChange( + path: firstPath, + oldPath: nil, + status: status(statusCode) + )) + } + } + return changes + } + + func numstats(_ output: String) -> [GitNumstat] { + let fields = output.split(separator: "\0", omittingEmptySubsequences: false).map(String.init) + var records: [GitNumstat] = [] + var index = 0 + while index < fields.count { + let header = fields[index] + index += 1 + if header.isEmpty { continue } + let pieces = header.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) + guard pieces.count >= 3 else { continue } + let isBinary = pieces[0] == "-" || pieces[1] == "-" + let additions = Int(pieces[0]) ?? 0 + let deletions = Int(pieces[1]) ?? 0 + if pieces[2].isEmpty { + guard index + 1 < fields.count else { break } + let oldPath = fields[index] + let path = fields[index + 1] + index += 2 + records.append(GitNumstat( + path: path, + oldPath: oldPath, + additions: additions, + deletions: deletions, + isBinary: isBinary + )) + } else { + records.append(GitNumstat( + path: pieces[2], + oldPath: nil, + additions: additions, + deletions: deletions, + isBinary: isBinary + )) + } + } + return records + } + + func patchSections(_ output: String) -> [Data] { + guard !output.isEmpty else { return [] } + let marker = "\ndiff --git " + let pieces = output.components(separatedBy: marker) + return pieces.enumerated().compactMap { index, piece in + let section = index == 0 ? piece : "diff --git " + piece + return section.hasPrefix("diff --git ") ? Data(section.utf8) : nil + } + } + + private func status(_ code: Character) -> DiffFileStatus { + switch code { + case "A": .added + case "D": .deleted + default: .modified + } + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitRawChange.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitRawChange.swift new file mode 100644 index 000000000000..66621543fe5b --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/GitRawChange.swift @@ -0,0 +1,6 @@ +/// One `git diff --raw -z` status record. +struct GitRawChange: Sendable, Equatable { + let path: String + let oldPath: String? + let status: DiffFileStatus +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/ResolvedDiffBase.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/ResolvedDiffBase.swift new file mode 100644 index 000000000000..68688cfd71dc --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/ResolvedDiffBase.swift @@ -0,0 +1,5 @@ +/// Internal concrete baseline used by the Git snapshot loader. +struct ResolvedDiffBase: Sendable, Equatable { + let info: DiffBaseInfo + let object: String +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/UnifiedDiffParser.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/UnifiedDiffParser.swift new file mode 100644 index 000000000000..584a98420bc1 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/UnifiedDiffParser.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Converts unified patch text into numbered display hunks. +struct UnifiedDiffParser: Sendable { + func parse(_ patch: Data) -> [DiffHunk] { + let lines = String(decoding: patch, as: UTF8.self) + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + var hunks: [DiffHunk] = [] + var index = 0 + while index < lines.count { + guard let header = parseHeader(lines[index]) else { + index += 1 + continue + } + index += 1 + var oldNumber = header.oldStart + var newNumber = header.newStart + var rows: [DiffRow] = [] + while index < lines.count, !lines[index].hasPrefix("@@ ") { + let line = lines[index] + if line.hasPrefix("diff --git ") { break } + if line.hasPrefix("\\ No newline at end of file") { + rows.append(DiffRow(kind: .noNewline, oldNo: nil, newNo: nil, text: "No newline at end of file")) + } else if line.hasPrefix("+") && !line.hasPrefix("+++") { + rows.append(DiffRow(kind: .add, oldNo: nil, newNo: newNumber, text: String(line.dropFirst()))) + newNumber += 1 + } else if line.hasPrefix("-") && !line.hasPrefix("---") { + rows.append(DiffRow(kind: .del, oldNo: oldNumber, newNo: nil, text: String(line.dropFirst()))) + oldNumber += 1 + } else if line.hasPrefix(" ") { + rows.append(DiffRow( + kind: .context, + oldNo: oldNumber, + newNo: newNumber, + text: String(line.dropFirst()) + )) + oldNumber += 1 + newNumber += 1 + } + index += 1 + } + hunks.append(DiffHunk( + oldStart: header.oldStart, + oldLines: header.oldLines, + newStart: header.newStart, + newLines: header.newLines, + sectionHeading: header.sectionHeading, + rows: rows + )) + } + return hunks + } + + private func parseHeader(_ line: String) -> Header? { + guard line.hasPrefix("@@ "), + let closing = line.range(of: " @@", range: line.index(line.startIndex, offsetBy: 3).. (start: Int, count: Int)? { + guard value.first == prefix else { return nil } + let parts = value.dropFirst().split(separator: ",", omittingEmptySubsequences: false) + guard let start = Int(parts[0]) else { return nil } + let count = parts.count > 1 ? Int(parts[1]) ?? 0 : 1 + return (start, count) + } + + private struct Header { + let oldStart: Int + let oldLines: Int + let newStart: Int + let newLines: Int + let sectionHeading: String? + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/WorkingTreeFileReader.swift b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/WorkingTreeFileReader.swift new file mode 100644 index 000000000000..4bc30b3167fa --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Sources/CmuxDiffEngine/WorkingTreeFileReader.swift @@ -0,0 +1,79 @@ +import Darwin +import Foundation + +/// Reads regular worktree files without spawning one Git process per untracked path. +struct WorkingTreeFileReader: Sendable { + let repositoryRoot: String + + func regularFileData(path: String) throws -> Data? { + let url = try absoluteURL(path: path) + var metadata = stat() + guard lstat(url.path, &metadata) == 0 else { return nil } + guard metadata.st_mode & S_IFMT == S_IFREG else { return nil } + return try Data(contentsOf: url, options: [.mappedIfSafe]) + } + + func lines(path: String, startLine: Int, endLine: Int) throws -> [String] { + guard startLine >= 1, endLine >= startLine else { + throw DiffEngineError.invalidRange + } + guard let data = try regularFileData(path: path) else { + throw DiffEngineError.fileNotFound(path) + } + return selectedLines(data: data, startLine: startLine, endLine: endLine) + } + + func selectedLines(data: Data, startLine: Int, endLine: Int) -> [String] { + var lines = String(decoding: data, as: UTF8.self) + .split(separator: "\n", omittingEmptySubsequences: false) + .map { line in + line.last == "\r" ? String(line.dropLast()) : String(line) + } + if data.last == 0x0A, lines.last == "" { + lines.removeLast() + } + guard startLine <= lines.count else { return [] } + return Array(lines[(startLine - 1).. (patch: Data, additions: Int, isBinary: Bool) { + let isBinary = data.contains(0) + if isBinary { + let header = "diff --git a/\(path) b/\(path)\nnew file mode 100644\nBinary files /dev/null and b/\(path) differ\n" + return (Data(header.utf8), 0, true) + } + let text = String(decoding: data, as: UTF8.self) + var rows = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let hasTrailingNewline = data.last == 0x0A + if hasTrailingNewline, rows.last == "" { + rows.removeLast() + } + let additions = data.isEmpty ? 0 : rows.count + var patch = "diff --git a/\(path) b/\(path)\nnew file mode 100644\n--- /dev/null\n+++ b/\(path)\n" + if additions > 0 { + patch += "@@ -0,0 +1,\(additions) @@\n" + patch += rows.map { "+" + $0 }.joined(separator: "\n") + patch += "\n" + if !hasTrailingNewline { + patch += "\\ No newline at end of file\n" + } + } + return (Data(patch.utf8), additions, false) + } + + private func absoluteURL(path: String) throws -> URL { + guard !path.isEmpty, !path.hasPrefix("/") else { + throw DiffEngineError.invalidPath(path) + } + let root = URL(fileURLWithPath: repositoryRoot, isDirectory: true).standardizedFileURL + let candidate = root.appendingPathComponent(path, isDirectory: false).standardizedFileURL + let resolvedRoot = root.resolvingSymlinksInPath() + let resolvedCandidate = candidate.resolvingSymlinksInPath() + let lexicalPrefix = root.path.hasSuffix("/") ? root.path : root.path + "/" + let prefix = resolvedRoot.path.hasSuffix("/") ? resolvedRoot.path : resolvedRoot.path + "/" + guard candidate.path.hasPrefix(lexicalPrefix), resolvedCandidate.path.hasPrefix(prefix) else { + throw DiffEngineError.invalidPath(path) + } + return candidate + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineErrorTests.swift b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineErrorTests.swift new file mode 100644 index 000000000000..d9dda95fb090 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineErrorTests.swift @@ -0,0 +1,95 @@ +import CmuxDiffEngine +import CmuxFoundation +import Foundation +import Testing + +@Suite +struct CmuxDiffEngineErrorTests { + @Test + func mapsRepositoryProbeFailureToNotGitRepository() async { + let engine = CmuxDiffEngine(commandRunner: FakeCommandRunner.failure("not a repository")) + await #expect(throws: DiffEngineError.notGitRepository) { + try await engine.summary( + repositoryPath: "/tmp", + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + } + } + + @Test + func reportsMissingLastTurnBaselineBeforeDiffCommands() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("base\n", path: "file.txt") + _ = try repo.commitAll() + await #expect(throws: DiffEngineError.baselineUnavailable) { + try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .lastTurn), + ignoreWhitespace: false + ) + } + } + + @Test + func rejectsEscapingContextPath() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + await #expect(throws: DiffEngineError.invalidPath("../secret")) { + try await CmuxDiffEngine().contextRows( + repositoryPath: repo.root.path, + path: "../secret", + startLine: 1, + endLine: 1 + ) + } + } + + @Test + func surfacesInjectedGitFailureAfterRepositoryResolution() async { + let runner = FakeCommandRunner { directory, arguments in + if arguments.contains("--show-toplevel") { + return CommandResult( + stdout: directory + "\n", + stderr: "", + exitStatus: 0, + timedOut: false, + executionError: nil + ) + } + if arguments.contains("rev-parse") { + return CommandResult( + stdout: "", + stderr: "missing HEAD", + exitStatus: 128, + timedOut: false, + executionError: nil + ) + } + return CommandResult( + stdout: nil, + stderr: "hash failure", + exitStatus: 1, + timedOut: false, + executionError: nil + ) + } + do { + _ = try await CmuxDiffEngine(commandRunner: runner).summary( + repositoryPath: "/tmp", + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + Issue.record("Expected command failure") + } catch let error as DiffEngineError { + guard case .commandFailed(_, let diagnostic) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(diagnostic == "hash failure") + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineFileTests.swift b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineFileTests.swift new file mode 100644 index 000000000000..6ca414b74794 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineFileTests.swift @@ -0,0 +1,169 @@ +import CmuxDiffEngine +import Foundation +import Testing + +@Suite +struct CmuxDiffEngineFileTests { + @Test + func parsesNumberedHunksNoNewlineMarkersAndPagesRows() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("one\ntwo\nthree\nfour\n", path: "page.txt") + _ = try repo.commitAll() + try repo.write("one\nTWO\nthree\nfour\nfive", path: "page.txt") + let engine = CmuxDiffEngine(rowLimit: 2) + + let first = try await engine.fileHunks( + repositoryPath: repo.root.path, + path: "page.txt", + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + #expect(first.hunks.flatMap(\.rows).count == 2) + #expect(first.nextCursor == 2) + let second = try await engine.fileHunks( + repositoryPath: repo.root.path, + path: "page.txt", + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false, + cursor: first.nextCursor + ) + #expect(second.hunks.flatMap(\.rows).count == 2) + #expect(second.nextCursor == 4) + + var cursor: Int? = 0 + var rows: [DiffRow] = [] + repeat { + let page = try await engine.fileHunks( + repositoryPath: repo.root.path, + path: "page.txt", + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false, + cursor: cursor + ) + rows.append(contentsOf: page.hunks.flatMap(\.rows)) + cursor = page.nextCursor + } while cursor != nil + #expect(rows.contains(DiffRow(kind: .del, oldNo: 2, newNo: nil, text: "two"))) + #expect(rows.contains(DiffRow(kind: .add, oldNo: nil, newNo: 2, text: "TWO"))) + #expect(rows.contains(DiffRow(kind: .add, oldNo: nil, newNo: 5, text: "five"))) + #expect(rows.last?.kind == .noNewline) + } + + @Test + func gatesLargeLineCountUntilForced() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + let content = (1...3_001).map { "line \($0)" }.joined(separator: "\n") + "\n" + try repo.write(content, path: "large.txt") + let engine = CmuxDiffEngine(rowLimit: 2_000) + let summary = try await engine.summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + #expect(summary.files.first?.isLarge == true) + + let gated = try await engine.fileHunks( + repositoryPath: repo.root.path, + path: "large.txt", + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + #expect(gated.tooLarge) + #expect(gated.hunks.isEmpty) + let forced = try await engine.fileHunks( + repositoryPath: repo.root.path, + path: "large.txt", + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false, + force: true + ) + #expect(!forced.tooLarge) + #expect(forced.hunks.flatMap(\.rows).count == 2_000) + #expect(forced.nextCursor == 2_000) + } + + @Test + func detectsPatchByteLimitAndBinaryNumstat() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("seed\n", path: "huge.txt") + try repo.write(Data([0, 1, 2, 3]), path: "binary.dat") + _ = try repo.commitAll() + try repo.write(String(repeating: "x", count: 1_048_700) + "\n", path: "huge.txt") + try repo.write(Data([0, 1, 4, 5]), path: "binary.dat") + + let summary = try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + #expect(summary.files.first(where: { $0.path == "huge.txt" })?.isLarge == true) + let binary = try #require(summary.files.first(where: { $0.path == "binary.dat" })) + #expect(binary.isBinary) + let page = try await CmuxDiffEngine().fileHunks( + repositoryPath: repo.root.path, + path: "binary.dat", + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + #expect(page.isBinary) + #expect(page.hunks.isEmpty) + } + + @Test + func readsWorkingAndDeletedFileContext() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("old one\nold two\nold three\n", path: "deleted.txt") + try repo.write("one\ntwo\nthree\nfour\n", path: "working.txt") + _ = try repo.commitAll() + try FileManager.default.removeItem(at: repo.root.appendingPathComponent("deleted.txt")) + try repo.write("one\nchanged\nthree\nfour\n", path: "working.txt") + + let engine = CmuxDiffEngine() + let working = try await engine.contextRows( + repositoryPath: repo.root.path, + path: "working.txt", + startLine: 2, + endLine: 3 + ) + let deleted = try await engine.contextRows( + repositoryPath: repo.root.path, + path: "deleted.txt", + startLine: 1, + endLine: 2 + ) + #expect(working == ["changed", "three"]) + #expect(deleted == ["old one", "old two"]) + } + + @Test + func stablePatchDigestChangesWithContent() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("base\n", path: "digest.txt") + _ = try repo.commitAll() + try repo.write("base\nfirst\n", path: "digest.txt") + let engine = CmuxDiffEngine() + let first = try await engine.summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ).files[0].patchDigest + let repeatDigest = try await engine.summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ).files[0].patchDigest + try repo.write("base\nsecond\n", path: "digest.txt") + let changed = try await engine.summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ).files[0].patchDigest + #expect(first == repeatDigest) + #expect(first != changed) + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineSummaryTests.swift b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineSummaryTests.swift new file mode 100644 index 000000000000..bb8c0ca431d6 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/CmuxDiffEngineSummaryTests.swift @@ -0,0 +1,182 @@ +import CmuxDiffEngine +import Foundation +import Testing + +@Suite +struct CmuxDiffEngineSummaryTests { + @Test + func summarizesStagedUnstagedAndUntrackedFilesWithNulSafePaths() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("one\n", path: "tracked.txt") + _ = try repo.commitAll() + + try repo.write("one\ntwo\n", path: "tracked.txt") + try repo.write("first\nsecond\n", path: "added name.txt") + try repo.git(["add", "added name.txt"]) + try repo.write("uno\ndos", path: "untracked ü.txt") + try FileManager.default.createSymbolicLink( + at: repo.root.appendingPathComponent("ignored-link"), + withDestinationURL: repo.root.appendingPathComponent("untracked ü.txt") + ) + + let summary = try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + + #expect(summary.totals == DiffTotals(files: 3, additions: 5, deletions: 0)) + #expect(summary.files.first(where: { $0.path == "tracked.txt" })?.status == .modified) + #expect(summary.files.first(where: { $0.path == "added name.txt" })?.status == .added) + let untracked = try #require(summary.files.first(where: { $0.path == "untracked ü.txt" })) + #expect(untracked.status == .untracked) + #expect(untracked.additions == 2) + #expect(untracked.patchDigest.count == 64) + #expect(!summary.files.contains(where: { $0.path == "ignored-link" })) + } + + @Test + func detectsRenameAndCopyWithOldPaths() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("alpha\nbeta\ngamma\ndelta\n", path: "original.txt") + _ = try repo.commitAll() + + try repo.git(["mv", "original.txt", "renamed.txt"]) + try repo.write("alpha\nbeta\ngamma\ndelta\nchanged\n", path: "renamed.txt") + try repo.write("alpha\nbeta\ngamma\ndelta\n", path: "copy.txt") + try repo.git(["add", "copy.txt"]) + + let summary = try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + let renamed = try #require(summary.files.first(where: { $0.status == .renamed })) + let copied = try #require(summary.files.first(where: { $0.status == .copied })) + #expect(renamed.oldPath == "original.txt") + #expect(copied.oldPath == "original.txt") + + let page = try await CmuxDiffEngine().fileHunks( + repositoryPath: repo.root.path, + path: renamed.path, + oldPath: renamed.oldPath, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + let rows = page.hunks.flatMap(\.rows) + let containsChangedAddition = rows.contains { row in + row.kind == .add && row.text == "changed" + } + #expect(containsChangedAddition) + } + + @Test + func ignoresWhitespaceOnlyChangesWhenRequested() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("alpha beta\n", path: "space.txt") + _ = try repo.commitAll() + try repo.write("alpha beta\n", path: "space.txt") + + let included = try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + let ignored = try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: true + ) + #expect(included.totals.files == 1) + #expect(ignored.totals.files == 0) + } + + @Test + func usesEmptyTreeForUnbornHead() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("first\nsecond\n", path: "birth.txt") + try repo.git(["add", "birth.txt"]) + + let emptyTree = try repo.gitOutput(["hash-object", "-t", "tree", "/dev/null"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let summary = try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + #expect(summary.baseInfo.resolvedRef == emptyTree) + #expect(summary.files.first?.status == .added) + #expect(summary.totals.additions == 2) + } + + @Test + func resolvesBranchMergeBase() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("base\n", path: "story.txt") + let base = try repo.commitAll("base") + try repo.git(["switch", "-q", "-c", "feature"]) + try repo.write("base\nfeature\n", path: "story.txt") + _ = try repo.commitAll("feature") + try repo.write("base\nfeature\nworking\n", path: "story.txt") + + let summary = try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .branchBase), + ignoreWhitespace: false + ) + #expect(summary.baseInfo.resolvedRef == base) + #expect(summary.totals.additions == 2) + } + + @Test + func comparesAgainstResolvedLastTurnObject() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("before\n", path: "turn.txt") + let baseline = try repo.commitAll("turn baseline") + try repo.write("before\nafter\n", path: "turn.txt") + + let summary = try await CmuxDiffEngine().summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .lastTurn, value: baseline), + ignoreWhitespace: false + ) + #expect(summary.baseInfo.kind == .lastTurn) + #expect(summary.baseInfo.resolvedRef == baseline) + #expect(summary.totals.additions == 1) + } + + @Test + func hardensAndBatchesGitCommandsWithoutPerUntrackedSpawns() async throws { + let repo = try FixtureRepository() + defer { repo.remove() } + try repo.write("tracked\n", path: "tracked.txt") + _ = try repo.commitAll() + try repo.write("tracked\nchanged\n", path: "tracked.txt") + try repo.write("one\n", path: "untracked-one.txt") + try repo.write("two\n", path: "untracked-two.txt") + let runner = RecordingCommandRunner() + + _ = try await CmuxDiffEngine(commandRunner: runner).summary( + repositoryPath: repo.root.path, + baseSpec: DiffBaseSpec(kind: .workingTree), + ignoreWhitespace: false + ) + let calls = await runner.arguments() + #expect(calls.allSatisfy { $0.prefix(2) == ["-c", "core.quotepath=false"] }) + let diffCalls = calls.filter { $0.contains("diff") } + #expect(diffCalls.count == 3) + #expect(diffCalls.allSatisfy { arguments in + arguments.contains("--no-color") && + arguments.contains("--no-ext-diff") && + arguments.contains("-z") + }) + #expect(calls.filter { $0.contains("--numstat") }.count == 1) + #expect(calls.filter { $0.contains("ls-files") }.count == 1) + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FakeCommandRunner.swift b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FakeCommandRunner.swift new file mode 100644 index 000000000000..d2356c7cf13b --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FakeCommandRunner.swift @@ -0,0 +1,27 @@ +import CmuxFoundation +import Foundation + +struct FakeCommandRunner: CommandRunning { + let response: @Sendable (_ directory: String, _ arguments: [String]) -> CommandResult + + func run( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? + ) async -> CommandResult { + response(directory, arguments) + } + + static func failure(_ diagnostic: String) -> FakeCommandRunner { + FakeCommandRunner { _, _ in + CommandResult( + stdout: nil, + stderr: diagnostic, + exitStatus: 128, + timedOut: false, + executionError: nil + ) + } + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FixtureRepository.swift b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FixtureRepository.swift new file mode 100644 index 000000000000..f4863fbae58c --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FixtureRepository.swift @@ -0,0 +1,62 @@ +import Foundation + +struct FixtureRepository { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("CmuxDiffEngineTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try git(["init", "-q", "-b", "main"]) + try git(["config", "user.name", "cmux diff tests"]) + try git(["config", "user.email", "diff-tests@example.invalid"]) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + + func write(_ text: String, path: String) throws { + let url = root.appendingPathComponent(path) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try text.write(to: url, atomically: true, encoding: .utf8) + } + + func write(_ data: Data, path: String) throws { + let url = root.appendingPathComponent(path) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url) + } + + func commitAll(_ message: String = "fixture") throws -> String { + try git(["add", "-A"]) + try git(["commit", "-q", "-m", message]) + return try gitOutput(["rev-parse", "HEAD"]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + func git(_ arguments: [String]) throws { + _ = try gitOutput(arguments) + } + + func gitOutput(_ arguments: [String]) throws -> String { + let process = Process() + let stdout = Pipe() + let stderr = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = root + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + let output = stdout.fileHandleForReading.readDataToEndOfFile() + let diagnostic = stderr.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + throw FixtureRepositoryError.gitFailed( + arguments: arguments, + diagnostic: String(decoding: diagnostic, as: UTF8.self) + ) + } + return String(decoding: output, as: UTF8.self) + } +} diff --git a/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FixtureRepositoryError.swift b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FixtureRepositoryError.swift new file mode 100644 index 000000000000..f1b04c411838 --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/FixtureRepositoryError.swift @@ -0,0 +1,3 @@ +enum FixtureRepositoryError: Error { + case gitFailed(arguments: [String], diagnostic: String) +} diff --git a/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/RecordingCommandRunner.swift b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/RecordingCommandRunner.swift new file mode 100644 index 000000000000..175fbff4721a --- /dev/null +++ b/Packages/macOS/CmuxDiffEngine/Tests/CmuxDiffEngineTests/RecordingCommandRunner.swift @@ -0,0 +1,26 @@ +import CmuxFoundation +import Foundation + +actor RecordingCommandRunner: CommandRunning { + private let underlying = CommandRunner() + private var recordedArguments: [[String]] = [] + + func run( + directory: String, + executable: String, + arguments: [String], + timeout: TimeInterval? + ) async -> CommandResult { + recordedArguments.append(arguments) + return await underlying.run( + directory: directory, + executable: executable, + arguments: arguments, + timeout: timeout + ) + } + + func arguments() -> [[String]] { + recordedArguments + } +} diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index 6303d0d6a008..1d91378dcd01 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -32,6 +32,7 @@ extension MobileHostService { "workspace.group_actions.v1", "workspace.group_create.v1", "workspace.create_in_group.v1", + "workspace.diffs.v1", "chat.artifact.v1", "chat.artifact.gallery.v1", "dogfood.v1", diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift index 659d7dc50925..a7ccb3f42023 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift @@ -98,7 +98,7 @@ final class SidebarRowPullRequestIconView: NSView { // transparent backing draws nothing (no destination pixels). let tinted = NSImage(size: image.size, flipped: false) { drawRect in image.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1) - color.set() + self.color.set() drawRect.fill(using: .sourceAtop) return true } diff --git a/Sources/TerminalController+MobileDiffs.swift b/Sources/TerminalController+MobileDiffs.swift new file mode 100644 index 000000000000..609e99de4f8f --- /dev/null +++ b/Sources/TerminalController+MobileDiffs.swift @@ -0,0 +1,323 @@ +import CmuxDiffEngine +import Foundation +import os + +private let mobileDiffLog = Logger(subsystem: "dev.cmux", category: "mobile-diffs") + +extension TerminalController { + /// Routes the read-only native diff namespace through one app dispatch case. + func v2MobileDiffDispatch(method: String, params: [String: Any]) async -> V2CallResult { + let routing = mobileDiffRoutingParams(params) + let requestedWorkspace = v2String(routing, "workspace_id") ?? "unknown" + guard v2HasNonNullParam(routing, "workspace_id"), v2UUID(routing, "workspace_id") != nil else { + return mobileDiffFailure( + method: method, + repository: requestedWorkspace, + code: "workspace_not_found" + ) + } + guard let resolved = mobileResolveWorkspaceAndSurface(params: routing, requireTerminal: false) else { + return mobileDiffFailure( + method: method, + repository: requestedWorkspace, + code: "workspace_not_found" + ) + } + guard let requestedBase = mobileDiffBaseSpec(params) else { + return mobileDiffFailure( + method: method, + repository: requestedWorkspace, + code: "invalid_params" + ) + } + + let workspace = resolved.workspace + var repository = workspace.resolvedWorkingDirectory() ?? "" + var baseSpec = requestedBase + if requestedBase.kind == .lastTurn { + guard let surfaceID = workspace.focusedPanelId, + let snapshot = SharedLiveAgentIndex.shared.snapshot( + workspaceId: workspace.id, + panelId: surfaceID + ), + let sessionID = AppDelegate.normalizedOpenDiffViewerSessionId(snapshot.sessionId) else { + return mobileDiffFailure( + method: method, + repository: repository, + code: "baseline_unavailable" + ) + } + let storeURL = AppDelegate.agentTurnDiffBaselineStoreURL() + let workspaceID = workspace.id + let baseline = await Task.detached(priority: .userInitiated) { + Self.mobileDiffBaseline( + storeURL: storeURL, + workspaceID: workspaceID, + surfaceID: surfaceID, + sessionID: sessionID + ) + }.value + guard let baseline else { + return mobileDiffFailure( + method: method, + repository: repository, + code: "baseline_unavailable" + ) + } + repository = baseline.repository + baseSpec = DiffBaseSpec(kind: .lastTurn, value: baseline.commit) + } + guard !repository.isEmpty else { + return mobileDiffFailure( + method: method, + repository: requestedWorkspace, + code: "not_git_repository" + ) + } + + do { + let engine = CmuxDiffEngine() + let ignoreWhitespace = mobileDiffBool(params, camel: "ignoreWhitespace", snake: "ignore_whitespace") ?? false + let payload: [String: Any] + let fileCount: Int + switch method { + case "mobile.workspace.diffs.summary": + let summary = try await engine.summary( + repositoryPath: repository, + baseSpec: baseSpec, + ignoreWhitespace: ignoreWhitespace + ) + payload = mobileDiffSummaryPayload(summary) + fileCount = summary.files.count + case "mobile.workspace.diffs.file": + guard let path = v2String(params, "path") else { + return mobileDiffFailure( + method: method, + repository: repository, + code: "invalid_params" + ) + } + let page = try await engine.fileHunks( + repositoryPath: repository, + path: path, + oldPath: v2String(params, "oldPath") ?? v2String(params, "old_path"), + baseSpec: baseSpec, + ignoreWhitespace: ignoreWhitespace, + cursor: mobileDiffInt(params, camel: "cursor", snake: "cursor"), + force: mobileDiffBool(params, camel: "force", snake: "force") ?? false + ) + payload = mobileDiffFilePayload(page) + fileCount = 1 + case "mobile.workspace.diffs.context": + guard let path = v2String(params, "path"), + let startLine = mobileDiffInt(params, camel: "startLine", snake: "start_line"), + let endLine = mobileDiffInt(params, camel: "endLine", snake: "end_line") else { + return mobileDiffFailure( + method: method, + repository: repository, + code: "invalid_params" + ) + } + let rows = try await engine.contextRows( + repositoryPath: repository, + path: path, + startLine: startLine, + endLine: endLine + ) + payload = ["rows": rows] + fileCount = 1 + default: + return mobileDiffFailure( + method: method, + repository: repository, + code: "method_not_found" + ) + } + mobileDiffSuccess( + method: method, + repository: repository, + fileCount: fileCount, + payload: payload + ) + return .ok(payload) + } catch let error as DiffEngineError { + return mobileDiffFailure( + method: method, + repository: repository, + code: mobileDiffErrorCode(error) + ) + } catch { + return mobileDiffFailure( + method: method, + repository: repository, + code: "internal_error" + ) + } + } + + private func mobileDiffRoutingParams(_ params: [String: Any]) -> [String: Any] { + guard !v2HasNonNullParam(params, "workspace_id") else { return params } + var routing = params + if let workspace = v2String(params, "workspace") + ?? v2String(params, "workspaceRef") + ?? v2String(params, "workspace_ref") { + routing["workspace_id"] = workspace + } + return routing + } + + private func mobileDiffBaseSpec(_ params: [String: Any]) -> DiffBaseSpec? { + guard let object = params["baseSpec"] as? [String: Any] + ?? params["base_spec"] as? [String: Any], + let rawKind = v2String(object, "kind"), + let kind = DiffBaseKind(rawValue: rawKind) else { + return nil + } + return DiffBaseSpec(kind: kind, value: v2String(object, "value")) + } + + private func mobileDiffBool(_ params: [String: Any], camel: String, snake: String) -> Bool? { + v2Bool(params, camel) ?? v2Bool(params, snake) + } + + private func mobileDiffInt(_ params: [String: Any], camel: String, snake: String) -> Int? { + v2StrictInt(params, camel) ?? v2StrictInt(params, snake) + } + + private func mobileDiffSummaryPayload(_ summary: DiffSummary) -> [String: Any] { + let files = summary.files.map { file -> [String: Any] in + [ + "path": file.path, + "oldPath": v2OrNull(file.oldPath), + "status": file.status.rawValue, + "additions": file.additions, + "deletions": file.deletions, + "isBinary": file.isBinary, + "isLarge": file.isLarge, + "patchDigest": file.patchDigest, + ] + } + return [ + "baseInfo": [ + "kind": summary.baseInfo.kind.rawValue, + "resolvedRef": summary.baseInfo.resolvedRef, + "describe": summary.baseInfo.describe, + ], + "totals": [ + "files": summary.totals.files, + "additions": summary.totals.additions, + "deletions": summary.totals.deletions, + ], + "files": files, + "truncatedFileCount": summary.truncatedFileCount, + ] + } + + private func mobileDiffFilePayload(_ page: DiffFilePage) -> [String: Any] { + let hunks = page.hunks.map { hunk -> [String: Any] in + [ + "oldStart": hunk.oldStart, + "oldLines": hunk.oldLines, + "newStart": hunk.newStart, + "newLines": hunk.newLines, + "sectionHeading": v2OrNull(hunk.sectionHeading), + "rows": hunk.rows.map { row -> [String: Any] in + [ + "kind": row.kind.rawValue, + "oldNo": v2OrNull(row.oldNo), + "newNo": v2OrNull(row.newNo), + "text": row.text, + ] + }, + ] + } + return [ + "hunks": hunks, + "isBinary": page.isBinary, + "tooLarge": page.tooLarge, + "nextCursor": v2OrNull(page.nextCursor), + ] + } + + private func mobileDiffErrorCode(_ error: DiffEngineError) -> String { + switch error { + case .notGitRepository: + "not_git_repository" + case .baselineUnavailable: + "baseline_unavailable" + case .fileNotFound: + "not_found" + case .invalidPath, .invalidRange: + "invalid_params" + case .defaultBranchUnavailable: + "default_branch_unavailable" + case .commandFailed: + "git_error" + } + } + + private func mobileDiffFailure( + method: String, + repository: String, + code: String + ) -> V2CallResult { + mobileDiffLog.error( + "method=\(method, privacy: .public) repo=\(repository, privacy: .public) files=0 bytes=0 error=\(code, privacy: .public)" + ) + let message: String + if code == "workspace_not_found" { + message = String( + localized: "rpc.v2.surface.respawn.workspaceNotFound", + defaultValue: "Workspace not found" + ) + } else { + message = code + } + return .err(code: code, message: message, data: nil) + } + + private func mobileDiffSuccess( + method: String, + repository: String, + fileCount: Int, + payload: [String: Any] + ) { + let byteCount = (try? JSONSerialization.data(withJSONObject: payload).count) ?? 0 + mobileDiffLog.info( + "method=\(method, privacy: .public) repo=\(repository, privacy: .public) files=\(fileCount) bytes=\(byteCount) ok" + ) + } + + nonisolated private static func mobileDiffBaseline( + storeURL: URL, + workspaceID: UUID, + surfaceID: UUID, + sessionID: String + ) -> (repository: String, commit: String)? { + guard let repository = AppDelegate.latestAgentTurnDiffRepoRoot( + storeURL: storeURL, + workspaceId: workspaceID, + surfaceId: surfaceID, + sessionId: sessionID + ), + let data = try? Data(contentsOf: storeURL), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let records = object["records"] as? [[String: Any]] else { + return nil + } + let workspaceKey = workspaceID.uuidString.lowercased() + let surfaceKey = surfaceID.uuidString.lowercased() + let candidates = records.compactMap { record -> (commit: String, capturedAt: TimeInterval)? in + guard AppDelegate.normalizedOpenDiffViewerIdentifier(record["workspaceId"] as? String) == workspaceKey, + AppDelegate.normalizedOpenDiffViewerIdentifier(record["surfaceId"] as? String) == surfaceKey, + AppDelegate.normalizedOpenDiffViewerSessionId(record["sessionId"] as? String) == sessionID, + AppDelegate.normalizedOpenDiffViewerPath(record["repoRoot"] as? String) == repository, + let commit = AppDelegate.normalizedOpenDiffViewerIdentifier(record["baseCommit"] as? String) else { + return nil + } + return (commit, (record["capturedAt"] as? NSNumber)?.doubleValue ?? 0) + } + guard let latest = candidates.max(by: { $0.capturedAt < $1.capturedAt }) else { return nil } + return (repository, latest.commit) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4bb240b4e293..c3171845da87 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13823,6 +13823,8 @@ class TerminalController { result = v2MobileTerminalMouse(params: request.params) case let method where method.hasPrefix("mobile.terminal.artifact."): result = await v2MobileTerminalArtifactDispatch(method: method, params: request.params) + case let method where method.hasPrefix("mobile.workspace.diffs."): + result = await v2MobileDiffDispatch(method: method, params: request.params) case "workspace.action": result = v2MobileWorkspaceAction(params: request.params) case "workspace.move": diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8292d27ed2ce..6530f8d94eba 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -572,6 +572,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources CC0DE00000000000000000A3 /* CmuxCore in Frameworks */ = {isa = PBXBuildFile; productRef = CC0DE00000000000000000A2 /* CmuxCore */; }; CC0DE00000000000000000A4 /* CmuxCore in Frameworks */ = {isa = PBXBuildFile; productRef = CC0DE00000000000000000A2 /* CmuxCore */; }; A500D015A1B2C3D4E5F60718 /* CMUXDebugLog in Frameworks */ = {isa = PBXBuildFile; productRef = A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */; }; + D1FF00000000000000000003 /* CmuxDiffEngine in Frameworks */ = {isa = PBXBuildFile; productRef = D1FF00000000000000000002 /* CmuxDiffEngine */; }; D1320AA0D1320AA0D1320AA2 /* CmuxDockTilePlugin.plugin in Copy Dock Tile Plugin */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C54860040000000000000001 /* CmuxDurableDeepLinkRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860040000000000000002 /* CmuxDurableDeepLinkRestoreTests.swift */; }; E7E000000000000000000005 /* CmuxEventBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E000000000000000000006 /* CmuxEventBus.swift */; }; @@ -1731,6 +1732,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A7C0F0020000000000000002 /* TerminalController+MobileAttachTicket.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C0F0020000000000000001 /* TerminalController+MobileAttachTicket.swift */; }; ACA7C4A7000000000000000D /* TerminalController+MobileChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */; }; C7AF10000000000000000003 /* TerminalController+MobileChatArtifacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */; }; + D1FF00000000000000000011 /* TerminalController+MobileDiffs.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FF00000000000000000010 /* TerminalController+MobileDiffs.swift */; }; D7AB00000000000000B001 /* TerminalController+MobileNotificationSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */; }; D7AB00000000000000B011 /* TerminalController+MobileScrollPrefetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */; }; C7AF20000000000000000001 /* TerminalController+MobileTerminalArtifacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */; }; @@ -3744,6 +3746,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7C0F0020000000000000001 /* TerminalController+MobileAttachTicket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileAttachTicket.swift"; sourceTree = ""; }; ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileChat.swift"; sourceTree = ""; }; C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileChatArtifacts.swift"; sourceTree = ""; }; + D1FF00000000000000000010 /* TerminalController+MobileDiffs.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileDiffs.swift"; sourceTree = ""; }; D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileNotificationSync.swift"; sourceTree = ""; }; D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileScrollPrefetch.swift"; sourceTree = ""; }; C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "TerminalController+MobileTerminalArtifacts.swift"; sourceTree = ""; }; @@ -4060,6 +4063,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55500000000000000000E3 /* CmuxCommandPalette in Frameworks */, C750100000000000000000A3 /* CmuxControlSocket in Frameworks */, CC0DE00000000000000000A3 /* CmuxCore in Frameworks */, + D1FF00000000000000000003 /* CmuxDiffEngine in Frameworks */, C0DE45000000000000000001 /* CmuxExtensionKit in Frameworks */, C0DE49000000000000000001 /* CmuxExtensionSidebarExamples in Frameworks */, C9A2C00000000000000000C3 /* CmuxFeedback in Frameworks */, @@ -4909,6 +4913,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE00000000000000000C51 /* TerminalController+ControlMobileHostContext.swift */, A7C0F0020000000000000001 /* TerminalController+MobileAttachTicket.swift */, ACA7C4A7000000000000000E /* TerminalController+MobileChat.swift */, + D1FF00000000000000000010 /* TerminalController+MobileDiffs.swift */, C7AF10000000000000000004 /* TerminalController+MobileChatArtifacts.swift */, C7AF20000000000000000002 /* TerminalController+MobileTerminalArtifacts.swift */, C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */, @@ -6257,6 +6262,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55200000000000000000B2 /* CmuxWindowing */, 5E55500000000000000000E2 /* CmuxCommandPalette */, C617000000000000000000A2 /* CmuxGit */, + D1FF00000000000000000002 /* CmuxDiffEngine */, 1A0B0C0D0E0F101112132012 /* CmuxIrohTransport */, C5B6A10000000000000000A2 /* CmuxSidebarGit */, E3B7A30000000000000000B2 /* CmuxSidebar */, @@ -6442,6 +6448,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55200000000000000000B1 /* XCLocalSwiftPackageReference "CmuxWindowing" */, 5E55500000000000000000E1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, + D1FF00000000000000000001 /* XCLocalSwiftPackageReference "CmuxDiffEngine" */, C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */, E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */, E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */, @@ -7714,6 +7721,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7C0F0020000000000000002 /* TerminalController+MobileAttachTicket.swift in Sources */, ACA7C4A7000000000000000D /* TerminalController+MobileChat.swift in Sources */, C7AF10000000000000000003 /* TerminalController+MobileChatArtifacts.swift in Sources */, + D1FF00000000000000000011 /* TerminalController+MobileDiffs.swift in Sources */, D7AB00000000000000B001 /* TerminalController+MobileNotificationSync.swift in Sources */, D7AB00000000000000B011 /* TerminalController+MobileScrollPrefetch.swift in Sources */, C7AF20000000000000000001 /* TerminalController+MobileTerminalArtifacts.swift in Sources */, @@ -9122,6 +9130,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxGit; }; + D1FF00000000000000000001 /* XCLocalSwiftPackageReference "CmuxDiffEngine" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxDiffEngine; + }; C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxSidebarGit; @@ -9328,6 +9340,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */; productName = CmuxGit; }; + D1FF00000000000000000002 /* CmuxDiffEngine */ = { + isa = XCSwiftPackageProductDependency; + package = D1FF00000000000000000001 /* XCLocalSwiftPackageReference "CmuxDiffEngine" */; + productName = CmuxDiffEngine; + }; C5B6A10000000000000000A2 /* CmuxSidebarGit */ = { isa = XCSwiftPackageProductDependency; package = C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 140db5c3bd9f..8f3b0aac185c 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -44,6 +44,9 @@ + + @@ -120,6 +123,9 @@ + + diff --git a/ios/cmux.xcworkspace/contents.xcworkspacedata b/ios/cmux.xcworkspace/contents.xcworkspacedata index 61abd14160bc..e46706caab33 100644 --- a/ios/cmux.xcworkspace/contents.xcworkspacedata +++ b/ios/cmux.xcworkspace/contents.xcworkspacedata @@ -7,6 +7,9 @@ + + diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index 5d937dbb14ce..0c4d2e48aa18 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -1,6 +1,15 @@ { "originHash" : "dc6d4db6aa973b9856528dbbf9756a616960bb3ed0daaa10e59f371203bb3a53", "pins" : [ + { + "identity" : "highlighterswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/smittytone/HighlighterSwift", + "state" : { + "revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0", + "version" : "3.1.0" + } + }, { "identity" : "iroh-ffi", "kind" : "remoteSourceControl",