Files
web-terminal/ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

259 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
)
}
/// T-iOS-32C2· **** `attach(null, cwd)`
/// bootstrap `claude --resume <id>\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) 活跃"
}
}