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:
268
ios/App/WebTerm/Screens/GitPanelScreen.swift
Normal file
268
ios/App/WebTerm/Screens/GitPanelScreen.swift
Normal file
@@ -0,0 +1,268 @@
|
||||
import APIClient
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// C2 · 项目 git 面板(web v0.6 `docs/plans/w6-project-git-panel.md` 的移动端对齐)。
|
||||
///
|
||||
/// 手机上不复制 web 的四列同步带 —— 那个布局在 720px 以下就会散架。语义完全一致,
|
||||
/// 只把"带"改成竖排卡片:唯一的绿色仍然只有「已同步」可以拿到。
|
||||
///
|
||||
/// 安全:分支/上游/文件路径/提交主题/PR 标题**全是不可信服务器字节** ——
|
||||
/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测);PR 链接
|
||||
/// 只在通过 `PrLink` 的 https+github.com 白名单后才可点。
|
||||
struct GitPanelScreen: View {
|
||||
@State private var viewModel: GitPanelViewModel
|
||||
|
||||
private enum Metrics {
|
||||
/// 提交信息输入框的行数区间(内容度量,非视觉 token)。
|
||||
static let commitEditorLines = 2...5
|
||||
/// 短 hash 展示长度(`git log --format=%h` 的常见宽度)。
|
||||
static let shortHashLength = 7
|
||||
}
|
||||
|
||||
/// 整宽 DS 按钮的行内距(与 ProjectDetailScreen 的按钮行一致)。
|
||||
private static let buttonRowInsets = EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.lg16
|
||||
)
|
||||
|
||||
init(viewModel: GitPanelViewModel) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
}
|
||||
|
||||
/// 生产入口:详情屏只需转手 endpoint/http/path。
|
||||
init(endpoint: HostEndpoint, http: any HTTPTransport, path: String) {
|
||||
self.init(viewModel: .forProject(endpoint: endpoint, http: http, path: path))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var bindable = viewModel
|
||||
return List {
|
||||
stateSection
|
||||
feedbackSection
|
||||
changesSection
|
||||
commitSection(message: $bindable.commitMessage)
|
||||
pullRequestSection
|
||||
logSection
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(GitPanelCopy.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.load()
|
||||
await viewModel.loadPullRequest() // gh 走外网,单独一条,不阻塞首屏
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
await viewModel.loadPullRequest()
|
||||
}
|
||||
.overlay {
|
||||
if !viewModel.isLoaded {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 同步状态
|
||||
|
||||
@ViewBuilder private var stateSection: some View {
|
||||
Section(GitPanelCopy.stateSection) {
|
||||
if let band = viewModel.band {
|
||||
GitSyncBandView(band: band, branch: viewModel.branch)
|
||||
Button {
|
||||
Task { await viewModel.fetchRemote() }
|
||||
} label: {
|
||||
Label(GitPanelCopy.fetchButton, systemImage: "arrow.down.circle")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(!viewModel.canFetch)
|
||||
.listRowInsets(Self.buttonRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
.accessibilityHint(GitPanelCopy.fetchHint)
|
||||
}
|
||||
if let message = viewModel.stateErrorMessage {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写操作的成功/失败回执。服务器安全文案原样显示(`Text(verbatim:)`)。
|
||||
@ViewBuilder private var feedbackSection: some View {
|
||||
if viewModel.errorMessage != nil || viewModel.noticeMessage != nil {
|
||||
Section {
|
||||
if let message = viewModel.errorMessage {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
if let message = viewModel.noticeMessage {
|
||||
InlineMessage(text: message, tone: .success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 改动(stage / unstage)
|
||||
|
||||
@ViewBuilder private var changesSection: some View {
|
||||
Section(GitPanelCopy.changesSection) {
|
||||
if let message = viewModel.changesErrorMessage {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
if viewModel.staged.isEmpty && viewModel.unstaged.isEmpty
|
||||
&& viewModel.changesErrorMessage == nil {
|
||||
Text(GitPanelCopy.noChanges)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
ForEach(viewModel.staged) { file in
|
||||
changedFileRow(file, isStaged: true)
|
||||
}
|
||||
ForEach(viewModel.unstaged) { file in
|
||||
changedFileRow(file, isStaged: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func changedFileRow(
|
||||
_ file: GitPanelViewModel.ChangedFile, isStaged: Bool
|
||||
) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
// 路径是服务器字节 → verbatim + 等宽中段截断。
|
||||
Text(verbatim: file.displayPath)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(GitChangeCopy.summary(file))
|
||||
.dsMetaText()
|
||||
}
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
Button(isStaged ? GitPanelCopy.unstageButton : GitPanelCopy.stageButton) {
|
||||
Task { await viewModel.setStaged(file, staged: !isStaged) }
|
||||
}
|
||||
.font(DS.Typography.caption.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||||
.disabled(viewModel.busy != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 提交 / 推送
|
||||
|
||||
@ViewBuilder private func commitSection(message: Binding<String>) -> some View {
|
||||
Section(GitPanelCopy.commitSection) {
|
||||
TextField(GitPanelCopy.commitPlaceholder, text: message, axis: .vertical)
|
||||
.lineLimit(Metrics.commitEditorLines)
|
||||
.font(DS.Typography.body)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button {
|
||||
Task { await viewModel.commit() }
|
||||
} label: {
|
||||
Label(GitPanelCopy.commitButton, systemImage: "checkmark.seal")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(!viewModel.canCommit)
|
||||
.listRowInsets(Self.buttonRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
Button {
|
||||
Task { await viewModel.push() }
|
||||
} label: {
|
||||
Label(GitPanelCopy.pushButton, systemImage: "arrow.up.circle")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(viewModel.busy != nil)
|
||||
.listRowInsets(Self.buttonRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PR / CI
|
||||
|
||||
@ViewBuilder private var pullRequestSection: some View {
|
||||
if let pr = viewModel.pr {
|
||||
Section(GitPanelCopy.prSection) {
|
||||
PrStatusRow(status: pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 最近提交
|
||||
|
||||
@ViewBuilder private var logSection: some View {
|
||||
Section(GitPanelCopy.logSection) {
|
||||
if let message = viewModel.logErrorMessage {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
if let log = viewModel.log {
|
||||
if log.commits.isEmpty {
|
||||
Text(GitPanelCopy.noCommits)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
commitRows(log)
|
||||
if log.truncated {
|
||||
Text(GitPanelCopy.truncatedLog)
|
||||
.dsMetaText()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func commitRows(_ log: GitLogResult) -> some View {
|
||||
let boundary = GitLogBoundary.index(commits: log.commits, upstream: log.upstream)
|
||||
ForEach(Array(log.commits.enumerated()), id: \.element.hash) { index, commit in
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
commitRow(commit)
|
||||
// 未推送边界只画一次,且只在最后一个 unpushed 之后(w6/G4)。
|
||||
if index == boundary, let upstream = log.upstream {
|
||||
UnpushedBoundary(upstream: upstream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func commitRow(_ commit: CommitLogEntry) -> some View {
|
||||
HStack(alignment: .top, spacing: DS.Space.sm8) {
|
||||
Text(verbatim: String(commit.hash.prefix(Metrics.shortHashLength)))
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
// 提交主题是服务器字节 → verbatim,两行上限。
|
||||
Text(verbatim: commit.subject)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
Text(GitTimeFormat.relative(fromMs: Double(commit.at), nowMs: Date().timeIntervalSince1970 * 1_000))
|
||||
.dsMetaText()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if commit.unpushed == true {
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.accessibilityLabel(GitChangeCopy.unpushedLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 文案(改动行摘要)
|
||||
|
||||
enum GitChangeCopy {
|
||||
static let unpushedLabel = "未推送"
|
||||
|
||||
static func summary(_ file: GitPanelViewModel.ChangedFile) -> String {
|
||||
"\(statusLabel(file.status)) · +\(file.added) −\(file.removed)"
|
||||
}
|
||||
|
||||
static func statusLabel(_ status: DiffFileStatus) -> String {
|
||||
switch status {
|
||||
case .modified: return "修改"
|
||||
case .added: return "新增"
|
||||
case .deleted: return "删除"
|
||||
case .renamed: return "重命名"
|
||||
case .binary: return "二进制"
|
||||
case .untracked: return "未跟踪"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user