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,76 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-26 · `GET /projects/detail?path=` phase
/// DiffViewModel/TimelineViewModel
///
/// fetch `forHost` `APIClient.projectDetail(path:)`
/// builder 400/404/500 `{error}` T-iOS-38
/// fake VM
/// - `.loaded(ProjectDetail)`sessions/worktrees/hasClaudeMd
/// - 400 `.failed(.pathInvalid)`404 `.failed(.notFound)`
/// 500// `.failed(.unavailable)` `load()`
@MainActor
@Observable
final class ProjectDetailViewModel {
/// ProjectDetailScreen
enum Failure: Equatable {
case pathInvalid
case notFound
case unavailable
}
enum Phase: Equatable {
case loading
case loaded(ProjectDetail)
case failed(Failure)
}
private(set) var phase: Phase = .loading
/// /diff
/// builder URL
let path: String
@ObservationIgnored
private let fetch: @Sendable () async throws -> ProjectDetail
init(path: String, fetch: @escaping @Sendable () async throws -> ProjectDetail) {
self.path = path
self.fetch = fetch
}
/// ProjectsViewModel.makeDetailViewModel
static func forHost(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> ProjectDetailViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return ProjectDetailViewModel(path: path, fetch: {
try await client.projectDetail(path: path)
})
}
/// Fetch `.failed`
func load() async {
phase = .loading
do {
phase = .loaded(try await fetch())
} catch let error as APIClientError {
phase = .failed(Self.failure(for: error))
} catch {
phase = .failed(.unavailable) //
}
}
private static func failure(for error: APIClientError) -> Failure {
switch error {
case .projectPathInvalid, .invalidRequest:
return .pathInvalid
case .projectNotFound:
return .notFound
default:
return .unavailable
}
}
}