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:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

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