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 ) } /// T-iOS-32(C2)· 恢复一条历史会话:**同一条** `attach(null, cwd)` 缝,只把 /// bootstrap 换成 `claude --resume \r`(镜像 web /// `public/tabs.ts:918 newTabForResume`)。 /// /// 两道校验都在铸造 request 之前:cwd 必须是绝对路径(与上面同款纪律), /// 会话 id 必须过 `ProjectResumeCommand` 白名单 —— 那串字节会被拼进一条真的送进 /// PTY 的命令行,web 侧没有这层校验,iOS 不复制这个缺口。 func requestResumeClaude(cwd: String, sessionId: String) { guard Validation.isAbsoluteCwd(cwd) else { openErrorMessage = ProjectsCopy.openClaudeInvalidPath return } guard let bootstrap = ProjectResumeCommand.bootstrapInput(sessionId: sessionId) else { openErrorMessage = ResumeCopy.notResumable return } openErrorMessage = nil openRequest = ProjectOpenRequest( id: UUID(), host: host, cwd: cwd, bootstrapInput: bootstrap ) } // 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) 活跃" } }