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:
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal file
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal 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 blob(含未知键)。nil = 从未成功加载 →
|
||||
/// 绝不 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
|
||||
/// 后采纳服务器 echo。无底盘(prefs 从未加载成功)→ 本地生效、不回写。
|
||||
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) 活跃"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user