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.
269 lines
10 KiB
Swift
269 lines
10 KiB
Swift
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 "未跟踪"
|
||
}
|
||
}
|
||
}
|