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
This commit is contained in:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,234 @@
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
}
}