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:
268
ios/App/WebTerm/Screens/GitPanelScreen.swift
Normal file
268
ios/App/WebTerm/Screens/GitPanelScreen.swift
Normal 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 "未跟踪"
|
||||
}
|
||||
}
|
||||
}
|
||||
359
ios/App/WebTerm/Screens/GitPanelViews.swift
Normal file
359
ios/App/WebTerm/Screens/GitPanelViews.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
84
ios/App/WebTerm/Screens/PairingCopy.swift
Normal file
84
ios/App/WebTerm/Screens/PairingCopy.swift
Normal 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 =
|
||||
"访问令牌格式不符合要求(16–512 位,且只能包含 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,或反馈该网段。"
|
||||
}
|
||||
}
|
||||
@@ -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)" }
|
||||
|
||||
@@ -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 / remove(create 归 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: - Worktrees(T-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。"
|
||||
|
||||
@@ -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-32(C2):历史会话恢复走同一条开会话缝。
|
||||
onResumeClaude: { cwd, sessionId in
|
||||
viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId)
|
||||
}
|
||||
)
|
||||
}
|
||||
// T-iPad-4 · iPhone 透传(现有全高 sheet 字节级不变);iPad 套
|
||||
|
||||
116
ios/App/WebTerm/Screens/ResumeHistorySheet.swift
Normal file
116
ios/App/WebTerm/Screens/ResumeHistorySheet.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "主机"
|
||||
|
||||
122
ios/App/WebTerm/Screens/SettingsScreen.swift
Normal file
122
ios/App/WebTerm/Screens/SettingsScreen.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
131
ios/App/WebTerm/Screens/WorktreeSheet.swift
Normal file
131
ios/App/WebTerm/Screens/WorktreeSheet.swift
Normal 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 立刻出现在列表里)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user