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.
370 lines
15 KiB
Swift
370 lines
15 KiB
Swift
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)" }
|
||
}
|