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,236 @@
import APIClient
import Foundation
import HostRegistry
import Observation
import WireProtocol
/// T-iOS-26 · Projects web v0.6 projects
/// `GET /projects` + `GET/PUT /prefs` / +
/// ""
///
/// Documented decisions:
/// - **prefs clobber **`PUT /prefs` blob
/// src/server.ts:286-288 `UiPrefs` unknown-key-preserving
/// API web/prefs GET
/// toggle ** PUT** =
/// - **PUT echo ** sanitize
/// + toggle
/// - **prefs load ** web mountProjects.init
/// clobber
/// - APIClient
/// OpenRequest `Validation.isAbsoluteCwd`deep-link
@MainActor
@Observable
final class ProjectsViewModel {
// MARK: - Observable state
private(set) var projects: [ProjectInfo] = []
private(set) var hasLoadedOnce = false
///
private(set) var fetchErrorMessage: String?
/// prefs / clobber
private(set) var prefsErrorMessage: String?
/// PUT /prefs
private(set) var prefsSyncErrorMessage: String?
/// ""
private(set) var openErrorMessage: String?
///
private(set) var favourites: [String] = []
/// key true web/
private(set) var collapsedGroups: [String: Bool] = [:]
/// T-iOS-26 id onChange
private(set) var openRequest: ProjectOpenRequest?
/// .searchable
var searchText = ""
// MARK: - Dependencies & internal state
let host: HostRegistry.Host
@ObservationIgnored let http: any HTTPTransport
/// prefs blobnil =
/// PUT
@ObservationIgnored private var prefsBase: UiPrefs?
private var client: APIClient {
APIClient(endpoint: host.endpoint, http: http)
}
init(host: HostRegistry.Host, http: any HTTPTransport) {
self.host = host
self.http = http
}
// MARK: - Derived view state
/// web
var groups: [ProjectGroup] {
ProjectGrouping.group(
ProjectGrouping.filter(projects, query: searchText),
favourites: Set(favourites)
)
}
var isSearching: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// caret web renderGrid
func isCollapsed(_ group: ProjectGroup) -> Bool {
group.isCollapsible && !isSearching && collapsedGroups[group.key] == true
}
func isFavourite(_ path: String) -> Bool {
favourites.contains(path)
}
/// vs web renderGrid msg
var emptyStateMessage: String? {
guard hasLoadedOnce, fetchErrorMessage == nil else { return nil }
if projects.isEmpty { return ProjectsCopy.emptyNoProjects }
if ProjectGrouping.filter(projects, query: searchText).isEmpty {
return ProjectsCopy.emptyNoMatch
}
return nil
}
// MARK: - Load / refresh
/// prefs+ prefs web init
func load() async {
await loadPrefsIfNeeded()
await refresh()
}
/// +
func refresh() async {
do {
projects = try await client.projects()
fetchErrorMessage = nil
hasLoadedOnce = true
} catch {
fetchErrorMessage = ProjectsCopy.fetchFailed(Self.errorDetail(error))
}
}
private func loadPrefsIfNeeded() async {
guard prefsBase == nil else { return }
do {
let prefs = try await client.prefs()
adopt(prefs)
prefsErrorMessage = nil
} catch {
prefsErrorMessage = ProjectsCopy.prefsLoadFailed
}
}
// MARK: - Favourites / collapse prefs
func toggleFavourite(path: String) async {
favourites = favourites.contains(path)
? favourites.filter { $0 != path }
: favourites + [path]
await persistPrefs()
}
func toggleCollapsed(key: String) async {
if collapsedGroups[key] == true {
collapsedGroups = collapsedGroups.filter { $0.key != key }
} else {
collapsedGroups = collapsedGroups.merging([key: true]) { _, new in new }
}
await persistPrefs()
}
/// blob PUT
/// echoprefs
private func persistPrefs() async {
guard let base = prefsBase else { return }
let next = base.withFavourites(favourites).withCollapsed(collapsedGroups)
prefsBase = next // toggle
do {
let echoed = try await client.putPrefs(next)
adopt(echoed)
prefsSyncErrorMessage = nil
} catch {
prefsSyncErrorMessage = ProjectsCopy.prefsSyncFailed(Self.errorDetail(error))
}
}
private func adopt(_ prefs: UiPrefs) {
prefsBase = prefs
favourites = prefs.favourites
collapsedGroups = prefs.collapsed
}
// MARK: - T-iOS-26
/// `attach(null, cwd)` + attach `claude\r` engine
/// attach-first
func requestOpenClaude(cwd: String) {
guard Validation.isAbsoluteCwd(cwd) else {
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
return
}
openErrorMessage = nil
openRequest = ProjectOpenRequest(
id: UUID(), host: host, cwd: cwd,
bootstrapInput: ProjectLaunch.claudeBootstrapInput
)
}
// MARK: - Detail assembly/
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
.forHost(endpoint: host.endpoint, http: http, path: path)
}
// MARK: - Helpers
private static func errorDetail(_ error: any Error) -> String {
(error as? APIClientError)?.message ?? error.localizedDescription
}
}
/// "" `SessionListViewModel.OpenRequest`
/// cwd+bootstrap T-iOS-13
/// AppCoordinator.openProject
struct ProjectOpenRequest: Equatable, Sendable, Identifiable {
let id: UUID
let host: HostRegistry.Host
///
let cwd: String
/// attach nil = shell
let bootstrapInput: String?
}
/// public/tabs.ts:679 `openProject` cmd
enum ProjectLaunch {
/// Enter `\r`0x0D `\n` CLAUDE.md Gotchas
static let claudeBootstrapInput = "claude\r"
}
/// plan §4
enum ProjectsCopy {
static let title = "项目"
static let searchPrompt = "筛选项目…"
static let activeGroupLabel = "活跃中"
static let otherGroupLabel = "其他"
static let allGroupLabel = "全部项目"
static let dirtyBadge = "未提交"
static let emptyNoProjects = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
static let emptyNoMatch = "没有匹配的项目。"
static let prefsLoadFailed = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。"
static let openClaudeInvalidPath = "项目路径无效,无法开新会话。"
static func fetchFailed(_ detail: String) -> String {
"刷新项目列表失败:\(detail)"
}
static func prefsSyncFailed(_ detail: String) -> String {
"收藏/折叠状态同步失败:\(detail)"
}
static func activeCountBadge(_ count: Int) -> String {
"\(count) 活跃"
}
}