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
237 lines
9.1 KiB
Swift
237 lines
9.1 KiB
Swift
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) 活跃"
|
||
}
|
||
}
|