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:
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)" }
|
||||
}
|
||||
Reference in New Issue
Block a user