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.
328 lines
13 KiB
Swift
328 lines
13 KiB
Swift
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)。"
|
||
}
|
||
}
|