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

View File

@@ -0,0 +1,359 @@
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)"
}
}

View File

@@ -0,0 +1,84 @@
import HostRegistry
/// User-facing pairing copy (plan §3.4 taxonomy actionable wording; §5.2
/// Local-Network guidance including the iOS 18 restart caveat).
///
/// Extracted from `PairingViewModel.swift` by C1: the VM grew the access-token
/// and host-management flows, and copy is presentation, not state machine
/// (plan §4 file-size discipline many small focused files).
///
/// SECURITY (§5.3): no function here ever takes or echoes a token value. The
/// shape-rejection copy is derived from `AccessTokenError`, which deliberately
/// carries a length at most never the rejected secret.
enum PairingCopy {
static let scanRejected =
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
static let manualRejected =
"无法解析这个地址。请输入完整 URL例如 http://192.168.1.5:3000"
static let storeFailed =
"主机已通过验证,但保存到本机失败,请重试。"
static let localNetworkDenied =
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
+ "iOS 18 存在需要重启手机才生效的已知问题)。"
static let notWebTerminal =
"对方在响应 HTTP但不是 web-terminal——端口对吗"
static let tlsFailure =
"TLS 连接失败:证书无效或不受信任。"
static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。"
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
static let deviceCertRequired =
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
static let clientCertRejected =
"本设备证书无效或已吊销,请重新导入。"
// MARK: - Access token (C1 · ios-completion §1.1)
/// 401 from `POST /auth` the token itself is wrong. Never echoes it.
static let tokenInvalid =
"访问令牌不正确。请到主机上核对 WEBTERM_TOKEN 的值后重新输入。"
/// 429 the server allows 10 `/auth` attempts per minute per IP.
static let tokenRateLimited =
"尝试次数过多(主机限制每分钟 10 次),请稍后再试。"
/// 204 WITHOUT `Set-Cookie`: this host never enabled auth, so there is
/// nothing to authenticate against and nothing gets saved. The pairing
/// failure therefore has another cause almost always `ALLOWED_ORIGINS`.
static let tokenNotRequired =
"该主机没有启用访问令牌(服务器未设置 WEBTERM_TOKEN令牌不会被保存。"
+ "配对失败的原因更可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
/// Defensive: a shape violation that slipped past the field validation.
static let tokenShapeUnknown =
"访问令牌格式不符合要求16512 位,且只能包含 A-Z a-z 0-9 . _ ~ + / = -)。"
static let hostsLoadFailed =
"无法读取已配对的主机列表(钥匙串读取失败)。"
static let hostRemoveFailed =
"移除主机失败(钥匙串写入失败),请重试。"
/// Turn the typed `AccessTokenError` into field-level copy. The length cases
/// report the length only that is not secret, and it is exactly what tells
/// the user whether they pasted a truncated value.
static func tokenShapeRejected(_ error: AccessTokenError) -> String {
switch error {
case .tooShort(let length):
return "访问令牌太短(\(length) 位,至少 \(AccessToken.minLength) 位)。"
case .tooLong(let length):
return "访问令牌太长(\(length) 位,最多 \(AccessToken.maxLength) 位)。"
case .invalidCharacters:
return "访问令牌含有不允许的字符(只能是 A-Z a-z 0-9 . _ ~ + / = -)。"
case .unknownHost:
return hostsLoadFailed
}
}
static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)"
}
/// §3.4 wording for the ATS cleartext block.
static func atsBlocked(host: String) -> String {
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
+ "请改用 https / tailscale serve或反馈该网段。"
}
}

View File

