Files
web-terminal/ios/App/WebTerm/DiffFetcher.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

235 lines
9.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import WireProtocol
// T-iOS-27 · App-layer fetcher for `GET /projects/diff?path=&staged=` the
// read-only structured git diff (server route src/server.ts:601-618, parser
// src/http/diff.ts). iOS mirrors the web split: parsing lives ONLY server-side;
// this file fetches + tolerantly decodes, the screen renders verbatim.
//
// APIClient APIClient T-iOS-38 ownerPLAN §6 Owns
// fetcher `HTTPTransport`
// APIClient ** APIClient T-iOS-38 owner
// follow-up**//
//
// Origin plan §3.4/§5.1iff-G`/projects/diff` RO
// requireAllowedOriginsrc/server.ts:601 "no Origin guard; same
// threat model as /projects"** Origin**
// G
//
// plan §4 web
// normalizeDiffResultpublic/diff.ts:38-102
// file/hunk/line kind .contextstatus
// .modified crash
// MARK: - src/types.ts:445-479App T-iOS-38
/// One diff line's semantic kind. Unknown wire values degrade to `.context`
/// (the web renderer's `?? 'df-context'` fallback, public/diff.ts:192).
enum DiffLineKind: String, Sendable, Equatable {
case added, removed, context, hunk, meta
}
struct DiffLine: Sendable, Equatable {
let kind: DiffLineKind
/// Verbatim server bytes (marker already stripped server-side). UNTRUSTED
/// display input render via `Text(verbatim:)` only, never Markdown/links.
let text: String
}
struct DiffHunk: Sendable, Equatable {
let header: String
let lines: [DiffLine]
}
/// File-level change status. Unknown wire values degrade to `.modified`
/// (display-only field; the web keeps the raw string for a CSS class).
enum DiffFileStatus: String, Sendable, Equatable {
case modified, added, deleted, renamed, binary, untracked
}
struct DiffFile: Sendable, Equatable {
let oldPath: String
let newPath: String
let status: DiffFileStatus
let added: Int
let removed: Int
let binary: Bool
let hunks: [DiffHunk]
}
struct DiffResult: Sendable, Equatable {
let files: [DiffFile]
let staged: Bool
let truncated: Bool
}
// MARK: - Decodable extension
extension DiffLine: Decodable {
private enum CodingKeys: String, CodingKey { case kind, text }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// kind/text normalizeLine
let rawKind = try container.decode(String.self, forKey: .kind)
text = try container.decode(String.self, forKey: .text)
kind = DiffLineKind(rawValue: rawKind) ?? .context
}
}
extension DiffHunk: Decodable {
private enum CodingKeys: String, CodingKey { case header, lines }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
header = try container.decode(String.self, forKey: .header)
let boxes = try container.decode([DiffLossyBox<DiffLine>].self, forKey: .lines)
lines = boxes.compactMap(\.value)
}
}
extension DiffFile: Decodable {
private enum CodingKeys: String, CodingKey {
case oldPath, newPath, status, added, removed, binary, hunks
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
oldPath = try container.decode(String.self, forKey: .oldPath)
newPath = try container.decode(String.self, forKey: .newPath)
added = try container.decode(Int.self, forKey: .added)
removed = try container.decode(Int.self, forKey: .removed)
binary = try container.decode(Bool.self, forKey: .binary)
let rawStatus = try container.decode(String.self, forKey: .status)
status = DiffFileStatus(rawValue: rawStatus) ?? .modified
let boxes = try container.decode([DiffLossyBox<DiffHunk>].self, forKey: .hunks)
hunks = boxes.compactMap(\.value)
}
}
extension DiffResult: Decodable {
private enum CodingKeys: String, CodingKey { case files, staged, truncated }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
staged = try container.decode(Bool.self, forKey: .staged)
truncated = try container.decode(Bool.self, forKey: .truncated)
let boxes = try container.decode([DiffLossyBox<DiffFile>].self, forKey: .files)
files = boxes.compactMap(\.value)
}
}
/// Per-element tolerance shim: a malformed element becomes nil instead of
/// failing the whole array. Local twin of APIClient's internal `LossyBox`
/// (not importable across the package boundary) folds together with the
/// fetcher in the T-iOS-38 follow-up.
private struct DiffLossyBox<Wrapped: Decodable>: Decodable {
let value: Wrapped?
init(from decoder: any Decoder) {
value = try? Wrapped(from: decoder)
}
}
// MARK: - route src/server.ts:602-618
enum DiffFetchError: Error, Equatable {
/// path / / URL
case invalidRequest
/// 400`path query parameter is required`
case pathInvalid
/// 404SEC-H7 + + .git
case projectNotFound
/// 200 DiffResult
case invalidResponse
/// 500 `failed to read diff`
case unexpectedStatus(Int)
}
// MARK: - DiffFetcher
/// Fetch a repo's structured diff over the injected transport. Immutable
/// value; one instance per (endpoint) the screen's VM closes over it.
struct DiffFetcher: Sendable {
let endpoint: HostEndpoint
private let http: any HTTPTransport
/// Route constants`stagedFlag`
/// `req.query['staged'] === '1'`src/server.ts:613 `1`/`0`
/// web fetchDiff `true`/`false` `'1'`
/// staged web Owns
private enum Route {
static let path = "/projects/diff"
static let pathKey = "path"
static let stagedKey = "staged"
static let stagedOn = "1"
static let stagedOff = "0"
static let methodGet = "GET"
}
private enum Status {
static let ok = 200
static let badRequest = 400
static let notFound = 404
}
/// Strict RFC 3986 unreserved set APIClient `Endpoints.
/// unreservedCharacters` `+` Express qs
/// `&`/`=` internal importT-iOS-38
private static let unreservedCharacters = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
init(endpoint: HostEndpoint, http: any HTTPTransport) {
self.endpoint = endpoint
self.http = http
}
/// `GET /projects/diff?path=&staged=` `DiffResult`
/// path 400 I/O
func fetch(path: String, staged: Bool) async throws -> DiffResult {
guard !path.isEmpty, let request = buildRequest(path: path, staged: staged) else {
throw DiffFetchError.invalidRequest
}
let (data, response) = try await http.send(request)
switch response.statusCode {
case Status.ok:
guard let result = try? JSONDecoder().decode(DiffResult.self, from: data) else {
throw DiffFetchError.invalidResponse
}
return result
case Status.badRequest:
throw DiffFetchError.pathInvalid
case Status.notFound:
throw DiffFetchError.projectNotFound
default:
throw DiffFetchError.unexpectedStatus(response.statusCode)
}
}
/// Build the RO GET APIClient `APIRoute.urlRequest(for:)`
/// baseURL scheme/host/portpath/query fragment
/// ** stamp Origin**iff-G
private func buildRequest(path: String, staged: Bool) -> URLRequest? {
guard let encodedPath = path.addingPercentEncoding(
withAllowedCharacters: Self.unreservedCharacters
) else { return nil }
guard var components = URLComponents(
url: endpoint.baseURL, resolvingAgainstBaseURL: true
) else { return nil }
components.path = Route.path
components.query = nil
components.fragment = nil
components.user = nil
components.password = nil
let stagedValue = staged ? Route.stagedOn : Route.stagedOff
components.percentEncodedQuery =
"\(Route.pathKey)=\(encodedPath)&\(Route.stagedKey)=\(stagedValue)"
guard let url = components.url else { return nil }
var request = URLRequest(url: url)
request.httpMethod = Route.methodGet
return request
}
}