Files
web-terminal/ios/App/WebTerm/ViewModels/GitPanelViewModel.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

370 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import APIClient
import Foundation
import Observation
import WireProtocol
/// 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. ****PRgh
/// ****""
/// 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)" }
}