Files
web-terminal/ios/App/WebTerm/ViewModels/WorktreeViewModel.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

328 lines
13 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 Observation
import WireProtocol
/// T-iOS-32 · worktree create / prune / remove
///
/// `src/http/worktrees.ts` + `docs/plans/w4-worktree-lifecycle.md`web
/// `public/projects.ts``validateBranchNameClient``confirmAndRemoveWorktree`
/// `confirmAndPruneWorktrees`
///
///
/// 1. ****
///
/// 2. **** 409** force**
/// UI
/// 3. ****SEC-M10 git stderr
@MainActor
@Observable
final class WorktreeViewModel {
struct Dependencies: Sendable {
let create: @Sendable (_ branch: String, _ base: String?) async throws
-> GitWriteOutcome<CreateWorktreeResult>
let prune: @Sendable () async throws -> GitWriteOutcome<PruneWorktreesResult>
let remove: @Sendable (_ worktreePath: String, _ force: Bool) async throws
-> GitWriteOutcome<RemoveWorktreeResult>
}
enum CreatePhase: Equatable {
case idle
case creating
/// canonical path/branch
case created(path: String, branch: String)
case failed(String)
}
enum PrunePhase: Equatable {
case idle
case pruning
case done(String)
case failed(String)
}
enum RemovePhase: Equatable {
case idle
case removing
/// 409 force`confirmForceRemove`
/// `cancelForceRemove`
case forceConfirming(path: String, message: String)
case removed(path: String)
case failed(String)
}
/// `TextField`
var branchText = ""
/// ref = HEAD ****
var baseText = ""
private(set) var createPhase: CreatePhase = .idle
private(set) var prunePhase: PrunePhase = .idle
private(set) var removePhase: RemovePhase = .idle
@ObservationIgnored
private let dependencies: Dependencies
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
///
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> WorktreeViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return WorktreeViewModel(dependencies: Dependencies(
create: { branch, base in
try await client.createWorktree(path: path, branch: branch, base: base)
},
prune: { try await client.pruneWorktrees(path: path) },
remove: { worktreePath, force in
try await client.removeWorktree(
path: path, worktreePath: worktreePath, force: force
)
}
))
}
///
var branchValidationMessage: String? {
let branch = trimmedBranch
guard !branch.isEmpty else { return nil }
return WorktreeBranchRule.validate(branch)
}
var canSubmitCreate: Bool {
createPhase != .creating && WorktreeBranchRule.validate(trimmedBranch) == nil
}
var isBusy: Bool {
createPhase == .creating || prunePhase == .pruning || removePhase == .removing
}
/// ""worktree worktree 400
/// unlock 409 UI
static func canRemove(_ worktree: WorktreeInfo) -> Bool {
!worktree.isMain && worktree.locked != true
}
private var trimmedBranch: String {
branchText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedBase: String? {
let base = baseText.trimmingCharacters(in: .whitespacesAndNewlines)
return base.isEmpty ? nil : base
}
// MARK: -
func create() async {
guard createPhase != .creating else { return } //
let branch = trimmedBranch
if let invalid = WorktreeBranchRule.validate(branch) {
createPhase = .failed(invalid) // I/O
return
}
createPhase = .creating
do {
switch try await dependencies.create(branch, trimmedBase) {
case .ok(let result):
createPhase = .created(
path: result.path ?? "", branch: result.branch ?? branch
)
case .rejected(let status, let message):
createPhase = .failed(GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.createFailed
))
case .rateLimited:
createPhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
createPhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.createFailed
))
}
}
/// sheet
func resetCreate() {
branchText = ""
baseText = ""
createPhase = .idle
}
// MARK: - prune
func prune() async {
guard prunePhase != .pruning else { return }
prunePhase = .pruning
do {
switch try await dependencies.prune() {
case .ok(let result):
// =
prunePhase = .done(
result.pruned.isEmpty
? WorktreeCopy.pruneNothing
: WorktreeCopy.pruneDone(result.pruned.count)
)
case .rejected(let status, let message):
prunePhase = .failed(GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.pruneFailed
))
case .rateLimited:
prunePhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
prunePhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.pruneFailed
))
}
}
func clearPruneResult() {
prunePhase = .idle
}
// MARK: - remove
/// `force: false`
func remove(worktreePath: String) async {
await runRemove(worktreePath: worktreePath, force: false)
}
/// `.forceConfirming`
func confirmForceRemove() async {
guard case .forceConfirming(let path, _) = removePhase else { return }
await runRemove(worktreePath: path, force: true)
}
func cancelForceRemove() {
guard case .forceConfirming = removePhase else { return }
removePhase = .idle
}
func clearRemoveResult() {
removePhase = .idle
}
private func runRemove(worktreePath: String, force: Bool) async {
guard removePhase != .removing else { return }
removePhase = .removing
do {
switch try await dependencies.remove(worktreePath, force) {
case .ok(let result):
removePhase = .removed(path: result.path ?? worktreePath)
case .rejected(let status, let message):
let text = GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.removeFailed
)
// 409 = force
// force 409
removePhase = (status == WorktreeStatus.conflict && !force)
? .forceConfirming(path: worktreePath, message: text)
: .failed(text)
case .rateLimited:
removePhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
removePhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.removeFailed
))
}
}
}
/// VM HTTP APIClient internal
private enum WorktreeStatus {
static let conflict = 409
}
// MARK: -
/// web `validateBranchNameClient``public/projects.ts:982`
/// `git check-ref-format`
///
/// "" `execFile` argv shell `--`
/// ****
enum WorktreeBranchRule {
/// web
static let maxLength = 250
static func validate(_ branch: String) -> String? {
if branch.isEmpty { return Message.empty }
if branch.count > maxLength { return Message.tooLong }
if branch.unicodeScalars.contains(where: isForbiddenControlScalar) {
return Message.whitespaceOrControl
}
if branch.hasPrefix("-") { return Message.leadingHyphen }
if branch.contains("..") { return Message.doubleDot }
if branch.hasSuffix(".lock") { return Message.lockSuffix }
if branch.contains(where: forbiddenCharacters.contains) { return Message.forbiddenCharacter }
if branch.contains("@{") { return Message.atBrace }
if branch.hasPrefix("/") || branch.hasSuffix("/") || branch.contains("//") {
return Message.slashUsage
}
return nil
}
/// `[\x00-\x20\x7f]` C0 + DEL
private static func isForbiddenControlScalar(_ scalar: Unicode.Scalar) -> Bool {
scalar.value <= 0x20 || scalar.value == 0x7F
}
private static let forbiddenCharacters: Set<Character> = ["~", "^", ":", "?", "*", "[", "\\"]
private enum Message {
static let empty = "分支名不能为空。"
static let tooLong = "分支名过长(最多 \(maxLength) 个字符)。"
static let whitespaceOrControl = "分支名不能包含空格或控制字符。"
static let leadingHyphen = "分支名不能以 - 开头。"
static let doubleDot = "分支名不能包含 \"..\""
static let lockSuffix = "分支名不能以 \".lock\" 结尾。"
static let forbiddenCharacter = "分支名包含非法字符(~ ^ : ? * [ \\)。"
static let atBrace = "分支名不能包含 \"@{\""
static let slashUsage = "分支名的 / 用法不合法。"
}
}
// MARK: - plan §4
enum WorktreeCopy {
static let sheetTitle = "新建 Worktree"
static let branchField = "新分支名"
static let baseField = "起点(留空 = 当前 HEAD"
static let submit = "创建 Worktree"
static let cancel = "取消"
static let createdTitle = "Worktree 已创建"
static let openInNewWorktree = "在新 Worktree 开会话"
static let createFailed = "创建 worktree 失败"
static let pruneButton = "回收失效 Worktree"
static let pruneConfirmTitle = "回收文件夹已消失的 worktree"
static let pruneConfirmMessage = "只清理 git 中已失效的登记项,不会删除任何仍存在的工作树。"
static let pruneConfirm = "回收"
static let pruneNothing = "没有可回收的 worktree。"
static let pruneFailed = "回收失败"
static let removeButton = "删除"
static let removeAccessibilityLabel = "删除 worktree"
static let removeConfirmTitle = "删除这个 worktree"
static let removeConfirm = "删除"
static let forceConfirmTitle = "有未提交的改动"
static let forceConfirmMessage = "继续将丢失该 worktree 里未提交的改动。"
static let forceConfirm = "强制删除"
static let removeFailed = "删除 worktree 失败"
static let lockedHint = "已锁定:请先在终端里 unlock。"
static func pruneDone(_ count: Int) -> String { "已回收 \(count) 个失效 worktree。" }
static func removeConfirmMessage(_ path: String) -> String {
"将删除工作树目录:\(path)"
}
static func removed(_ path: String) -> String { "已删除 \(path)" }
static func created(path: String, branch: String) -> String {
"已在 \(path) 创建分支 \(branch)"
}
}