Files
web-terminal/ios/App/WebTerm/Screens/GitPanelViews.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

360 lines
13 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 SwiftUI
/// C2 · git `DS` token/
/// `Text(verbatim:)` `DS.Layout.minHitTarget`
// MARK: - w6/G3
/// HEAD/ · · +
///
/// **绿**`statusWorking`
/// // HEAD`statusWaiting`
/// 怀 web
struct GitSyncBandView: View {
let band: GitSyncBand
///
let branch: String?
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
headRow
if case .tracking(_, let ahead, let behind) = band.head {
countsRow(ahead: ahead, behind: behind)
}
if let footnote {
Text(footnote)
.font(DS.Typography.caption)
.foregroundStyle(
band.isInSync ? DS.Palette.textSecondary : DS.Palette.statusWaiting
)
.fixedSize(horizontal: false, vertical: true)
}
dirtyRow
}
.padding(.vertical, DS.Space.xs4)
}
@ViewBuilder private var headRow: some View {
HStack(spacing: DS.Space.sm8) {
if let branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
}
switch band.head {
case .detached:
SyncChip(text: GitPanelCopy.detachedHead, tone: .warning)
case .noUpstream:
SyncChip(text: GitPanelCopy.noUpstream, tone: .warning)
case .tracking(let upstream, _, _):
//
Text(verbatim: upstream)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
if band.isInSync {
SyncChip(text: GitPanelCopy.inSync, tone: .good)
}
}
Spacer(minLength: 0)
}
}
private func countsRow(ahead: Int?, behind: Int?) -> some View {
HStack(spacing: DS.Space.md12) {
countCell(caption: GitPanelCopy.toPushCaption, symbol: "arrow.up", value: ahead)
countCell(caption: GitPanelCopy.toPullCaption, symbol: "arrow.down", value: behind)
if !band.isBehindVerified {
SyncChip(text: GitPanelCopy.unverified, tone: .warning)
}
Spacer(minLength: 0)
}
}
/// nil ``"" 0
private func countCell(caption: String, symbol: String, value: Int?) -> some View {
HStack(spacing: DS.Space.xs4) {
Image(systemName: symbol)
.imageScale(.small)
Text(value.map(String.init) ?? GitPanelCopy.unknownCount)
.font(DS.Typography.mono(.callout))
Text(caption)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
.foregroundStyle(DS.Palette.textPrimary)
.accessibilityElement(children: .combine)
}
@ViewBuilder private var dirtyRow: some View {
switch band.dirty {
case .unknown:
Text(GitPanelCopy.dirtyUnknown)
.dsMetaText()
case .clean:
SyncChip(text: GitPanelCopy.clean, tone: .good)
case .changed(let count):
SyncChip(text: GitPanelCopy.dirtyCount(count), tone: .dirty)
}
}
/// """"
private var footnote: String? {
switch band.head {
case .detached:
return GitPanelCopy.detachedDetail
case .noUpstream:
return GitPanelCopy.noUpstreamDetail
case .tracking:
guard !band.isBehindVerified else { return nil }
return GitPanelCopy.staleFetch
}
}
}
/// ****""
///
/// `GitSyncBand` 绿
/// `nil` `` 0 ``
struct SyncSummaryChips: View {
let band: GitSyncBand
var body: some View {
HStack(spacing: DS.Space.xs4) {
switch band.head {
case .detached:
SyncChip(text: GitPanelCopy.detachedHead, tone: .warning)
case .noUpstream:
SyncChip(text: GitPanelCopy.noUpstream, tone: .warning)
case .tracking(_, let ahead, let behind):
if band.isInSync {
SyncChip(text: GitPanelCopy.inSync, tone: .good)
} else {
trackingChips(ahead: ahead, behind: behind)
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder private func trackingChips(ahead: Int?, behind: Int?) -> some View {
if let ahead, ahead > 0 {
SyncChip(text: "\(ahead)", tone: .neutral)
}
if let behind, behind > 0 {
SyncChip(text: "\(behind)", tone: .neutral)
}
if ahead == nil || behind == nil {
// 0
SyncChip(text: "↑↓ \(GitPanelCopy.unknownCount)", tone: .warning)
}
if !band.isBehindVerified {
SyncChip(text: GitPanelCopy.unverified, tone: .warning)
}
}
}
/// + `DirtyBadge`
struct SyncChip: View {
enum Tone {
case good
case warning
case dirty
case neutral
}
let text: String
var tone: Tone = .neutral
private var color: Color {
switch tone {
case .good: return DS.Palette.statusWorking
case .warning: return DS.Palette.statusWaiting
case .dirty: return DS.Palette.statusWaiting
case .neutral: return DS.Palette.textSecondary
}
}
var body: some View {
Text(text)
.font(DS.Typography.caption.weight(.medium))
.foregroundStyle(color)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.background(color.opacity(Self.fillOpacity), in: Capsule())
.lineLimit(1)
}
/// `DirtyBadge`
private static let fillOpacity: Double = 0.18
}
// MARK: - w6/G4
/// `git log` 线 `<upstream>`
struct UnpushedBoundary: View {
let upstream: String
var body: some View {
HStack(spacing: DS.Space.sm8) {
Rectangle()
.fill(DS.Palette.statusWaiting)
.frame(height: DS.Stroke.hairline)
// upstream verbatim
Text(verbatim: GitPanelCopy.unpushedBoundary(upstream))
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.lineLimit(1)
Rectangle()
.fill(DS.Palette.statusWaiting)
.frame(height: DS.Stroke.hairline)
}
.padding(.vertical, DS.Space.xs2)
}
}
// MARK: -
/// /verbatim
struct InlineMessage: View {
enum Tone {
case error
case success
case info
}
let text: String
var tone: Tone = .info
private var color: Color {
switch tone {
case .error: return DS.Palette.statusStuck
case .success: return DS.Palette.statusWorking
case .info: return DS.Palette.textSecondary
}
}
private var symbol: String {
switch tone {
case .error: return "exclamationmark.triangle"
case .success: return "checkmark.circle"
case .info: return "info.circle"
}
}
var body: some View {
HStack(alignment: .top, spacing: DS.Space.sm8) {
Image(systemName: symbol)
.foregroundStyle(color)
Text(verbatim: text)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - PR / CI w3-pr-ci-chip
/// PR + CI
///
/// gh // PR// 200 + `availability`
///
struct PrStatusRow: View {
let status: PrStatus
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
HStack(spacing: DS.Space.sm8) {
TelemetryChip(systemImage: "arrow.triangle.pull", text: headline)
if let checks = status.checks, checks.total > 0 {
TelemetryChip(
systemImage: checkSymbol(checks),
text: PrCopy.checks(checks),
isWarning: checks.failing > 0
)
}
Spacer(minLength: 0)
}
if let title = status.title, !title.isEmpty {
// PR GitHub verbatim
Text(verbatim: title)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(2)
}
if let url = PrLink.url(from: status.url) {
Link(destination: url) {
Label(PrCopy.openInBrowser, systemImage: "safari")
.font(DS.Typography.caption)
}
.frame(minHeight: DS.Layout.minHitTarget, alignment: .leading)
}
}
}
private var headline: String {
switch status.availability {
case .ok:
return PrCopy.number(status.number, state: status.state, isDraft: status.isDraft)
case .noPr: return PrCopy.noPr
case .notInstalled: return PrCopy.notInstalled
case .unauthenticated: return PrCopy.unauthenticated
case .disabled: return PrCopy.disabled
case .error: return PrCopy.error
}
}
private func checkSymbol(_ checks: PrCheckSummary) -> String {
if checks.failing > 0 { return "xmark.octagon" }
if checks.pending > 0 { return "clock" }
return "checkmark.seal"
}
}
/// PR URL **** `openURL`
/// App scheme https + github.com
enum PrLink {
private static let allowedScheme = "https"
private static let allowedHostSuffix = "github.com"
static func url(from raw: String?) -> URL? {
guard let raw, let url = URL(string: raw), url.scheme?.lowercased() == allowedScheme,
let host = url.host?.lowercased(),
host == allowedHostSuffix || host.hasSuffix("." + allowedHostSuffix)
else {
return nil
}
return url
}
}
enum PrCopy {
static let noPr = "无 PR"
static let notInstalled = "主机未安装 gh"
static let unauthenticated = "gh 未登录"
static let disabled = "PR 查询已关闭"
static let error = "PR 状态获取失败"
static let openInBrowser = "在浏览器打开"
static let draft = "草稿"
static func number(_ number: Int?, state: String?, isDraft: Bool?) -> String {
var text = number.map { "PR #\($0)" } ?? "PR"
if let state, !state.isEmpty { text += " · \(state)" }
if isDraft == true { text += " · \(draft)" }
return text
}
static func checks(_ checks: PrCheckSummary) -> String {
"\(checks.passing)\(checks.failing)\(checks.pending)"
}
}