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,164 @@
import Foundation
import Observation
import WireProtocol
/// T-iOS-27 · State for the read-only diff viewer (`DiffScreen`).
///
/// The fetch closure is injected `forProject` `DiffFetcher`
/// App T-iOS-26 ProjectDetail (endpoint, path)
/// fake
/// `DiffFetcher` VM
/// - files `.loaded`/hunk /****
/// diff DIFF_MAX_BYTES 2MB Text
/// - `[]` `.empty`truncated
/// - 400/ `.failed(.pathInvalid)`404 `.failed(.notFound)`
/// `.failed(.unavailable)` `load()`
/// - staged/unstaged `setStaged` fetch no-op
@MainActor
@Observable
final class DiffViewModel {
/// DiffScreen DiffCopy
enum Failure: Equatable {
case pathInvalid
case notFound
case unavailable
}
/// Rendering phase an explicit enum so the screen can never show an
/// error and diff rows at the same time (same discipline as Timeline).
enum Phase: Equatable {
case loading
/// filestruncated
case empty(truncated: Bool)
case loaded(DiffPresentation)
case failed(Failure)
}
private(set) var phase: Phase = .loading
/// false = unstagedtrue = --staged
private(set) var staged = false
@ObservationIgnored
private let fetch: @Sendable (_ staged: Bool) async throws -> DiffResult
init(fetch: @escaping @Sendable (_ staged: Bool) async throws -> DiffResult) {
self.fetch = fetch
}
/// `DiffScreen(endpoint:path:http:)` T-iOS-26
/// ProjectDetail DiffFetcher
static func forProject(
endpoint: HostEndpoint, path: String, http: any HTTPTransport
) -> DiffViewModel {
let fetcher = DiffFetcher(endpoint: endpoint, http: http)
return DiffViewModel(fetch: { staged in
try await fetcher.fetch(path: path, staged: staged)
})
}
/// Fetch and present. Also thepath: callable again from `.failed`.
func load() async {
phase = .loading
do {
let result = try await fetch(staged)
phase = Self.presentation(for: result)
} catch let error as DiffFetchError {
phase = .failed(Self.failure(for: error))
} catch {
phase = .failed(.unavailable) //
}
}
/// staged/unstaged fetch Steps
func setStaged(_ newValue: Bool) async {
guard newValue != staged else { return }
staged = newValue
await load()
}
// MARK: -
static func presentation(for result: DiffResult) -> Phase {
guard !result.files.isEmpty else {
return .empty(truncated: result.truncated)
}
return .loaded(DiffPresentation(
rows: makeRows(files: result.files), truncated: result.truncated
))
}
/// (binary | hunk )
/// binary hunks web renderDiffFile early return
/// public/diff.ts:163-166id
static func makeRows(files: [DiffFile]) -> [DiffRow] {
var rows: [DiffRow] = []
for file in files {
rows.append(DiffRow(id: rows.count, kind: .fileHeader(header(for: file))))
if file.binary {
rows.append(DiffRow(id: rows.count, kind: .binaryNotice))
continue
}
for hunk in file.hunks {
rows.append(DiffRow(id: rows.count, kind: .hunkHeader(hunk.header)))
for line in hunk.lines {
rows.append(DiffRow(
id: rows.count, kind: .line(kind: line.kind, text: line.text)
))
}
}
}
return rows
}
private static func failure(for error: DiffFetchError) -> Failure {
switch error {
case .invalidRequest, .pathInvalid:
return .pathInvalid
case .projectNotFound:
return .notFound
case .invalidResponse, .unexpectedStatus:
return .unavailable
}
}
/// Rename old new webpublic/diff.ts:146-150
/// newPath `Text(verbatim:)`
private static func header(for file: DiffFile) -> DiffFileHeader {
let pathLabel = file.status == .renamed && file.oldPath != file.newPath
? "\(file.oldPath)\(file.newPath)"
: file.newPath
return DiffFileHeader(
pathLabel: pathLabel, added: file.added,
removed: file.removed, status: file.status
)
}
}
// MARK: -
/// Display-ready flattened diff
struct DiffPresentation: Equatable {
let rows: [DiffRow]
let truncated: Bool
}
/// One lazy-list row. `id` =
struct DiffRow: Equatable, Identifiable {
enum Kind: Equatable {
case fileHeader(DiffFileHeader)
case binaryNotice
case hunkHeader(String)
case line(kind: DiffLineKind, text: String)
}
let id: Int
let kind: Kind
}
/// File-header row payload rename
struct DiffFileHeader: Equatable {
let pathLabel: String
let added: Int
let removed: Int
let status: DiffFileStatus
}