@@ -18,6 +18,12 @@ struct PairingScreen: View {
@State private var manualURLText = ""
@State private var isShowingScanner = false
@State private var scannerError: String?
/// C1 · Typed access token. Lives in `SecureField` view state only for as
/// long as the prompt is up, and is cleared the moment it is submitted or
/// abandoned the persisted copy belongs in the Keychain, nowhere else.
@State private var accessTokenText = ""
/// C1 · Host pending the "" confirmation dialog (nil = none).
@State private var hostPendingRemoval: HostRegistry.Host?
var body: some View {
content
@@ -37,47 +43,162 @@ struct PairingScreen: View {
probingView(pending)
case .failed(let pending, let failure):
FailureView(pending: pending, failure: failure, viewModel: viewModel)
case .awaitingToken(let pending):
tokenPrompt(pending, isValidating: false)
case .validatingToken(let pending):
tokenPrompt(pending, isValidating: true)
case .paired(let host):
pairedView(host)
}
}
private var idleView: some View {
// MARK: - Access-token prompt (C1 · §1.1)
/// The host answered 401. `SecureField` (never a plain `TextField`), no
/// autocorrect/autocapitalisation a token is not prose and the inline
/// rejection comes from the VM, which never echoes the value back.
private func tokenPrompt(
_ pending: PairingViewModel.PendingHost, isValidating: Bool
) -> some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
VStack(spacing: DS.Space.md12) { // inviting hero
Image(systemName: "desktopcomputer")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
.padding(.top, DS.Space.sm8)
Text(ScreenCopy.heroTitle)
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.textPrimary)
Text(ScreenCopy.heroSubtitle)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
tokenFieldCard(pending, isValidating: isValidating)
if let rejection = viewModel.tokenRejection {
Label(rejection, systemImage: "exclamationmark.circle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity)
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.manualSectionTitle)
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.font(DS.Typography.mono())
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.onSubmit { viewModel.submitManualURL(manualURLText) }
.accessibilityIdentifier("pairing.urlField")
Divider()
Button(ScreenCopy.manualSubmit) {
DS.Haptics.selection()
viewModel.submitManualURL(manualURLText)
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.submitButton")
VStack(spacing: DS.Space.md12) {
if isValidating {
ProgressView()
}
Button(ScreenCopy.tokenSubmit) { submitAccessToken() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(isValidating || accessTokenText.isEmpty)
.accessibilityIdentifier("pairing.tokenSubmit")
Button(ScreenCopy.cancel) {
accessTokenText = ""
viewModel.cancelAccessTokenEntry()
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(isValidating)
}
}
.padding(DS.Space.lg16)
}
}
private func tokenFieldCard(
_ pending: PairingViewModel.PendingHost, isValidating: Bool
) -> some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.tokenSectionTitle)
Text(pending.displayAddress)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
Text(ScreenCopy.tokenHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
Divider()
SecureField(ScreenCopy.tokenPlaceholder, text: $accessTokenText)
.font(DS.Typography.mono())
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.disabled(isValidating)
.onSubmit { submitAccessToken() }
.accessibilityIdentifier("pairing.tokenField")
}
}
}
/// Hand the token to the VM and drop the view's copy immediately.
private func submitAccessToken() {
let token = accessTokenText
accessTokenText = ""
Task { await viewModel.submitAccessToken(token) }
}
private var idleView: some View {
idleContent
.sheet(isPresented: $isShowingScanner) { scannerSheet }
.task { await viewModel.loadPairedHosts() }
.confirmationDialog(
ScreenCopy.removeConfirmTitle,
isPresented: removalDialogBinding,
titleVisibility: .visible,
presenting: hostPendingRemoval
) { host in
removalDialogActions(host)
} message: { _ in
Text(ScreenCopy.removeConfirmMessage)
}
}
/// Presented iff a host is staged for removal; dismissal clears the staging.
private var removalDialogBinding: Binding<Bool> {
Binding(
get: { hostPendingRemoval != nil },
set: { presented in if !presented { hostPendingRemoval = nil } }
)
}
@ViewBuilder private func removalDialogActions(
_ host: HostRegistry.Host
) -> some View {
Button(ScreenCopy.removeConfirm(host.name), role: .destructive) {
hostPendingRemoval = nil
Task { await viewModel.removeHost(id: host.id) }
}
Button(ScreenCopy.cancel, role: .cancel) { hostPendingRemoval = nil }
}
private var hero: some View {
VStack(spacing: DS.Space.md12) { // inviting hero
Image(systemName: "desktopcomputer")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
.padding(.top, DS.Space.sm8)
Text(ScreenCopy.heroTitle)
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.textPrimary)
Text(ScreenCopy.heroSubtitle)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var manualEntryCard: some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.manualSectionTitle)
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.font(DS.Typography.mono())
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.onSubmit { viewModel.submitManualURL(manualURLText) }
.accessibilityIdentifier("pairing.urlField")
Divider()
Button(ScreenCopy.manualSubmit) {
DS.Haptics.selection()
viewModel.submitManualURL(manualURLText)
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.submitButton")
}
}
}
private var idleContent: some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
hero
manualEntryCard
if let rejection = viewModel.inputRejection {
Label(rejection, systemImage: "exclamationmark.circle.fill")
.font(DS.Typography.caption)
@@ -97,10 +218,72 @@ struct PairingScreen: View {
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
pairedHostsSection
}
.padding(DS.Space.lg16)
}
.sheet(isPresented: $isShowingScanner) { scannerSheet }
}
// MARK: - Paired hosts (C1 · remove + "re-pair to add/update the token")
/// The app's host-management surface: re-pairing the same address updates
/// that host in place (that is how an existing host gains an access token
/// after the server enabled `WEBTERM_TOKEN`), and deletes the record
/// which takes its stored token with it and de-registers its APNs token.
@ViewBuilder private var pairedHostsSection: some View {
if !viewModel.pairedHosts.isEmpty || viewModel.hostsErrorMessage != nil {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.pairedSectionTitle)
if let message = viewModel.hostsErrorMessage {
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
}
ForEach(viewModel.pairedHosts) { host in
pairedHostRow(host)
if host.id != viewModel.pairedHosts.last?.id {
Divider()
}
}
Text(ScreenCopy.pairedSectionHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
}
private func pairedHostRow(_ host: HostRegistry.Host) -> some View {
HStack(spacing: DS.Space.md12) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(verbatim: host.name)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Text(host.endpoint.originHeader)
.dsMetaText()
.lineLimit(1)
.truncationMode(.middle)
// PRESENCE only a token value never reaches the screen.
if host.accessToken != nil {
Label(ScreenCopy.tokenStored, systemImage: "key.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.accent)
}
}
Spacer(minLength: 0)
Button(role: .destructive) {
hostPendingRemoval = host
} label: {
Label(ScreenCopy.remove, systemImage: "trash")
.labelStyle(.iconOnly)
}
.buttonStyle(.plain)
.foregroundStyle(DS.Palette.statusStuck)
.accessibilityLabel(ScreenCopy.removeAccessibility(host.name))
.accessibilityIdentifier("pairing.removeHost")
}
}
@ViewBuilder private var scannerSheet: some View {
@@ -277,12 +460,22 @@ private struct FailureView: View {
}
VStack(spacing: DS.Space.md12) {
let needsSettings = failure.action == .openLocalNetworkSettings
// C1 · 401 is ambiguous (token OR Origin), so BOTH remedies
// stay on screen: the token prompt leads, retry stays put.
let needsToken = failure.action == .enterAccessToken
if needsSettings {
Button(ScreenCopy.openSettings) { openAppSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
if needsToken {
Button(ScreenCopy.enterToken) { viewModel.beginAccessTokenEntry() }
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.enterTokenButton")
}
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
.buttonStyle(DSButtonStyle(
kind: (needsSettings || needsToken) ? .secondary : .primary
))
Button(ScreenCopy.back) { viewModel.cancel() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
@@ -391,6 +584,24 @@ private enum ScreenCopy {
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
static let publicAcknowledge = "我已了解风险,仍要连接"
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
// C1 · access token + host management
static let enterToken = "输入访问令牌"
static let tokenSectionTitle = "输入访问令牌"
static let tokenPlaceholder = "WEBTERM_TOKEN"
static let tokenHint =
"这台主机设置了 WEBTERM_TOKEN。令牌只会保存在本机钥匙串里绝不出现在网址或日志中。"
static let tokenSubmit = "验证并保存"
static let tokenStored = "已保存访问令牌"
static let pairedSectionTitle = "已配对主机"
static let pairedSectionHint =
"想给已配对的主机补/换访问令牌:在上面重新输入同一个地址配对一次即可(不会重复添加)。"
static let remove = "移除"
static let removeConfirmTitle = "移除这台主机?"
static let removeConfirmMessage =
"会同时删除本机保存的访问令牌,并在该主机上注销本设备的推送。主机上正在跑的会话不受影响。"
static func removeConfirm(_ name: String) -> String { "移除「\(name)" }
static func removeAccessibility(_ name: String) -> String { "移除主机 \(name)" }
static func probing(_ address: String) -> String { "正在验证 \(address)" }
static func paired(_ name: String) -> String { "已配对:\(name)" }

View File

@@ -5,6 +5,14 @@ import WireProtocol
/// T-iOS-26 · sessions/worktrees/CLAUDE.md + diff
/// T-iOS-27 `DiffScreen(endpoint:path:http:)`+ ""
///
/// C2 web v0.6 / Android
/// - ****`GitSyncBand` / / HEAD /
/// git ""
/// - **Git **stage/commit/push/fetch + `git log` + PR/CI
/// - **worktree **T-iOS-32 / /
///
/// - **`claude --resume` **
///
/// ///CLAUDE.md ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
struct ProjectDetailScreen: View {
@@ -12,7 +20,30 @@ struct ProjectDetailScreen: View {
private let endpoint: HostEndpoint
private let http: any HTTPTransport
private let onOpenClaude: (String) -> Void
@State private var isDiffPresented = false
private let onResumeClaude: (String, String) -> Void
/// worktree prune / removecreate sheet
///
@State private var worktreeActions: WorktreeViewModel
@State private var route: DetailRoute?
@State private var sheet: DetailSheet?
@State private var worktreePendingRemoval: WorktreeInfo?
@State private var isPruneConfirmPresented = false
/// destination `isPresented:`
private enum DetailRoute: Hashable, Identifiable {
case diff
case gitPanel
var id: Self { self }
}
private enum DetailSheet: Hashable, Identifiable {
case newWorktree
case resumeHistory
var id: Self { self }
}
private enum Metrics {
/// CLAUDE.md token
@@ -23,12 +54,17 @@ struct ProjectDetailScreen: View {
viewModel: ProjectDetailViewModel,
endpoint: HostEndpoint,
http: any HTTPTransport,
onOpenClaude: @escaping (String) -> Void
onOpenClaude: @escaping (String) -> Void,
onResumeClaude: @escaping (String, String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.endpoint = endpoint
self.http = http
self.onOpenClaude = onOpenClaude
self.onResumeClaude = onResumeClaude
_worktreeActions = State(initialValue: .forProject(
endpoint: endpoint, http: http, path: viewModel.path
))
}
var body: some View {
@@ -36,14 +72,118 @@ struct ProjectDetailScreen: View {
.navigationTitle(ProjectDetailCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() }
.navigationDestination(isPresented: $isDiffPresented) {
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
.navigationDestination(item: $route) { destination(for: $0) }
.sheet(item: $sheet) { presentedSheet(for: $0) }
}
/// worktree prune remove
/// 409 `content` `body`
/// `body`
@ViewBuilder private var content: some View {
phaseContent
.confirmationDialog(
WorktreeCopy.pruneConfirmTitle,
isPresented: $isPruneConfirmPresented,
titleVisibility: .visible
) {
Button(WorktreeCopy.pruneConfirm, role: .destructive) {
Task {
await worktreeActions.prune()
await viewModel.load()
}
}
} message: {
Text(WorktreeCopy.pruneConfirmMessage)
}
.confirmationDialog(
WorktreeCopy.removeConfirmTitle,
isPresented: removalDialogBinding,
titleVisibility: .visible,
presenting: worktreePendingRemoval
) { worktree in
Button(WorktreeCopy.removeConfirm, role: .destructive) {
remove(worktree)
}
} message: { worktree in
Text(verbatim: WorktreeCopy.removeConfirmMessage(worktree.path))
}
.confirmationDialog(
WorktreeCopy.forceConfirmTitle,
isPresented: forceDialogBinding,
titleVisibility: .visible
) {
// 409****
Button(WorktreeCopy.forceConfirm, role: .destructive) {
Task {
await worktreeActions.confirmForceRemove()
await viewModel.load()
}
}
Button(WorktreeCopy.cancel, role: .cancel) {
worktreeActions.cancelForceRemove()
}
} message: {
Text(WorktreeCopy.forceConfirmMessage)
}
}
@ViewBuilder private func destination(for route: DetailRoute) -> some View {
switch route {
case .diff:
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
case .gitPanel:
GitPanelScreen(endpoint: endpoint, http: http, path: viewModel.path)
}
}
@ViewBuilder private func presentedSheet(for sheet: DetailSheet) -> some View {
switch sheet {
case .newWorktree:
WorktreeSheet(
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
onCreated: { Task { await viewModel.load() } },
onOpenSession: onOpenClaude
)
case .resumeHistory:
ResumeHistorySheet(
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
onResume: onResumeClaude
)
}
}
/// worktree
private var removalDialogBinding: Binding<Bool> {
Binding(
get: { worktreePendingRemoval != nil },
set: { presented in if !presented { worktreePendingRemoval = nil } }
)
}
/// 409 force
private var forceDialogBinding: Binding<Bool> {
Binding(
get: {
if case .forceConfirming = worktreeActions.removePhase { return true }
return false
},
set: { presented in if !presented { worktreeActions.cancelForceRemove() } }
)
}
private func remove(_ worktree: WorktreeInfo) {
Task {
await worktreeActions.remove(worktreePath: worktree.path)
if case .removed = worktreeActions.removePhase {
DS.Haptics.warning()
await viewModel.load()
}
}
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
@ViewBuilder private var phaseContent: some View {
switch viewModel.phase {
case .loading:
ProgressView()
@@ -93,8 +233,8 @@ struct ProjectDetailScreen: View {
List {
headerSection(detail)
actionsSection(detail)
sessionsSection(detail.sessions)
worktreesSection(detail.worktrees)
sessionsSection(detail)
worktreesSection(detail)
claudeMdSection(detail)
}
.listStyle(.insetGrouped)
@@ -127,6 +267,13 @@ struct ProjectDetailScreen: View {
DirtyBadge()
}
}
// w6/G3 git
if let band = GitSyncBand.make(
sync: detail.sync, dirtyCount: detail.dirtyCount,
nowMs: Date().timeIntervalSince1970 * 1_000
) {
SyncSummaryChips(band: band)
}
}
}
}
@@ -146,32 +293,53 @@ struct ProjectDetailScreen: View {
))
.listRowBackground(Color.clear)
if detail.isGit {
Button {
isDiffPresented = true
} label: {
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
secondaryAction(GitPanelCopy.entryLabel, symbol: "point.3.filled.connected.trianglepath.dotted") {
route = .gitPanel
}
secondaryAction(ProjectDetailCopy.viewDiff, symbol: "plus.forwardslash.minus") {
route = .diff
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.sm8, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
}
}
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
private func secondaryAction(
_ title: String, symbol: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: symbol)
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
@ViewBuilder private func sessionsSection(_ detail: ProjectDetail) -> some View {
Section(ProjectDetailCopy.sessionsHeader) {
if sessions.isEmpty {
if detail.sessions.isEmpty {
Text(ProjectDetailCopy.noSessions)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(sessions, id: \.id) { session in
ForEach(detail.sessions, id: \.id) { session in
sessionRow(session)
}
}
// `claude --resume <id>`T-iOS-32
Button {
sheet = .resumeHistory
} label: {
Label(ResumeCopy.entryLabel, systemImage: "clock.arrow.circlepath")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
}
@@ -199,16 +367,75 @@ struct ProjectDetailScreen: View {
}
}
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
if !worktrees.isEmpty {
Section(ProjectDetailCopy.worktreesHeader) {
ForEach(worktrees, id: \.path) { worktree in
// MARK: - WorktreesT-iOS-32 + + +
/// git worktree """"
/// w6/G5
@ViewBuilder private func worktreesSection(_ detail: ProjectDetail) -> some View {
if detail.isGit {
Section {
ForEach(detail.worktrees, id: \.path) { worktree in
worktreeRow(worktree)
}
Button {
sheet = .newWorktree
} label: {
Label(WorktreeCopy.sheetTitle, systemImage: "plus.rectangle.on.folder")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
worktreeFeedback
} header: {
worktreeHeader(detail)
} footer: {
if detail.worktrees.contains(where: WorktreeViewModel.canRemove) {
Text(ProjectDetailCopy.worktreeSwipeHint)
}
}
}
}
private func worktreeHeader(_ detail: ProjectDetail) -> some View {
HStack {
Text(ProjectDetailCopy.worktreesHeader)
Spacer(minLength: DS.Space.sm8)
// prunable
if detail.worktrees.contains(where: { $0.prunable == true }) {
Button(WorktreeCopy.pruneButton) {
isPruneConfirmPresented = true
}
.font(DS.Typography.caption.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(minHeight: DS.Layout.minHitTarget)
.disabled(worktreeActions.isBusy)
}
}
}
/// prune / remove
@ViewBuilder private var worktreeFeedback: some View {
switch worktreeActions.prunePhase {
case .done(let message):
InlineMessage(text: message, tone: .success)
case .failed(let message):
InlineMessage(text: message, tone: .error)
case .idle, .pruning:
EmptyView()
}
switch worktreeActions.removePhase {
case .removed(let path):
InlineMessage(text: WorktreeCopy.removed(path), tone: .success)
case .failed(let message):
InlineMessage(text: message, tone: .error)
case .idle, .removing, .forceConfirming:
EmptyView()
}
}
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
HStack(spacing: DS.Space.sm8) {
@@ -225,12 +452,28 @@ struct ProjectDetailScreen: View {
if worktree.locked == true {
TagBadge(text: ProjectDetailCopy.worktreeLocked)
}
if worktree.prunable == true {
TagBadge(text: ProjectDetailCopy.worktreePrunable)
}
}
Text(verbatim: worktree.path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
if worktree.locked == true {
Text(WorktreeCopy.lockedHint)
.dsMetaText()
}
}
.swipeActions(edge: .trailing) {
// worktree 400/409
if WorktreeViewModel.canRemove(worktree) {
Button(WorktreeCopy.removeButton, role: .destructive) {
worktreePendingRemoval = worktree
}
.accessibilityLabel(WorktreeCopy.removeAccessibilityLabel)
}
}
}
@@ -268,7 +511,7 @@ struct DirtyBadge: View {
}
}
/// worktree //accent
/// worktree ///accent
struct TagBadge: View {
let text: String
@@ -297,6 +540,8 @@ enum ProjectDetailCopy {
static let worktreeMain = ""
static let worktreeCurrent = "当前"
static let worktreeLocked = "已锁定"
static let worktreePrunable = "可回收"
static let worktreeSwipeHint = "左滑一行可删除该 worktree主 worktree 与已锁定的不可删)。"
static let detachedHead = "(分离 HEAD"
static let claudeMdHeader = "CLAUDE.md"
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"

View File

@@ -39,7 +39,11 @@ struct ProjectsScreen: View {
viewModel: viewModel.makeDetailViewModel(path: route.path),
endpoint: viewModel.host.endpoint,
http: viewModel.http,
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) },
// T-iOS-32C2
onResumeClaude: { cwd, sessionId in
viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId)
}
)
}
// T-iPad-4 · iPhone sheet iPad

View File

@@ -0,0 +1,116 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-32 · `claude --resume <id>` `GET /sessions`
///
/// cwd cwd
/// resume ** cwd**
/// `claude --resume <id>\r`worktree worktree
///
/// id/cwd/preview `Text(verbatim:)`
/// id `ProjectResumeCommand` ****西
/// PTY
struct ResumeHistorySheet: View {
@State private var viewModel: ResumeHistoryViewModel
/// (cwd, sessionId) ""
let onResume: (String, String) -> Void
@Environment(\.dismiss) private var dismiss
private enum Metrics {
/// 120
static let previewLineLimit = 2
}
init(viewModel: ResumeHistoryViewModel, onResume: @escaping (String, String) -> Void) {
_viewModel = State(initialValue: viewModel)
self.onResume = onResume
}
var body: some View {
NavigationStack {
content
.navigationTitle(ResumeCopy.sheetTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(WorktreeCopy.cancel) { dismiss() }
}
}
.task { await viewModel.load() }
}
}
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .empty:
ContentUnavailableView {
Label(ResumeCopy.emptyTitle, systemImage: "clock.arrow.circlepath")
} description: {
Text(ResumeCopy.emptyDetail)
}
case .failed(let message):
ContentUnavailableView {
Label(ResumeCopy.loadFailed, systemImage: "exclamationmark.triangle")
} description: {
Text(verbatim: message)
} actions: {
Button(ResumeCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
.tint(DS.Palette.accent)
}
case .loaded(let candidates):
List(candidates) { candidate in
row(candidate)
}
.listStyle(.insetGrouped)
}
}
private func row(_ candidate: ResumeHistoryViewModel.ResumeCandidate) -> some View {
HStack(spacing: DS.Space.sm8) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
// verbatim
Text(verbatim: candidate.session.preview.isEmpty
? ResumeCopy.noPreview : candidate.session.preview)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(Metrics.previewLineLimit)
Text(verbatim: candidate.cwd)
.font(DS.Typography.mono(.caption2))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Text(GitTimeFormat.relative(
fromMs: candidate.session.mtimeMs,
nowMs: Date().timeIntervalSince1970 * 1_000
))
.dsMetaText()
if !candidate.canResume {
Text(ResumeCopy.notResumable)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
}
}
Spacer(minLength: DS.Space.sm8)
if candidate.canResume {
Button(ResumeCopy.resume) {
DS.Haptics.selection()
onResume(candidate.cwd, candidate.session.id)
dismiss()
}
.font(DS.Typography.body.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
.accessibilityLabel(
ResumeCopy.resumeAccessibilityLabel(candidate.session.project)
)
}
}
}
}

View File

@@ -224,6 +224,17 @@ struct SessionListScreen: View {
} label: {
Label(ScreenCopy.addHost, systemImage: "plus")
}
// C1 · Same sheet as `PairingScreen` is the host
// management surface (access token + ), and it already
// receives the real `HostStore` through its VM. A second entry
// with the management label is what makes those two actions
// discoverable without a second navigation path to maintain.
Button {
onAddHost()
} label: {
Label(ScreenCopy.manageHosts, systemImage: "key")
}
.accessibilityIdentifier("sessions.manageHosts")
Button {
onEnroll()
} label: {
@@ -365,6 +376,8 @@ private enum ScreenCopy {
static let newSession = "新建会话"
static let kill = "结束"
static let addHost = "配对新主机"
/// C1 · Access token + (same sheet as `addHost` see the menu).
static let manageHosts = "管理主机与令牌"
static let deviceCert = "设备证书"
static let enroll = "自动获取证书"
static let hostMenuFallback = "主机"

View File

@@ -0,0 +1,122 @@
import SwiftUI
/// T-iOS-34 · **** / /
///
/// Dynamic Type`TerminalTheme`
/// `.footnote` /
///
///
/// `ThemeStore.select` + `@Observable`
/// `preferredColorScheme`
struct SettingsScreen: View {
/// `RootView`
let themeStore: ThemeStore
/// **** `preferredColorScheme`
///
@Environment(\.colorScheme) private var effectiveScheme
var body: some View {
List {
themeSection
terminalPreviewSection
}
.navigationTitle(SettingsCopy.title)
}
// MARK: -
private var themeSection: some View {
Section {
ForEach(AppTheme.allCases, id: \.self) { theme in
themeRow(theme)
}
} header: {
Text(SettingsCopy.themeHeader)
} footer: {
Text(SettingsCopy.themeFooter)
}
}
private func themeRow(_ theme: AppTheme) -> some View {
Button {
themeStore.select(theme)
DS.Haptics.selection()
} label: {
HStack(spacing: DS.Space.md12) {
Image(systemName: theme.symbolName)
.foregroundStyle(DS.Palette.accent)
.frame(width: DS.Space.xxl24)
Text(theme.label)
.foregroundStyle(DS.Palette.textPrimary)
Spacer(minLength: DS.Space.sm8)
if themeStore.theme == theme {
Image(systemName: "checkmark")
.foregroundStyle(DS.Palette.accent)
.fontWeight(.semibold)
}
}
.frame(minHeight: DS.Layout.minHitTarget)
}
.accessibilityIdentifier("settings.theme.\(theme.rawValue)")
// VoiceOver
.accessibilityAddTraits(themeStore.theme == theme ? [.isSelected] : [])
}
// MARK: -
///
/// // `TerminalPalette`
private var terminalPreviewSection: some View {
Section {
let colors = TerminalPalette.colors(for: effectiveScheme)
HStack(spacing: DS.Space.xs2) {
Text(verbatim: SettingsCopy.terminalSample)
.font(DS.Typography.mono(.footnote))
.foregroundStyle(Color(uiColor: colors.foreground))
Rectangle()
.fill(Color(uiColor: colors.caret))
.frame(width: DS.Space.sm8, height: DS.Space.lg16)
Spacer(minLength: 0)
}
.padding(DS.Space.md12)
.background(
Color(uiColor: colors.background),
in: RoundedRectangle(cornerRadius: DS.Radius.sm8)
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(SettingsCopy.terminalPreviewA11y)
} header: {
Text(SettingsCopy.terminalHeader)
}
}
}
///
enum SettingsCopy {
static let title = "设置"
static let themeHeader = "外观"
static let themeFooter = "默认深色 —— 与网页端一致。选「跟随系统」时随 iOS 的浅色/深色切换。"
static let terminalHeader = "终端预览"
static let terminalSample = "$ claude --resume"
static let terminalPreviewA11y = "终端配色预览"
}
// MARK: - Previews
#Preview("SettingsScreen") {
NavigationStack {
SettingsScreen(themeStore: ThemeStore(defaults: PreviewThemeDefaults()))
}
}
/// defaults `UserDefaults`
private final class PreviewThemeDefaults: ThemeDefaults {
private var values: [String: String] = [:]
func themeRaw(forKey key: String) -> String? { values[key] }
func setThemeRaw(_ value: String, forKey key: String) {
values = values.merging([key: value]) { _, new in new }
}
}

View File

@@ -37,6 +37,13 @@ struct TerminalScreen: View {
/// T-iPad-3 · KeyBar nil =
@State private var keyBarUserOverride: Bool?
/// T-iOS-33 · `makeUIView`
@State private var searchModel = TerminalSearchModel()
/// T-iOS-31 · PTT + epoch `init`
/// PTT //epoch `viewModel`
@State private var voiceEpochSource: VoiceEpochSource
@State private var voiceModel: VoicePTTViewModel
/// DS.Motion.gated
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -46,6 +53,33 @@ struct TerminalScreen: View {
static let hideKeyBar = "隐藏快捷键栏"
}
/// `init` init PTT
/// ViewModel `@State` `viewModel`
/// `sendInput` epoch sessionId/
///
/// SwiftUI **** `@State`
/// `TerminalContainerView` `.id(controller.generation)`
/// `controller.terminalViewModel`
init(
viewModel: TerminalViewModel,
onNewSessionInCwd: (@MainActor () -> Void)? = nil,
onKillSession: (@MainActor () -> Void)? = nil
) {
self.viewModel = viewModel
self.onNewSessionInCwd = onNewSessionInCwd
self.onKillSession = onKillSession
let epochSource = VoiceEpochSource()
_voiceEpochSource = State(initialValue: epochSource)
_voiceModel = State(initialValue: VoicePTTViewModel(
dictation: SpeechDictation(),
clock: ContinuousClock(),
epoch: { epochSource.epoch },
isReadOnly: { viewModel.isReadOnly },
inject: { text in viewModel.sendInput(text) }
))
}
/// KeyBar `KeyBarVisibility`
private var isKeyBarVisible: Bool {
KeyBarVisibility.isVisible(
@@ -58,6 +92,8 @@ struct TerminalScreen: View {
TerminalHostView(
viewModel: viewModel,
keyBarVisible: isKeyBarVisible,
searchModel: searchModel,
voiceModel: voiceModel,
onNewSessionInCwd: onNewSessionInCwd,
onKillSession: onKillSession
)
@@ -70,11 +106,37 @@ struct TerminalScreen: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
// T-iOS-33 · web `#searchbox`
// (`position:fixed; top; right`, style.css:812)
.overlay(alignment: .topTrailing) {
if searchModel.isPresented {
TerminalSearchBar(model: searchModel)
.padding(.horizontal, DS.Space.sm8)
.padding(.top, DS.Space.sm8)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
// T-iOS-31 · PTT 🎤
.overlay(alignment: .bottom) {
VoicePTTBanner(model: voiceModel)
.padding(.horizontal, DS.Space.md12)
.padding(.bottom, DS.Space.xl20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: viewModel.bannerModel
)
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: searchModel.isPresented
)
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: voiceModel.phase
)
.toolbar {
searchToolbarItem
newSessionToolbarItem
keyBarToggleToolbarItem
}
@@ -84,7 +146,34 @@ struct TerminalScreen: View {
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
hasHardwareKeyboard = GCKeyboard.coalesced != nil
}
.onAppear { viewModel.start() }
// T-iOS-31 · epoch
.onChange(of: viewModel.sessionId) { _, id in voiceEpochSource.noteSession(id) }
.onChange(of: viewModel.banner) { _, banner in voiceEpochSource.noteBanner(banner) }
.onAppear {
viewModel.start()
voiceEpochSource.noteSession(viewModel.sessionId)
}
}
/// T-iOS-33 · web toolbar 🔍/
@ToolbarContentBuilder private var searchToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
if searchModel.isPresented {
searchModel.dismiss()
} else {
searchModel.present()
}
} label: {
Label(
searchModel.isPresented
? TerminalSearchModel.Copy.close : TerminalSearchModel.Copy.open,
systemImage: searchModel.isPresented ? "magnifyingglass.circle.fill"
: "magnifyingglass"
)
}
.accessibilityIdentifier("terminal.searchToggleButton")
}
}
/// T-iPad-3 · KeyBar
@@ -155,6 +244,10 @@ private struct TerminalHostView: UIViewRepresentable {
/// T-iPad-3 · KeyBar (`inputAccessoryView`) `KeyBarVisibility`
/// SwiftUI `updateUIView`
let keyBarVisible: Bool
/// T-iOS-33 · `makeUIView` SwiftTerm
let searchModel: TerminalSearchModel
/// T-iOS-31 · PTT 🎤 /
let voiceModel: VoicePTTViewModel
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
var onKillSession: (@MainActor () -> Void)? = nil
@@ -170,7 +263,15 @@ private struct TerminalHostView: UIViewRepresentable {
let viewModel = viewModel
terminal.onKeyCommand = { key in viewModel.send(key: key) }
let keyBar = KeyBarView()
// T-iOS-33 · SwiftTerm PTY
searchModel.attach(terminal)
// T-iOS-31 · 🎤 KeyByteMap
let voiceModel = voiceModel
let keyBar = KeyBarView(voice: KeyBarVoiceHandlers(
onDown: { Task { await voiceModel.pressDown() } },
onUp: { Task { await voiceModel.pressUp() } }
))
keyBar.onKey = { key in viewModel.send(key: key) }
terminal.installKeyBar(keyBar, visible: keyBarVisible)
@@ -323,11 +424,15 @@ final class KeyCommandTerminalView: TerminalView {
addInteraction(UIContextMenuInteraction(delegate: delegate))
}
/// Whether a selection exists reuses SwiftTerm's own `copy` eligibility
/// (`canPerformAction` returns `selection.active`); pure read, no bytes.
var hasActiveSelection: Bool {
canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil)
}
// NOTE (T-iOS-33/31 root fix): the "does a selection exist" read used to be
// a LOCAL `hasActiveSelection` here, computed via SwiftTerm's own `copy`
// eligibility (`canPerformAction` answers from `selection.active`). SwiftTerm
// v1.14.0 promoted the very same thing to `public var hasActiveSelection`
// (`selection?.active ?? false`) on its own view, which then COLLIDED with
// the local one and pinned the dependency at 1.13.0 (`override` cannot fix a
// `public`-but-not-`open` property). The local copy is therefore gone and
// every caller the pointer context menu, the find-bar tests now reads
// upstream's property, so the pin rides at 1.15.0. Do not reintroduce it.
/// Copy the current selection via SwiftTerm's own `copy(_:)` (selection
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
@@ -336,3 +441,23 @@ final class KeyCommandTerminalView: TerminalView {
copy(nil)
}
}
// MARK: - Terminal search binding (T-iOS-33)
/// The find bar's engine is SwiftTerm's own scrollback search
/// (`TerminalViewSearch.swift`): `findNext`/`findPrevious` select + scroll the
/// match (that selection IS the highlight) and `clearSearch` drops both. Pure
/// read no PTY byte is produced, so search also works on an exited session.
extension KeyCommandTerminalView: TerminalSearching {
func searchNext(term: String) -> Bool {
findNext(term)
}
func searchPrevious(term: String) -> Bool {
findPrevious(term)
}
func searchClear() {
clearSearch()
}
}

View File

@@ -0,0 +1,131 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-32 · Worktree`POST /projects/worktree`G
///
/// + ref = HEAD
/// canonical /" Worktree "
/// "spin up a worktree, land the winner, delete the rest"
///
/// `WorktreeBranchRule`//
/// `Text(verbatim:)`
struct WorktreeSheet: View {
@State private var viewModel: WorktreeViewModel
/// worktree
let onCreated: () -> Void
/// " worktree " canonical
let onOpenSession: (String) -> Void
@Environment(\.dismiss) private var dismiss
init(
viewModel: WorktreeViewModel,
onCreated: @escaping () -> Void,
onOpenSession: @escaping (String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.onCreated = onCreated
self.onOpenSession = onOpenSession
}
var body: some View {
@Bindable var bindable = viewModel
return NavigationStack {
Form {
if case .created(let path, let branch) = viewModel.createPhase {
createdSection(path: path, branch: branch)
} else {
formSection(
branch: $bindable.branchText, base: $bindable.baseText
)
}
}
.navigationTitle(WorktreeCopy.sheetTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(WorktreeCopy.cancel) { dismiss() }
}
}
}
}
@ViewBuilder private func formSection(
branch: Binding<String>, base: Binding<String>
) -> some View {
Section {
TextField(WorktreeCopy.branchField, text: branch)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
.onSubmit { submit() }
TextField(WorktreeCopy.baseField, text: base)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} footer: {
if let message = viewModel.branchValidationMessage {
Text(message)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
}
}
Section {
Button {
submit()
} label: {
if viewModel.createPhase == .creating {
ProgressView()
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
} else {
Label(WorktreeCopy.submit, systemImage: "plus.rectangle.on.folder")
}
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(!viewModel.canSubmitCreate)
.listRowBackground(Color.clear)
if case .failed(let message) = viewModel.createPhase {
InlineMessage(text: message, tone: .error)
}
}
}
@ViewBuilder private func createdSection(path: String, branch: String) -> some View {
Section(WorktreeCopy.createdTitle) {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
Text(verbatim: branch)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(2)
.truncationMode(.middle)
}
}
Section {
Button {
DS.Haptics.selection()
onOpenSession(path)
dismiss()
} label: {
Label(WorktreeCopy.openInNewWorktree, systemImage: "terminal.fill")
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(path.isEmpty)
.listRowBackground(Color.clear)
Button(WorktreeCopy.cancel) { dismiss() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowBackground(Color.clear)
}
}
private func submit() {
Task {
await viewModel.create()
if case .created = viewModel.createPhase {
DS.Haptics.success()
onCreated() // worktree
}
}
}
}