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.
This commit is contained in:
161
ios/App/WebTerm/ViewModels/GitPanelPresentation.swift
Normal file
161
ios/App/WebTerm/ViewModels/GitPanelPresentation.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
|
||||
// C2 · git 面板的**纯呈现规则**(零 I/O、零 SwiftUI)—— 把 w6 那条"只有一种
|
||||
// 状态可以显示为绿色"的设计铁律做成可单测的数据,而不是散落在视图里的条件。
|
||||
//
|
||||
// 真源:`docs/plans/w6-project-git-panel.md`(Design rule that drives everything)
|
||||
// + web 参照实现 `public/projects.ts:615-745 makeSyncBand`。iOS 与 web 必须给出
|
||||
// 同一判断,只有布局不同(手机上四列带换成竖排卡片)。
|
||||
|
||||
// MARK: - 同步带(w6/G3)
|
||||
|
||||
/// 项目/worktree 的上游同步态,已按"可信度"归约完毕。
|
||||
///
|
||||
/// 为什么每个字段都是 optional 而不是 0:`ahead`/`behind` 是对 `@{u}` 的比较,
|
||||
/// 而 `@{u}` 是**本地缓存**的远端引用,只有 fetch 会推动它。所以
|
||||
/// - `ahead` 只用本地引用 ⇒ 永远可信;
|
||||
/// - `behind` 的新鲜度**不超过** `lastFetchMs` ⇒ 过期时必须标"未核实";
|
||||
/// - `upstream == nil` 意思是"无可比对",**不是**"没有要推的东西";
|
||||
/// - `nil` 计数是"算不出来",用 `?? 0` 折叠它就会让一个读取失败的仓库显示绿色 ——
|
||||
/// 这正是本类型存在的原因。
|
||||
struct GitSyncBand: Equatable {
|
||||
/// HEAD 的三种处境(互斥)。
|
||||
enum Head: Equatable {
|
||||
/// 分离 HEAD:没有分支,也就没有 ahead/behind。
|
||||
case detached
|
||||
/// 分支不跟踪任何上游(新建 worktree 分支的常态)。
|
||||
case noUpstream
|
||||
/// 正常跟踪。计数为 nil = 读不到(渲染 `—`,绝不当 0)。
|
||||
case tracking(upstream: String, ahead: Int?, behind: Int?)
|
||||
}
|
||||
|
||||
/// 工作区脏度三态。`unknown` 是主机关掉了 dirty 检查(`PROJECT_DIRTY_CHECK=0`),
|
||||
/// **不等于** clean —— 把它渲染成"干净"就是在替主机说谎。
|
||||
enum Dirty: Equatable {
|
||||
case unknown
|
||||
case clean
|
||||
case changed(Int)
|
||||
}
|
||||
|
||||
/// `FETCH_HEAD` 可以有多旧,`behind` 才不再可信(镜像 web `FETCH_STALE_MS`,
|
||||
/// `public/projects.ts:615`)。
|
||||
static let fetchStaleMs: Double = 60 * 60 * 1000
|
||||
|
||||
let head: Head
|
||||
let dirty: Dirty
|
||||
/// 唯一允许显示为绿色的状态:有上游 && ↑0 && ↓0 && fetch 新鲜。
|
||||
let isInSync: Bool
|
||||
/// `behind` 是否经过一次新鲜 fetch 核实过(false ⇒ 必须标"未核实")。
|
||||
let isBehindVerified: Bool
|
||||
/// 分离 HEAD 上没有可 fetch 的目标(服务器会 400)——按钮直接禁用。
|
||||
let canFetch: Bool
|
||||
|
||||
/// nil = 非 git 目录(没有任何可如实陈述的内容)。
|
||||
static func make(sync: SyncState?, dirtyCount: Int?, nowMs: Double) -> GitSyncBand? {
|
||||
guard let sync else { return nil }
|
||||
let isStale = Self.isStale(lastFetchMs: sync.lastFetchMs, nowMs: nowMs)
|
||||
let head = Self.head(for: sync)
|
||||
let isTracking: Bool
|
||||
if case .tracking = head { isTracking = true } else { isTracking = false }
|
||||
return GitSyncBand(
|
||||
head: head,
|
||||
dirty: Self.dirty(for: dirtyCount),
|
||||
isInSync: isTracking && sync.ahead == 0 && sync.behind == 0 && !isStale,
|
||||
isBehindVerified: isTracking && !isStale && sync.behind != nil,
|
||||
canFetch: sync.detached != true
|
||||
)
|
||||
}
|
||||
|
||||
/// 从未 fetch(nil)也算过期 —— "没 fetch 过"比"很久没 fetch"更不可信。
|
||||
private static func isStale(lastFetchMs: Double?, nowMs: Double) -> Bool {
|
||||
guard let lastFetchMs else { return true }
|
||||
return nowMs - lastFetchMs > fetchStaleMs
|
||||
}
|
||||
|
||||
private static func head(for sync: SyncState) -> Head {
|
||||
if sync.detached == true { return .detached }
|
||||
guard let upstream = sync.upstream else { return .noUpstream }
|
||||
return .tracking(upstream: upstream, ahead: sync.ahead, behind: sync.behind)
|
||||
}
|
||||
|
||||
private static func dirty(for dirtyCount: Int?) -> Dirty {
|
||||
guard let dirtyCount else { return .unknown }
|
||||
return dirtyCount > 0 ? .changed(dirtyCount) : .clean
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 未推送边界(w6/G4)
|
||||
|
||||
/// `git log` 列表里"已推送/未推送"的分界线位置。
|
||||
///
|
||||
/// 标记本身由服务器算(`unpushed`,按 SHA 前缀匹配 `@{u}..HEAD`)——客户端只决定
|
||||
/// 画在哪一行之后,且**只画一次**。日期序会把一个未推送的 merge 排到已推送提交
|
||||
/// 下面,所以边界必须取"最后一个 unpushed"的位置,而不是"前 N 行"。
|
||||
enum GitLogBoundary {
|
||||
/// 边界画在返回索引所指行的**下方**;nil = 不画(无上游 ⇒ 无可比对;
|
||||
/// 或者一条未推送都没有)。
|
||||
static func index(commits: [CommitLogEntry], upstream: String?) -> Int? {
|
||||
guard upstream != nil else { return nil }
|
||||
// `unpushed` 只在严格 true 时生效:nil 是"服务器没说",不是 false。
|
||||
return commits.lastIndex { $0.unpushed == true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 相对时间
|
||||
|
||||
/// 提交时间/最近 fetch 的相对文案(中文)。服务器时间戳可能因主机时钟漂移落在
|
||||
/// 未来 —— 那时退化为「刚刚」,绝不输出负数。
|
||||
enum GitTimeFormat {
|
||||
private static let msPerSecond: Double = 1_000
|
||||
private static let secondsPerMinute: Double = 60
|
||||
private static let secondsPerHour: Double = 60 * 60
|
||||
private static let secondsPerDay: Double = 24 * 60 * 60
|
||||
/// 一分钟以内一律「刚刚」。
|
||||
private static let justNowSeconds: Double = 60
|
||||
|
||||
static func relative(fromMs: Double, nowMs: Double) -> String {
|
||||
let seconds = max(0, (nowMs - fromMs) / msPerSecond)
|
||||
if seconds < justNowSeconds { return Copy.justNow }
|
||||
if seconds < secondsPerHour {
|
||||
return Copy.minutesAgo(Int(seconds / secondsPerMinute))
|
||||
}
|
||||
if seconds < secondsPerDay {
|
||||
return Copy.hoursAgo(Int(seconds / secondsPerHour))
|
||||
}
|
||||
return Copy.daysAgo(Int(seconds / secondsPerDay))
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
static let justNow = "刚刚"
|
||||
static func minutesAgo(_ value: Int) -> String { "\(value) 分钟前" }
|
||||
static func hoursAgo(_ value: Int) -> String { "\(value) 小时前" }
|
||||
static func daysAgo(_ value: Int) -> String { "\(value) 天前" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 写操作失败文案
|
||||
|
||||
/// git 写操作(stage/commit/push/fetch/worktree)的错误话术**单一出口**。
|
||||
///
|
||||
/// 纪律:服务器已经把 git stderr 分类成安全短句(SEC-M10),客户端**原样显示**
|
||||
/// 那句话 —— 既不吞掉(用户会以为操作成功了),也不自己编造原因。只有服务器
|
||||
/// 没给 message 时才落到调用方的兜底短语。
|
||||
enum GitWriteFeedback {
|
||||
static func message(status: Int, serverMessage: String?, fallback: String) -> String {
|
||||
guard let serverMessage, !serverMessage.isEmpty else {
|
||||
return "\(fallback)(HTTP \(status))"
|
||||
}
|
||||
return serverMessage
|
||||
}
|
||||
|
||||
/// 抛出的错误:`APIClientError` 自带面向用户的话术(含 401 引导补令牌),
|
||||
/// 其余(传输层)落兜底短语 —— 绝不把 `localizedDescription` 当主文案,
|
||||
/// 它是英文系统串。
|
||||
static func message(for error: any Error, fallback: String) -> String {
|
||||
(error as? APIClientError)?.message ?? fallback
|
||||
}
|
||||
|
||||
/// 429:限流是服务器的保护机制,**绝不**自动重试。
|
||||
static let rateLimited = APIClientError.rateLimited.message
|
||||
}
|
||||
369
ios/App/WebTerm/ViewModels/GitPanelViewModel.swift
Normal file
369
ios/App/WebTerm/ViewModels/GitPanelViewModel.swift
Normal file
@@ -0,0 +1,369 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// C2 · git 面板状态(`docs/plans/w6-project-git-panel.md` 的移动端对齐)。
|
||||
///
|
||||
/// 面板的职责是**如实告诉你发生了什么**,并提供 web 已有的那几个写操作:
|
||||
/// 同步带(↑↓ + 脏度 + fetch)· 改动文件的 stage/unstage · commit · push ·
|
||||
/// `git log`(含未推送边界)· PR/CI 芯片。
|
||||
///
|
||||
/// 三条纪律(都被单测钉住):
|
||||
/// 1. **绝不本地推算 git 数字**。任一写操作成功后重新向服务器要一遍详情/日志 ——
|
||||
/// `ahead`/`behind`/`lastFetchMs` 只有服务器说的算(w6 的核心教训:本地缓存的
|
||||
/// 数字会在 push 后继续撒谎)。
|
||||
/// 2. **分区独立降级**。日志失败不该让同步带消失,PR(gh 会走外网)单独加载,
|
||||
/// 详情失败就**没有**同步带,而不是显示一个编造的"已同步"。
|
||||
/// 3. **服务器安全文案原样上浮**(SEC-M10 已在服务端把 stderr 分类成安全短句)——
|
||||
/// 写操作绝不静默失败。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GitPanelViewModel {
|
||||
|
||||
/// 一个待处理的改动文件。
|
||||
///
|
||||
/// `stagePaths` 与显示路径分开:重命名必须**同时**把 oldPath 与 newPath 交给
|
||||
/// `git add`(镜像 web `public/diff.ts` 的做法),否则只暂存新增侧、删除侧留在
|
||||
/// 工作区,提交出来是"复制"而不是"重命名"。
|
||||
struct ChangedFile: Equatable, Identifiable, Sendable {
|
||||
let displayPath: String
|
||||
let stagePaths: [String]
|
||||
let status: DiffFileStatus
|
||||
let added: Int
|
||||
let removed: Int
|
||||
|
||||
var id: String { displayPath }
|
||||
|
||||
static func make(from file: DiffFile) -> ChangedFile {
|
||||
let primary = file.newPath.isEmpty ? file.oldPath : file.newPath
|
||||
let isRename = file.status == .renamed && !file.oldPath.isEmpty
|
||||
&& file.oldPath != file.newPath
|
||||
return ChangedFile(
|
||||
displayPath: isRename ? "\(file.oldPath) → \(file.newPath)" : primary,
|
||||
stagePaths: isRename ? [file.oldPath, file.newPath] : [primary],
|
||||
status: file.status,
|
||||
added: file.added,
|
||||
removed: file.removed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 正在进行的写操作(用于禁用按钮 + 行内 spinner)。
|
||||
enum Operation: Equatable {
|
||||
case fetch
|
||||
case commit
|
||||
case push
|
||||
case stage(String)
|
||||
case unstage(String)
|
||||
}
|
||||
|
||||
/// 注入的依赖 —— 生产由 `forProject` 包 `APIClient`/`DiffFetcher`,测试注入 fake。
|
||||
struct Dependencies: Sendable {
|
||||
let detail: @Sendable () async throws -> ProjectDetail
|
||||
let log: @Sendable () async throws -> GitLogResult
|
||||
let pr: @Sendable () async throws -> PrStatus
|
||||
let changes: @Sendable (_ staged: Bool) async throws -> [ChangedFile]
|
||||
let stage: @Sendable (_ files: [String], _ stage: Bool) async throws
|
||||
-> GitWriteOutcome<StageResult>
|
||||
let commit: @Sendable (_ message: String) async throws -> GitWriteOutcome<CommitResult>
|
||||
let push: @Sendable () async throws -> GitWriteOutcome<PushResult>
|
||||
let fetchRemote: @Sendable () async throws -> GitWriteOutcome<FetchResult>
|
||||
/// 判定 fetch 新鲜度的"现在"(可注入,测试才能确定性地跨过 1h 阈值)。
|
||||
let nowMs: @Sendable () -> Double
|
||||
|
||||
init(
|
||||
detail: @escaping @Sendable () async throws -> ProjectDetail,
|
||||
log: @escaping @Sendable () async throws -> GitLogResult,
|
||||
pr: @escaping @Sendable () async throws -> PrStatus,
|
||||
changes: @escaping @Sendable (Bool) async throws -> [ChangedFile],
|
||||
stage: @escaping @Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>,
|
||||
commit: @escaping @Sendable (String) async throws -> GitWriteOutcome<CommitResult>,
|
||||
push: @escaping @Sendable () async throws -> GitWriteOutcome<PushResult>,
|
||||
fetchRemote: @escaping @Sendable () async throws -> GitWriteOutcome<FetchResult>,
|
||||
nowMs: @escaping @Sendable () -> Double = { Date().timeIntervalSince1970 * 1_000 }
|
||||
) {
|
||||
self.detail = detail
|
||||
self.log = log
|
||||
self.pr = pr
|
||||
self.changes = changes
|
||||
self.stage = stage
|
||||
self.commit = commit
|
||||
self.push = push
|
||||
self.fetchRemote = fetchRemote
|
||||
self.nowMs = nowMs
|
||||
}
|
||||
}
|
||||
|
||||
let path: String
|
||||
|
||||
private(set) var isLoaded = false
|
||||
private(set) var branch: String?
|
||||
/// nil = 非 git 目录 **或** 详情读取失败(见 `stateErrorMessage`)。
|
||||
private(set) var band: GitSyncBand?
|
||||
private(set) var stateErrorMessage: String?
|
||||
private(set) var log: GitLogResult?
|
||||
private(set) var logErrorMessage: String?
|
||||
private(set) var pr: PrStatus?
|
||||
private(set) var unstaged: [ChangedFile] = []
|
||||
private(set) var staged: [ChangedFile] = []
|
||||
private(set) var changesErrorMessage: String?
|
||||
private(set) var busy: Operation?
|
||||
/// 最近一次写操作失败(服务器安全文案优先)。
|
||||
private(set) var errorMessage: String?
|
||||
/// 最近一次写操作成功的简短确认。
|
||||
private(set) var noticeMessage: String?
|
||||
/// 提交信息草稿(`TextField` 绑定)。
|
||||
var commitMessage = ""
|
||||
|
||||
@ObservationIgnored
|
||||
private let dependencies: Dependencies
|
||||
|
||||
init(path: String, dependencies: Dependencies) {
|
||||
self.path = path
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
/// 生产装配缝(ProjectDetailScreen 只需转手 endpoint/http/path)。
|
||||
static func forProject(
|
||||
endpoint: HostEndpoint, http: any HTTPTransport, path: String
|
||||
) -> GitPanelViewModel {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
let diff = DiffFetcher(endpoint: endpoint, http: http)
|
||||
return GitPanelViewModel(path: path, dependencies: Dependencies(
|
||||
detail: { try await client.projectDetail(path: path) },
|
||||
log: { try await client.gitLog(path: path) },
|
||||
pr: { try await client.prStatus(path: path) },
|
||||
changes: { staged in
|
||||
try await diff.fetch(path: path, staged: staged).files.map(ChangedFile.make(from:))
|
||||
},
|
||||
stage: { files, stage in
|
||||
try await client.gitStage(path: path, files: files, stage: stage)
|
||||
},
|
||||
commit: { message in try await client.gitCommit(path: path, message: message) },
|
||||
push: { try await client.gitPush(path: path) },
|
||||
fetchRemote: { try await client.gitFetch(path: path) }
|
||||
))
|
||||
}
|
||||
|
||||
var canCommit: Bool {
|
||||
busy == nil && !commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// 分离 HEAD 上没有可 fetch 的目标(服务器会 400)——按钮直接禁用。
|
||||
var canFetch: Bool { busy == nil && band?.canFetch == true }
|
||||
|
||||
// MARK: - 读
|
||||
|
||||
/// 首屏 + 每次写操作之后的刷新。三个分区各自独立降级。
|
||||
func load() async {
|
||||
await loadState()
|
||||
await loadLog()
|
||||
await loadChanges()
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
/// PR/CI 单独一条命令:gh 会替我们走一次外网调用,慢到秒级,绝不放进首屏串行链。
|
||||
func loadPullRequest() async {
|
||||
do {
|
||||
pr = try await dependencies.pr()
|
||||
} catch {
|
||||
// 200 + 降级 availability 才是"gh 不可用"的正常表达;真错误只是让芯片
|
||||
// 缺席(面板其余部分照常),绝不弹成写操作错误。
|
||||
pr = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func loadState() async {
|
||||
do {
|
||||
let detail = try await dependencies.detail()
|
||||
branch = detail.branch
|
||||
band = GitSyncBand.make(
|
||||
sync: detail.sync, dirtyCount: detail.dirtyCount, nowMs: dependencies.nowMs()
|
||||
)
|
||||
stateErrorMessage = nil
|
||||
} catch {
|
||||
branch = nil
|
||||
band = nil // 读不到就没有同步带 —— 绝不显示一个编造的"已同步"
|
||||
stateErrorMessage = GitWriteFeedback.message(
|
||||
for: error, fallback: GitPanelCopy.stateFailed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLog() async {
|
||||
do {
|
||||
log = try await dependencies.log()
|
||||
logErrorMessage = nil
|
||||
} catch {
|
||||
logErrorMessage = GitWriteFeedback.message(for: error, fallback: GitPanelCopy.logFailed)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChanges() async {
|
||||
do {
|
||||
unstaged = try await dependencies.changes(false)
|
||||
staged = try await dependencies.changes(true)
|
||||
changesErrorMessage = nil
|
||||
} catch {
|
||||
changesErrorMessage = GitWriteFeedback.message(
|
||||
for: error, fallback: GitPanelCopy.changesFailed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 写
|
||||
|
||||
func setStaged(_ file: ChangedFile, staged: Bool) async {
|
||||
await perform(
|
||||
staged ? .stage(file.id) : .unstage(file.id),
|
||||
fallback: staged ? GitPanelCopy.stageFailed : GitPanelCopy.unstageFailed,
|
||||
write: { try await self.dependencies.stage(file.stagePaths, staged) },
|
||||
onSuccess: { [weak self] (result: StageResult) in
|
||||
self?.noticeMessage = staged
|
||||
? GitPanelCopy.staged(result.count)
|
||||
: GitPanelCopy.unstaged(result.count)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func commit() async {
|
||||
let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else {
|
||||
errorMessage = GitPanelCopy.commitMessageRequired
|
||||
return
|
||||
}
|
||||
await perform(
|
||||
.commit,
|
||||
fallback: GitPanelCopy.commitFailed,
|
||||
write: { try await self.dependencies.commit(message) },
|
||||
onSuccess: { [weak self] (result: CommitResult) in
|
||||
// 只有成功才清草稿:409「无暂存」时用户的文字必须留着。
|
||||
self?.commitMessage = ""
|
||||
self?.noticeMessage = GitPanelCopy.committed(result.commit)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func push() async {
|
||||
await perform(
|
||||
.push,
|
||||
fallback: GitPanelCopy.pushFailed,
|
||||
write: { try await self.dependencies.push() },
|
||||
onSuccess: { [weak self] (result: PushResult) in
|
||||
self?.noticeMessage = GitPanelCopy.pushed(
|
||||
branch: result.branch, remote: result.remote
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func fetchRemote() async {
|
||||
await perform(
|
||||
.fetch,
|
||||
fallback: GitPanelCopy.fetchFailed,
|
||||
write: { try await self.dependencies.fetchRemote() },
|
||||
onSuccess: { [weak self] (_: FetchResult) in
|
||||
self?.noticeMessage = GitPanelCopy.fetched
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 所有写操作的唯一执行点:忙态去重 → 三种结局映射 → 成功后**重新向服务器
|
||||
/// 取真相**。`write`/`onSuccess` 都是非逃逸闭包,因此在 MainActor 上原地执行。
|
||||
private func perform<Payload: Sendable & Equatable>(
|
||||
_ operation: Operation,
|
||||
fallback: String,
|
||||
write: () async throws -> GitWriteOutcome<Payload>,
|
||||
onSuccess: (Payload) -> Void
|
||||
) async {
|
||||
guard busy == nil else { return } // 重复点击只发一次
|
||||
busy = operation
|
||||
errorMessage = nil
|
||||
noticeMessage = nil
|
||||
do {
|
||||
switch try await write() {
|
||||
case .ok(let payload):
|
||||
onSuccess(payload)
|
||||
busy = nil
|
||||
await load() // 数字只认服务器(w6:本地推算会在 push 后继续撒谎)
|
||||
return
|
||||
case .rejected(let status, let message):
|
||||
errorMessage = GitWriteFeedback.message(
|
||||
status: status, serverMessage: message, fallback: fallback
|
||||
)
|
||||
case .rateLimited:
|
||||
errorMessage = GitWriteFeedback.rateLimited // 限流绝不自动重试
|
||||
}
|
||||
} catch {
|
||||
errorMessage = GitWriteFeedback.message(for: error, fallback: fallback)
|
||||
}
|
||||
busy = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan §4)
|
||||
|
||||
enum GitPanelCopy {
|
||||
static let title = "Git 面板"
|
||||
static let entryLabel = "Git 面板"
|
||||
static let stateSection = "同步状态"
|
||||
static let changesSection = "改动"
|
||||
static let commitSection = "提交"
|
||||
static let logSection = "最近提交"
|
||||
static let prSection = "Pull Request"
|
||||
|
||||
static let upstreamCaption = "上游"
|
||||
static let toPushCaption = "待推送"
|
||||
static let toPullCaption = "待拉取"
|
||||
static let unknownCount = "—"
|
||||
static let inSync = "已同步"
|
||||
static let unverified = "未核实"
|
||||
static let noUpstream = "无上游"
|
||||
static let detachedHead = "分离 HEAD"
|
||||
static let dirtyUnknown = "未检查改动"
|
||||
static let clean = "工作区干净"
|
||||
static let neverFetched = "从未 fetch —— 待拉取数不可信"
|
||||
static let staleFetch = "上次 fetch 已久 —— 待拉取数不可信"
|
||||
static let noUpstreamDetail = "此分支不跟踪任何上游,没有可报告的待推送/待拉取。"
|
||||
static let detachedDetail = "HEAD 处于分离状态:没有分支,也无法 fetch。"
|
||||
|
||||
static let fetchButton = "Fetch"
|
||||
static let fetchHint = "只刷新远端引用,不 pull、不合并"
|
||||
static let commitButton = "提交"
|
||||
static let pushButton = "推送"
|
||||
static let commitPlaceholder = "提交信息"
|
||||
static let stageButton = "暂存"
|
||||
static let unstageButton = "取消暂存"
|
||||
static let noChanges = "工作区没有待提交的改动。"
|
||||
static let noStaged = "暂存区为空。"
|
||||
static let noCommits = "暂无提交记录。"
|
||||
static let truncatedLog = "仅显示最近若干条提交。"
|
||||
|
||||
static let commitMessageRequired = "请先填写提交信息。"
|
||||
static let stateFailed = "读取 git 状态失败"
|
||||
static let logFailed = "读取提交记录失败"
|
||||
static let changesFailed = "读取改动列表失败"
|
||||
static let stageFailed = "暂存失败"
|
||||
static let unstageFailed = "取消暂存失败"
|
||||
static let commitFailed = "提交失败"
|
||||
static let pushFailed = "推送失败"
|
||||
static let fetchFailed = "Fetch 失败"
|
||||
static let fetched = "已刷新远端引用。"
|
||||
|
||||
static func staged(_ count: Int) -> String { "已暂存 \(count) 个文件。" }
|
||||
static func unstaged(_ count: Int) -> String { "已取消暂存 \(count) 个文件。" }
|
||||
|
||||
static func committed(_ commit: String) -> String {
|
||||
commit.isEmpty ? "已提交。" : "已提交 \(commit)。"
|
||||
}
|
||||
|
||||
static func pushed(branch: String?, remote: String?) -> String {
|
||||
guard let branch, let remote else { return "已推送。" }
|
||||
return "已推送 \(branch) → \(remote)。"
|
||||
}
|
||||
|
||||
static func unpushedBoundary(_ upstream: String) -> String { "以上未推送到 \(upstream)" }
|
||||
static func dirtyCount(_ count: Int) -> String { "\(count) 处未提交改动" }
|
||||
static func lastFetched(_ label: String) -> String { "上次 fetch:\(label)" }
|
||||
}
|
||||
@@ -42,9 +42,59 @@ import WireProtocol
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PairingViewModel {
|
||||
/// The probe, injected as a closure so tests can both fake results AND
|
||||
/// assert non-invocation before the user confirms (task RED list).
|
||||
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
|
||||
/// Everything the pairing flow needs from the network/platform, injected as
|
||||
/// ONE value built by the composition root (`AppEnvironment.probe`).
|
||||
///
|
||||
/// Why a struct and not three parameters: `AppEnvironment.probe` is handed to
|
||||
/// this VM by `AppCoordinator`, so growing the capability set inside the
|
||||
/// value keeps the composition root the single place they are built — adding
|
||||
/// separately-defaulted parameters instead would silently fall back to
|
||||
/// defaults in production, i.e. a dead hook.
|
||||
///
|
||||
/// Tests fake results AND assert non-invocation before the user confirms
|
||||
/// (task RED list); `validateToken`/`unregisterPush` default to the
|
||||
/// zero-config behaviour so existing call sites stay one-liners.
|
||||
struct Probe: Sendable {
|
||||
/// Two-step host verification (`runPairingProbe`), carrying an optional
|
||||
/// candidate access token for a host that gates its surface (§1.1).
|
||||
let verifyHost: @Sendable (HostEndpoint, AccessToken?) async
|
||||
-> Result<HostEndpoint, PairingError>
|
||||
/// One-shot `POST /auth` validation of a candidate token — §1.1's four
|
||||
/// outcomes, or a transport-level failure.
|
||||
let validateToken: @Sendable (HostEndpoint, AccessToken) async
|
||||
-> Result<AccessTokenProbeResult, TokenProbeFailure>
|
||||
/// Side effect of removing a host: de-register THIS device's APNs token
|
||||
/// on that host (`PushRegistrar.handleHostRemoved`). Failure is the
|
||||
/// registrar's to log — removal must never be blocked by it.
|
||||
let unregisterPush: @Sendable (HostRegistry.Host) async -> Void
|
||||
|
||||
init(
|
||||
verifyHost: @escaping @Sendable (HostEndpoint, AccessToken?) async
|
||||
-> Result<HostEndpoint, PairingError>,
|
||||
/// Unscripted default = "this host has auth disabled", which is the
|
||||
/// zero-config LAN reality and never fabricates an authenticated state.
|
||||
validateToken: @escaping @Sendable (HostEndpoint, AccessToken) async
|
||||
-> Result<AccessTokenProbeResult, TokenProbeFailure> = { _, _ in
|
||||
.success(.authDisabled)
|
||||
},
|
||||
unregisterPush: @escaping @Sendable (HostRegistry.Host) async -> Void = { _ in }
|
||||
) {
|
||||
self.verifyHost = verifyHost
|
||||
self.validateToken = validateToken
|
||||
self.unregisterPush = unregisterPush
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a `POST /auth` probe never produced one of the four §1.1 outcomes.
|
||||
/// Deliberately payload-free about the token itself (§5.3).
|
||||
enum TokenProbeFailure: Error, Equatable, Sendable {
|
||||
/// Transport-level failure / unexpected status; carries the network
|
||||
/// description only (never the token).
|
||||
case unreachable(String)
|
||||
/// The candidate violated the frozen shape before any I/O — defensive:
|
||||
/// the VM validates first, so this should be unreachable in practice.
|
||||
case malformed
|
||||
}
|
||||
|
||||
// MARK: - UI state model
|
||||
|
||||
@@ -81,6 +131,11 @@ final class PairingViewModel {
|
||||
/// iOS Local Network permission was denied → deep-link to the app's
|
||||
/// Settings pane (its 本地网络 toggle lives there).
|
||||
case openLocalNetworkSettings
|
||||
/// C1 · The host answered **401**, which is ambiguous by design (Origin
|
||||
/// or access token — same status). Offer the one remedy that can be
|
||||
/// applied from the phone: type the access token. Retry stays available
|
||||
/// for the Origin case.
|
||||
case enterAccessToken
|
||||
}
|
||||
|
||||
struct FailureDisplay: Equatable, Sendable {
|
||||
@@ -93,6 +148,10 @@ final class PairingViewModel {
|
||||
case confirming(PendingHost)
|
||||
case probing(PendingHost)
|
||||
case failed(PendingHost, FailureDisplay)
|
||||
/// C1 · Access-token prompt for a host that answered 401.
|
||||
case awaitingToken(PendingHost)
|
||||
/// C1 · `POST /auth` in flight for the typed candidate.
|
||||
case validatingToken(PendingHost)
|
||||
case paired(HostRegistry.Host)
|
||||
}
|
||||
|
||||
@@ -112,11 +171,24 @@ final class PairingViewModel {
|
||||
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
|
||||
/// observes it to move on to the session list).
|
||||
private(set) var pairedHost: HostRegistry.Host?
|
||||
/// Inline copy under the token field (wrong token / rate-limited / this host
|
||||
/// has no auth at all / shape violation). NEVER echoes the token itself.
|
||||
private(set) var tokenRejection: String?
|
||||
/// C1 · Already-paired hosts, for the manage section (token update + remove).
|
||||
/// Loaded explicitly by the screen — pairing itself never needs it.
|
||||
private(set) var pairedHosts: [HostRegistry.Host] = []
|
||||
/// Explicit store-failure copy for the manage section (never a silent
|
||||
/// empty list hiding a broken keychain).
|
||||
private(set) var hostsErrorMessage: String?
|
||||
|
||||
// MARK: - Dependencies (not observed)
|
||||
|
||||
@ObservationIgnored private let store: any HostStore
|
||||
@ObservationIgnored private let probe: Probe
|
||||
/// The token validated by `POST /auth` for the host being paired, held in
|
||||
/// memory ONLY until the host record is written (Keychain via `HostStore`).
|
||||
/// nil = no token (LAN default, or the host reported auth disabled).
|
||||
@ObservationIgnored private var candidateToken: AccessToken?
|
||||
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
|
||||
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
|
||||
/// production reads the keychain. Defaulted so existing call sites compile.
|
||||
@@ -124,7 +196,7 @@ final class PairingViewModel {
|
||||
|
||||
init(
|
||||
store: any HostStore,
|
||||
probe: @escaping Probe,
|
||||
probe: Probe, // C1 · a value now (three capabilities), no longer a closure
|
||||
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||||
KeychainClientIdentityStore().hasInstalledIdentity()
|
||||
}
|
||||
@@ -170,11 +242,15 @@ final class PairingViewModel {
|
||||
}
|
||||
|
||||
/// Back out of confirm/failed to a clean entry state. Never probes.
|
||||
/// Drops the in-memory token candidate too — an abandoned pairing must not
|
||||
/// leave a secret behind for the next target.
|
||||
func cancel() {
|
||||
phase = .idle
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
tokenRejection = nil
|
||||
candidateToken = nil
|
||||
}
|
||||
|
||||
// MARK: - Confirm → probe → store
|
||||
@@ -200,7 +276,9 @@ final class PairingViewModel {
|
||||
switch phase {
|
||||
case .idle, .confirming, .failed:
|
||||
return true
|
||||
case .probing, .paired:
|
||||
case .probing, .awaitingToken, .validatingToken, .paired:
|
||||
// Mid-credential-entry counts as busy: a scan landing while the
|
||||
// token prompt is up must not silently retarget the token.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -209,6 +287,8 @@ final class PairingViewModel {
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
tokenRejection = nil
|
||||
candidateToken = nil // a new target never inherits the previous token
|
||||
hostName = endpoint.baseURL.host ?? ""
|
||||
phase = .confirming(PendingHost(
|
||||
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
||||
@@ -228,7 +308,10 @@ final class PairingViewModel {
|
||||
return
|
||||
}
|
||||
phase = .probing(pending)
|
||||
switch await probe(pending.endpoint) {
|
||||
// The candidate token (if any) travels with BOTH probe legs — the HTTP
|
||||
// one as a `Cookie` header via APIClient, the WS one via the
|
||||
// probe-scoped transport the composition root builds (§1.1).
|
||||
switch await probe.verifyHost(pending.endpoint, candidateToken) {
|
||||
case .failure(let error):
|
||||
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
||||
case .success(let endpoint):
|
||||
@@ -239,13 +322,23 @@ final class PairingViewModel {
|
||||
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
|
||||
/// endpoint. A store failure is surfaced explicitly (never swallowed);
|
||||
/// retry re-runs the whole confirm flow.
|
||||
///
|
||||
/// C1 · Re-pairing the SAME origin updates that host in place (its `id` is
|
||||
/// reused) instead of appending a duplicate. That is what makes "this host
|
||||
/// just got a WEBTERM_TOKEN" recoverable: pair it again, type the token, and
|
||||
/// the existing record — the one every `lastSessionId`/watermark is keyed by
|
||||
/// — gains the token.
|
||||
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
|
||||
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
let existing = await existingHost(matching: endpoint)
|
||||
let host = HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: trimmedName.isEmpty ? fallbackName : trimmedName,
|
||||
endpoint: endpoint
|
||||
id: existing?.id ?? UUID(),
|
||||
name: resolvedName(for: endpoint, existing: existing),
|
||||
endpoint: endpoint,
|
||||
// A validated candidate wins; otherwise keep whatever the record
|
||||
// already had (re-pairing to fix a name must not wipe a working
|
||||
// credential). `.authDisabled` clears the candidate first, so a
|
||||
// host that reports no auth can never persist a token here.
|
||||
accessToken: candidateToken ?? existing?.accessToken
|
||||
)
|
||||
do {
|
||||
_ = try await store.upsert(host)
|
||||
@@ -255,10 +348,128 @@ final class PairingViewModel {
|
||||
))
|
||||
return
|
||||
}
|
||||
candidateToken = nil // persisted — drop the in-memory copy
|
||||
pairedHost = host
|
||||
phase = .paired(host)
|
||||
}
|
||||
|
||||
/// The already-paired host at the same origin, or nil. A store read failure
|
||||
/// degrades to nil (pair as new) rather than blocking the pairing.
|
||||
private func existingHost(matching endpoint: HostEndpoint) async -> HostRegistry.Host? {
|
||||
let hosts = try? await store.loadAll()
|
||||
return hosts?.first { $0.endpoint.originHeader == endpoint.originHeader }
|
||||
}
|
||||
|
||||
/// User-typed name wins; an untouched field keeps the existing host's name
|
||||
/// (re-pairing must not rename "MacBook" to "192.168.1.5").
|
||||
private func resolvedName(
|
||||
for endpoint: HostEndpoint, existing: HostRegistry.Host?
|
||||
) -> String {
|
||||
let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let derived = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
if trimmed.isEmpty || trimmed == derived {
|
||||
return existing?.name ?? derived
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// MARK: - Access token (§1.1: POST /auth is a one-shot pairing probe)
|
||||
|
||||
/// Failure page → token prompt. Only from a 401-shaped failure, which is the
|
||||
/// only failure whose remedy might be a token.
|
||||
func beginAccessTokenEntry() {
|
||||
guard case .failed(let pending, let failure) = phase,
|
||||
failure.action == .enterAccessToken else { return }
|
||||
tokenRejection = nil
|
||||
phase = .awaitingToken(pending)
|
||||
}
|
||||
|
||||
/// Token prompt → back to the confirm page (the URL is still fine; the user
|
||||
/// may prefer to fix `ALLOWED_ORIGINS` instead).
|
||||
func cancelAccessTokenEntry() {
|
||||
guard case .awaitingToken(let pending) = phase else { return }
|
||||
tokenRejection = nil
|
||||
candidateToken = nil
|
||||
phase = .confirming(pending)
|
||||
}
|
||||
|
||||
/// Validate a typed token against the host, then re-run the host probe with
|
||||
/// it. Shape is checked BEFORE any I/O (§1.1 charset/length is the server's
|
||||
/// own rule, so a shape violation can never be the right token).
|
||||
///
|
||||
/// The four §1.1 outcomes are all handled explicitly, and `authDisabled`
|
||||
/// deliberately does NOT persist anything: a 204 without `Set-Cookie` means
|
||||
/// the host never enabled auth, so claiming "authenticated" would be a lie
|
||||
/// and storing the token would gate nothing.
|
||||
func submitAccessToken(_ raw: String) async {
|
||||
guard case .awaitingToken(let pending) = phase else { return }
|
||||
let token: AccessToken
|
||||
do {
|
||||
token = try AccessToken(validating: raw)
|
||||
} catch let error as AccessTokenError {
|
||||
tokenRejection = PairingCopy.tokenShapeRejected(error)
|
||||
return
|
||||
} catch {
|
||||
tokenRejection = PairingCopy.tokenShapeUnknown
|
||||
return
|
||||
}
|
||||
tokenRejection = nil
|
||||
phase = .validatingToken(pending)
|
||||
switch await probe.validateToken(pending.endpoint, token) {
|
||||
case .success(.valid):
|
||||
candidateToken = token
|
||||
await runProbe(for: pending) // re-verify, now authenticated
|
||||
case .success(.authDisabled):
|
||||
candidateToken = nil
|
||||
tokenRejection = PairingCopy.tokenNotRequired
|
||||
phase = .awaitingToken(pending)
|
||||
case .success(.invalidToken):
|
||||
tokenRejection = PairingCopy.tokenInvalid
|
||||
phase = .awaitingToken(pending)
|
||||
case .success(.rateLimited):
|
||||
tokenRejection = PairingCopy.tokenRateLimited
|
||||
phase = .awaitingToken(pending)
|
||||
case .failure(.unreachable(let underlying)):
|
||||
tokenRejection = PairingCopy.hostUnreachable(underlying)
|
||||
phase = .awaitingToken(pending)
|
||||
case .failure(.malformed):
|
||||
tokenRejection = PairingCopy.tokenShapeUnknown
|
||||
phase = .awaitingToken(pending)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paired-host management (C1 · the removal path `handleHostRemoved` lacked)
|
||||
|
||||
/// Load the manage section's host list. Explicit error copy on failure.
|
||||
func loadPairedHosts() async {
|
||||
do {
|
||||
pairedHosts = try await store.loadAll()
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hostsErrorMessage = PairingCopy.hostsLoadFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a paired host: de-register this device's APNs token on it, then
|
||||
/// delete the record (which takes its access token with it — the token is a
|
||||
/// field of the record, so no orphaned secret can survive).
|
||||
///
|
||||
/// ORDER MATTERS: the de-registration POST goes out FIRST, while the record
|
||||
/// (and therefore the host's access token, which the request needs to get
|
||||
/// past a token gate) still exists. A failing de-registration never blocks
|
||||
/// the removal — the user asked for the host to be gone, and the server also
|
||||
/// prunes tokens itself on an APNs 410.
|
||||
func removeHost(id: UUID) async {
|
||||
guard let host = pairedHosts.first(where: { $0.id == id }) else { return }
|
||||
await probe.unregisterPush(host)
|
||||
do {
|
||||
pairedHosts = try await store.remove(id: id)
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hostsErrorMessage = PairingCopy.hostRemoveFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||
|
||||
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
||||
@@ -289,7 +500,14 @@ final class PairingViewModel {
|
||||
case .originRejected(let hint):
|
||||
// The probe already derived the complete actionable copy from
|
||||
// endpoint.originHeader — surface it VERBATIM, never re-derive.
|
||||
return FailureDisplay(message: hint, action: .retry)
|
||||
//
|
||||
// C1 · The action is `.enterAccessToken`, not `.retry`: this case now
|
||||
// also carries the AMBIGUOUS 401 (Origin *or* token — the server
|
||||
// answers the same status for both, see `unauthorizedPairingHint`),
|
||||
// and typing a token is the only remedy reachable from the phone.
|
||||
// The failure view keeps a retry button alongside it, so the pure
|
||||
// Origin case (the 403 on the guarded kill) loses nothing.
|
||||
return FailureDisplay(message: hint, action: .enterAccessToken)
|
||||
case .atsBlocked(let host):
|
||||
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
||||
case .tlsFailure:
|
||||
@@ -418,40 +636,3 @@ final class PairingViewModel {
|
||||
private static let ipv4OctetCount = 4
|
||||
private static let ipv4OctetRange = 0...255
|
||||
}
|
||||
|
||||
/// User-facing pairing copy (plan §3.4 taxonomy → actionable wording; §5.2
|
||||
/// Local-Network guidance including the iOS 18 restart caveat).
|
||||
enum PairingCopy {
|
||||
static let scanRejected =
|
||||
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
|
||||
static let manualRejected =
|
||||
"无法解析这个地址。请输入完整 URL,例如 http://192.168.1.5:3000"
|
||||
static let storeFailed =
|
||||
"主机已通过验证,但保存到本机失败,请重试。"
|
||||
static let localNetworkDenied =
|
||||
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
|
||||
+ "(iOS 18 存在需要重启手机才生效的已知问题)。"
|
||||
static let notWebTerminal =
|
||||
"对方在响应 HTTP,但不是 web-terminal——端口对吗?"
|
||||
static let tlsFailure =
|
||||
"TLS 连接失败:证书无效或不受信任。"
|
||||
static let timeout =
|
||||
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
|
||||
static let deviceCertRequired =
|
||||
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
|
||||
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
|
||||
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
|
||||
static let clientCertRejected =
|
||||
"本设备证书无效或已吊销,请重新导入。"
|
||||
|
||||
static func hostUnreachable(_ underlying: String) -> String {
|
||||
"无法连接主机:\(underlying)"
|
||||
}
|
||||
|
||||
/// §3.4 wording for the ATS cleartext block.
|
||||
static func atsBlocked(host: String) -> String {
|
||||
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
|
||||
+ "请改用 https / tailscale serve,或反馈该网段。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,28 @@ final class ProjectsViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
/// T-iOS-32(C2)· 恢复一条历史会话:**同一条** `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 {
|
||||
|
||||
146
ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift
Normal file
146
ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-32 · `claude --resume <id>` 历史(`GET /sessions`)。
|
||||
///
|
||||
/// 服务端把主机 `~/.claude/projects` 下最近修改的会话文件列给我们(
|
||||
/// `src/http/history.ts`),这正是 `claude --resume` 选择器的数据。恢复动作走的是
|
||||
/// 已有的"在此仓库开新会话"缝:`attach(null, cwd)` + attach 后注入
|
||||
/// `claude --resume <id>\r`(镜像 web `public/tabs.ts:918 newTabForResume`)。
|
||||
///
|
||||
/// **安全(本文件存在的主要理由)**:`id`/`cwd`/`preview` 都是不可信服务器字节,
|
||||
/// 而 `id` 会被拼进一条**真的送进 PTY 的命令行**。因此 `ProjectResumeCommand` 用
|
||||
/// 白名单校验 id,任何越界字符(`;` `` ` `` `$(` 空格、换行…)一律拒绝、该行不可恢复。
|
||||
/// web 侧没有这一层(`claude --resume ${sessionId}\r` 直接拼),iOS 不复制这个缺口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ResumeHistoryViewModel {
|
||||
|
||||
/// 一条可展示的历史会话。`bootstrapInput == nil` ⇒ id 未通过白名单 ⇒ 行照常
|
||||
/// 显示(用户能看到主机上有这条历史),但不提供恢复按钮。
|
||||
struct ResumeCandidate: Equatable, Identifiable, Sendable {
|
||||
let session: HistorySession
|
||||
/// 会话自己的 cwd(worktree 会话会回到那个 worktree,而不是仓库根)。
|
||||
let cwd: String
|
||||
let bootstrapInput: String?
|
||||
|
||||
var id: String { session.id }
|
||||
var canResume: Bool { bootstrapInput != nil }
|
||||
}
|
||||
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
case loaded([ResumeCandidate])
|
||||
/// 服务器无历史(或全都不属于本仓库)——正常态,不是失败。
|
||||
case empty
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
let projectPath: String
|
||||
private(set) var phase: Phase = .loading
|
||||
|
||||
@ObservationIgnored
|
||||
private let fetch: @Sendable () async throws -> [HistorySession]
|
||||
|
||||
init(projectPath: String, fetch: @escaping @Sendable () async throws -> [HistorySession]) {
|
||||
self.projectPath = projectPath
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
/// 生产装配缝。
|
||||
static func forProject(
|
||||
endpoint: HostEndpoint, http: any HTTPTransport, path: String
|
||||
) -> ResumeHistoryViewModel {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return ResumeHistoryViewModel(projectPath: path, fetch: {
|
||||
try await client.claudeSessions()
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch 并归约。也是「重试」路径。
|
||||
func load() async {
|
||||
phase = .loading
|
||||
do {
|
||||
let candidates = Self.candidates(from: try await fetch(), projectPath: projectPath)
|
||||
phase = candidates.isEmpty ? .empty : .loaded(candidates)
|
||||
} catch {
|
||||
phase = .failed(GitWriteFeedback.message(for: error, fallback: ResumeCopy.loadFailed))
|
||||
}
|
||||
}
|
||||
|
||||
/// 过滤 + 映射(纯函数)。服务器已按 mtime 新→旧排好,**客户端不重排**。
|
||||
///
|
||||
/// 只保留 cwd 落在本项目内的会话:在另一个仓库跑过的会话,用本仓库的 cwd 去
|
||||
/// resume 是错的。前缀比较带 `/` 边界,否则 `/repos/web-terminal-old` 会被
|
||||
/// 当成 `/repos/web-terminal` 的子目录。
|
||||
static func candidates(
|
||||
from sessions: [HistorySession], projectPath: String
|
||||
) -> [ResumeCandidate] {
|
||||
let root = normalized(projectPath)
|
||||
guard Validation.isAbsoluteCwd(root) else { return [] }
|
||||
return sessions.compactMap { session in
|
||||
let cwd = normalized(session.cwd)
|
||||
guard Validation.isAbsoluteCwd(cwd), isInside(cwd: cwd, root: root) else { return nil }
|
||||
return ResumeCandidate(
|
||||
session: session,
|
||||
cwd: cwd,
|
||||
bootstrapInput: ProjectResumeCommand.bootstrapInput(sessionId: session.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 去掉尾部 `/`(根 `/` 保留),让前缀比较有确定的边界。
|
||||
private static func normalized(_ path: String) -> String {
|
||||
guard path.count > 1, path.hasSuffix("/") else { return path }
|
||||
return String(path.dropLast())
|
||||
}
|
||||
|
||||
private static func isInside(cwd: String, root: String) -> Bool {
|
||||
cwd == root || cwd.hasPrefix(root.hasSuffix("/") ? root : root + "/")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 恢复命令合成(安全边界)
|
||||
|
||||
/// 把一个服务器给的会话 id 变成一条可以送进 PTY 的命令 —— 或者拒绝它。
|
||||
///
|
||||
/// 白名单而非黑名单:id 在服务端就是 `.jsonl` 的文件名主干(实际是 UUID),因此
|
||||
/// `[A-Za-z0-9._-]` 足够,其余字符(空格、引号、`;`、`|`、`&`、`` ` ``、`$`、
|
||||
/// 换行、路径分隔符…)全部意味着"这不是一个会话 id"。被拒的 id 绝不进命令行。
|
||||
enum ProjectResumeCommand {
|
||||
/// 文件名主干的合理上限(UUID 是 36)。
|
||||
static let maxIdLength = 128
|
||||
|
||||
private static let allowed = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
|
||||
|
||||
/// nil = id 不可信 ⇒ 该行不可恢复。成功时以 `\r`(0x0D)结尾 —— Enter 是 `\r`
|
||||
/// 不是 `\n`(CLAUDE.md Gotchas)。
|
||||
static func bootstrapInput(sessionId: String) -> String? {
|
||||
guard isWellFormed(sessionId) else { return nil }
|
||||
return "claude --resume \(sessionId)\r"
|
||||
}
|
||||
|
||||
static func isWellFormed(_ sessionId: String) -> Bool {
|
||||
!sessionId.isEmpty
|
||||
&& sessionId.count <= maxIdLength
|
||||
&& sessionId.allSatisfy(allowed.contains)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan §4)
|
||||
|
||||
enum ResumeCopy {
|
||||
static let entryLabel = "恢复历史会话"
|
||||
static let sheetTitle = "历史会话"
|
||||
static let emptyTitle = "暂无历史会话"
|
||||
static let emptyDetail = "主机在此仓库下还没有 Claude Code 历史记录(`~/.claude/projects`)。"
|
||||
static let loadFailed = "读取历史会话失败"
|
||||
static let retry = "重试"
|
||||
static let resume = "恢复"
|
||||
static let notResumable = "会话标识不合法,无法恢复。"
|
||||
static let noPreview = "(无首条提示)"
|
||||
|
||||
static func resumeAccessibilityLabel(_ project: String) -> String { "恢复 \(project) 的会话" }
|
||||
}
|
||||
@@ -57,6 +57,19 @@ final class TerminalViewModel {
|
||||
static let replayTooLargeMessage =
|
||||
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
||||
|
||||
/// Actionable copy for `.failed(.unauthorized)` (C1 over B3, ios-completion
|
||||
/// §1.1). The server answers **401 on the WS upgrade for two different
|
||||
/// reasons** — the Origin/CSWSH check runs first, the `webterm_auth` cookie
|
||||
/// second (src/server.ts:1367-1379) — and the status is identical, so the
|
||||
/// client provably cannot tell them apart. Hence the copy names BOTH
|
||||
/// remedies instead of guessing one. Retrying is pointless (the engine
|
||||
/// already treats this as terminal), so the message asks for a credential
|
||||
/// change, not patience.
|
||||
static let unauthorizedMessage =
|
||||
"主机拒绝了连接(401):访问令牌缺失或不正确,也可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
|
||||
+ "请在会话列表的主机菜单里「管理主机与令牌」重新输入访问令牌;"
|
||||
+ "若令牌无误,就把主机的 ALLOWED_ORIGINS 设成 App 连接的完整地址后重启 web-terminal。"
|
||||
|
||||
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
|
||||
/// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen
|
||||
/// §3.2 signature needs real cols/rows and this is their single source.
|
||||
@@ -271,6 +284,11 @@ final class TerminalViewModel {
|
||||
case .failed(.replayTooLarge):
|
||||
banner = .none
|
||||
phase = .failed(message: Self.replayTooLargeMessage)
|
||||
case .failed(.unauthorized):
|
||||
// Same shape as replayTooLarge: a terminal state, so the spinner
|
||||
// must go and the terminal becomes read-only with actionable copy.
|
||||
banner = .none
|
||||
phase = .failed(message: Self.unauthorizedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
327
ios/App/WebTerm/ViewModels/WorktreeViewModel.swift
Normal file
327
ios/App/WebTerm/ViewModels/WorktreeViewModel.swift
Normal file
@@ -0,0 +1,327 @@
|
||||
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)。"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user