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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,4 @@ artifacts/

# tmux verbose debug logs (tmux -v) that land in the cwd
tmux-*.log
ios/cmux.xcworkspace/xcshareddata/
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
switch method {
case "mobile.workspace.list", "workspace.list":
return false
case "mobile.workspace.git.status", "workspace.git.status",
"mobile.workspace.git.diff", "workspace.git.diff":
return !ticketCoverage.ticketCoversWorkspaceRequest(
ticket: ticket,
workspaceSelection: workspaceSelection.value
)
case "workspace.create":
return false
case "workspace.action", "workspace.close":
Expand Down Expand Up @@ -350,7 +356,15 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
// Only the unauthenticated host probe is exempt. attach_ticket.create has no
// attach token yet (it mints the ticket), so requiring auth routes it through
// the Stack Auth account token: a ticket can only be created by a signed-in user.
return method != "mobile.host.status"
switch method {
case "mobile.host.status":
return false
case "mobile.workspace.git.status", "workspace.git.status",
"mobile.workspace.git.diff", "workspace.git.diff":
return true
default:
return true
}
}

private static func stringParamSelection(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
public import Foundation

/// Typed decoder for the size-bounded workspace Git diff RPC result.
public struct MobileSyncGitDiffResponse: Decodable, Sendable {
/// The baseline identifier, currently `worktree`.
public let baseline: String
/// Concatenated unified patch text for included paths.
public let patch: String
/// Requested paths represented in `patch`.
public let included: [String]
/// Requested paths omitted after the response cap was reached.
public let truncated: [String]
/// Requested paths whose individual patch alone exceeded the response cap.
public let tooLarge: [MobileSyncGitDiffTooLargeFile]

private enum CodingKeys: String, CodingKey {
case baseline
case patch
case included
case truncated
case tooLarge = "too_large"
}

/// Decodes a workspace Git diff response from raw JSON data.
/// - Parameter data: The RPC result payload.
/// - Returns: The decoded response.
/// - Throws: A decoding error if the payload is malformed.
public static func decode(_ data: Data) throws -> MobileSyncGitDiffResponse {
try JSONDecoder().decode(Self.self, from: data)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

/// A requested file whose individual unified diff exceeded the host soft cap.
public struct MobileSyncGitDiffTooLargeFile: Decodable, Sendable {
/// The repository-relative requested path.
public let path: String
/// The UTF-8 byte count of the generated unified diff.
public let bytes: Int
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

/// One changed file decoded from a workspace Git status response.
public struct MobileSyncGitStatusFile: Decodable, Sendable {
/// The repository-relative current path.
public let path: String
/// The repository-relative prior path for a rename, otherwise `nil`.
public let oldPath: String?
/// The normalized status code: `M`, `A`, `D`, or `R`.
public let status: String
/// The number of added lines, or zero for binary files.
public let additions: Int
/// The number of deleted lines, or zero for binary files.
public let deletions: Int
/// Whether Git classified the file as binary.
public let binary: Bool
/// Whether the path is untracked.
public let untracked: Bool

private enum CodingKeys: String, CodingKey {
case path
case oldPath = "old_path"
case status
case additions
case deletions
case binary
case untracked
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
public import Foundation

/// Typed decoder for the workspace Git status RPC result.
public struct MobileSyncGitStatusResponse: Decodable, Sendable {
/// The absolute repository root reported by the Mac host.
public let repoRoot: String
/// The baseline identifier, currently `worktree`.
public let baseline: String
/// Changed files in Git porcelain order.
public let files: [MobileSyncGitStatusFile]
/// Added lines summed across non-binary files.
public let totalAdditions: Int
/// Deleted lines summed across non-binary files.
public let totalDeletions: Int
/// Whether the host omitted untracked files beyond its processing cap.
public let truncatedUntracked: Bool

private enum CodingKeys: String, CodingKey {
case repoRoot = "repo_root"
case baseline
case files
case totalAdditions = "total_additions"
case totalDeletions = "total_deletions"
case truncatedUntracked = "truncated_untracked"
}

/// Decodes the status response, treating the additive truncation field as
/// false when connected to an older host.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
repoRoot = try container.decode(String.self, forKey: .repoRoot)
baseline = try container.decode(String.self, forKey: .baseline)
files = try container.decode([MobileSyncGitStatusFile].self, forKey: .files)
totalAdditions = try container.decode(Int.self, forKey: .totalAdditions)
totalDeletions = try container.decode(Int.self, forKey: .totalDeletions)
truncatedUntracked = try container.decodeIfPresent(Bool.self, forKey: .truncatedUntracked) ?? false
}

/// Decodes a workspace Git status response from raw JSON data.
/// - Parameter data: The RPC result payload.
/// - Returns: The decoded response.
/// - Throws: A decoding error if the payload is malformed.
public static func decode(_ data: Data) throws -> MobileSyncGitStatusResponse {
try JSONDecoder().decode(Self.self, from: data)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation
import Testing

@testable import CmuxMobileRPC

@Suite struct MobileSyncGitResponseTests {
@Test func statusResponseDecodesSnakeCaseWireShape() throws {
let data = Data(#"""
{
"repo_root":"/repo","baseline":"worktree",
"files":[
{"path":"new.swift","status":"A","additions":4,"deletions":0,"binary":false,"untracked":true},
{"path":"renamed.swift","old_path":"old.swift","status":"R","additions":1,"deletions":2,"binary":false,"untracked":false}
],
"total_additions":5,"total_deletions":2,"truncated_untracked":true
}
"""#.utf8)

let response = try MobileSyncGitStatusResponse.decode(data)
#expect(response.repoRoot == "/repo")
#expect(response.baseline == "worktree")
#expect(response.files.count == 2)
#expect(response.files[0].oldPath == nil)
#expect(response.files[0].untracked)
#expect(response.files[1].oldPath == "old.swift")
#expect(response.totalAdditions == 5)
#expect(response.totalDeletions == 2)
#expect(response.truncatedUntracked)
}

@Test func statusResponseDefaultsMissingUntrackedTruncationToFalse() throws {
let data = Data(#"""
{
"repo_root":"/repo","baseline":"worktree","files":[],
"total_additions":0,"total_deletions":0
}
"""#.utf8)

#expect(try !MobileSyncGitStatusResponse.decode(data).truncatedUntracked)
}

@Test func diffResponseDecodesBatchingMetadata() throws {
let data = Data(#"""
{
"baseline":"worktree","patch":"diff --git a/a b/a\n",
"included":["a"],"truncated":["b","c"],
"too_large":[{"path":"huge.bin","bytes":5000000}]
}
"""#.utf8)

let response = try MobileSyncGitDiffResponse.decode(data)
#expect(response.baseline == "worktree")
#expect(response.patch == "diff --git a/a b/a\n")
#expect(response.included == ["a"])
#expect(response.truncated == ["b", "c"])
#expect(response.tooLarge.count == 1)
#expect(response.tooLarge[0].path == "huge.bin")
#expect(response.tooLarge[0].bytes == 5_000_000)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}
}
/// The shell-owned live RPC session used for diff-viewer requests.
///
/// The returned client is shared with terminal, workspace, and chat RPCs.
/// Consumers may send requests through it but must not disconnect it; the
/// shell owns replacement and teardown of the session.
public var mobileDiffRPCClient: (any MobileSyncing)? {
guard connectionState == .connected else { return nil }
return remoteClient
}
/// Whether legacy connected-but-clientless shells use local iOS workspace creation.
public var usesLocalWorkspaceCreationFallback: Bool {
remoteClient == nil && connectionState == .connected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,37 @@ import Testing
#expect(store.selectedTerminalID?.rawValue == "terminal-build")
}

@Test func diffRPCClientUsesLiveSessionWithoutActiveAttachState() throws {
let clock = TestClock()
let runtime = LivenessTestRuntime(
transportFactory: LivenessTransportFactory(
router: LivenessHostRouter(),
box: TransportBox()
),
now: { clock.now }
)
let store = MobileShellComposite.preview(runtime: runtime)
let ticket = try makeTicket(clock: clock)
let route = try #require(ticket.routes.first)
let client = MobileCoreRPCClient(
runtime: runtime,
route: route,
ticket: ticket,
allowsStackAuthFallback: true
)

store.remoteClient = client
store.connectionState = .connected

#expect(store.activeTicket == nil)
#expect(store.activeRoute == nil)
let diffClient = try #require(store.mobileDiffRPCClient as? MobileCoreRPCClient)
#expect(diffClient === client)

store.connectionState = .disconnected
#expect(store.mobileDiffRPCClient == nil)
}

@Test func signInMovesToPairingUntilPreviewCodeConnects() {
let store = MobileShellComposite.preview()

Expand Down
2 changes: 1 addition & 1 deletion Packages/iOS/CmuxMobileShellUI/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Packages/iOS/CmuxMobileShellUI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ let package = Package(
.package(path: "../CmuxMobileCamera"),
.package(path: "../CmuxMobileDiagnostics"),
.package(path: "../CmuxMobilePairedMac"),
.package(path: "../CmuxMobileRPC"),
.package(path: "../CmuxMobileShell"),
.package(path: "../CmuxMobileShellModel"),
.package(path: "../CmuxMobileSupport"),
Expand All @@ -43,6 +44,7 @@ let package = Package(
"CmuxMobileCamera",
"CmuxMobileDiagnostics",
"CmuxMobilePairedMac",
"CmuxMobileRPC",
"CmuxMobileShell",
"CmuxMobileShellModel",
"CmuxMobileSupport",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Foundation

/// Static cmux light/dark appearance sent to Pierre before highlighting starts.
struct MobileDiffAppearance: Sendable {
var jsonObject: [String: Any] {
[
"backgroundOpacity": 1,
"fontFamily": "Menlo",
"fontSize": 10,
"lineHeight": 20,
"diffHeaderHeight": 44,
"theme": [
"light": "cmux-ghostty-light",
"dark": "cmux-ghostty-dark",
],
"themes": [
"light": theme(
name: "cmux-ghostty-light",
ghosttyName: "Apple System Colors Light",
type: "light",
background: "#feffff",
foreground: "#000000",
selectionBackground: "#abd8ff",
selectionForeground: "#000000",
palette: lightPalette
),
"dark": theme(
name: "cmux-ghostty-dark",
ghosttyName: "Apple System Colors",
type: "dark",
background: "#1e1e1e",
foreground: "#ffffff",
selectionBackground: "#3f638b",
selectionForeground: "#ffffff",
palette: darkPalette
),
],
]
}

private var lightPalette: [String: String] {
basePalette.merging([
"11": "#e5bc00",
"14": "#69c9f2",
]) { _, new in new }
}

private var darkPalette: [String: String] {
basePalette.merging([
"11": "#ffd60a",
"14": "#76d6ff",
]) { _, new in new }
}

private var basePalette: [String: String] {
[
"0": "#1a1a1a", "1": "#cc372e", "2": "#26a439", "3": "#cdac08",
"4": "#0869cb", "5": "#9647bf", "6": "#479ec2", "7": "#98989d",
"8": "#464646", "9": "#ff453a", "10": "#32d74b", "12": "#0a84ff",
"13": "#bf5af2", "15": "#ffffff",
]
}

private func theme(
name: String,
ghosttyName: String,
type: String,
background: String,
foreground: String,
selectionBackground: String,
selectionForeground: String,
palette: [String: String]
) -> [String: Any] {
[
"name": name,
"ghosttyName": ghosttyName,
"type": type,
"background": background,
"foreground": foreground,
"selectionBackground": selectionBackground,
"selectionForeground": selectionForeground,
"palette": palette,
]
}
}
Loading
Loading