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) -> 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 "未跟踪" } } }