feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.
Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).
Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
This commit is contained in:
@@ -17,13 +17,13 @@ struct DiffScreen: View {
|
||||
@State private var viewModel: DiffViewModel
|
||||
|
||||
private enum Metrics {
|
||||
static let pickerHorizontalPadding: CGFloat = 16
|
||||
static let pickerVerticalPadding: CGFloat = 8
|
||||
static let lineVerticalInset: CGFloat = 1
|
||||
static let lineHorizontalInset: CGFloat = 12
|
||||
static let fileHeaderTopInset: CGFloat = 14
|
||||
static let statTagSpacing: CGFloat = 8
|
||||
static let lineBackgroundOpacity = 0.12
|
||||
/// Faint per-line tint alpha for added/removed/hunk rows. This is a
|
||||
/// **code-diff syntax constant** (like terminal/xterm colors), NOT a
|
||||
/// card/status design token — the frozen `DS.Opacity` scale
|
||||
/// (stale/exited/pressed) has no semantic slot for a syntax highlight
|
||||
/// fill. The tint HUE always comes from `DS.Palette` (below); only this
|
||||
/// scanning-aid alpha is diff-local.
|
||||
static let lineTintOpacity = 0.12
|
||||
}
|
||||
|
||||
/// 生产入口(T-iOS-26 消费):`(endpoint, path)` + 注入的传输层。
|
||||
@@ -41,8 +41,8 @@ struct DiffScreen: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
scopePicker
|
||||
.padding(.horizontal, Metrics.pickerHorizontalPadding)
|
||||
.padding(.vertical, Metrics.pickerVerticalPadding)
|
||||
.padding(.horizontal, DS.Space.lg16)
|
||||
.padding(.vertical, DS.Space.sm8)
|
||||
content
|
||||
}
|
||||
.navigationTitle(DiffCopy.title)
|
||||
@@ -104,6 +104,7 @@ struct DiffScreen: View {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(DS.Palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +137,8 @@ struct DiffScreen: View {
|
||||
|
||||
private var truncatedBanner: some View {
|
||||
Label(DiffCopy.truncatedBanner, systemImage: "scissors")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
}
|
||||
|
||||
@ViewBuilder private func rowView(_ row: DiffRow) -> some View {
|
||||
@@ -146,13 +147,13 @@ struct DiffScreen: View {
|
||||
fileHeaderRow(header)
|
||||
case .binaryNotice:
|
||||
Text(DiffCopy.binaryFile)
|
||||
.font(.caption.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.caption.italic())
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.listRowSeparator(.hidden)
|
||||
case .hunkHeader(let header):
|
||||
diffTextRow(
|
||||
header, color: .blue,
|
||||
background: Color.blue.opacity(Metrics.lineBackgroundOpacity)
|
||||
header, color: DS.Palette.accent,
|
||||
background: DS.Palette.accent.opacity(Metrics.lineTintOpacity)
|
||||
)
|
||||
case .line(let kind, let text):
|
||||
diffTextRow(
|
||||
@@ -162,24 +163,25 @@ struct DiffScreen: View {
|
||||
}
|
||||
|
||||
private func fileHeaderRow(_ header: DiffFileHeader) -> some View {
|
||||
HStack(spacing: Metrics.statTagSpacing) {
|
||||
// 路径是服务器字节:verbatim + 单行中段截断。
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
// 路径是服务器字节:verbatim + 单行中段截断(等宽对齐)。
|
||||
Text(verbatim: header.pathLabel)
|
||||
.font(.footnote.weight(.semibold).monospaced())
|
||||
.font(DS.Typography.mono(.footnote).weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: 0)
|
||||
Text(verbatim: "+\(header.added)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.green)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.statusWorking)
|
||||
Text(verbatim: "-\(header.removed)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.red)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
Text(DiffStatusStyle.label(for: header.status))
|
||||
.font(.caption2)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DiffStatusStyle.color(for: header.status))
|
||||
}
|
||||
.padding(.top, Metrics.fileHeaderTopInset)
|
||||
.padding(.top, DS.Space.lg16)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
@@ -187,7 +189,7 @@ struct DiffScreen: View {
|
||||
/// single-line tail truncation (read-only skim view; no wrapping blob).
|
||||
private func diffTextRow(_ text: String, color: Color, background: Color) -> some View {
|
||||
Text(verbatim: text)
|
||||
.font(.caption.monospaced())
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -195,30 +197,32 @@ struct DiffScreen: View {
|
||||
.listRowBackground(background)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: Metrics.lineVerticalInset,
|
||||
leading: Metrics.lineHorizontalInset,
|
||||
bottom: Metrics.lineVerticalInset,
|
||||
trailing: Metrics.lineHorizontalInset
|
||||
top: DS.Space.xs2,
|
||||
leading: DS.Space.md12,
|
||||
bottom: DS.Space.xs2,
|
||||
trailing: DS.Space.md12
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - kind → 颜色(全函数;未知 kind 已在解码层降级为 .context)
|
||||
// 语义色一律取自 DS.Palette(added=working 绿 / removed=stuck 红 /
|
||||
// hunk=accent),与全 App 品牌色对齐;仅淡底 alpha 是 diff 语法常量。
|
||||
|
||||
static func lineColor(_ kind: DiffLineKind) -> Color {
|
||||
switch kind {
|
||||
case .added: return .green
|
||||
case .removed: return .red
|
||||
case .context: return .primary
|
||||
case .hunk: return .blue
|
||||
case .meta: return .secondary
|
||||
case .added: return DS.Palette.statusWorking
|
||||
case .removed: return DS.Palette.statusStuck
|
||||
case .context: return DS.Palette.textPrimary
|
||||
case .hunk: return DS.Palette.accent
|
||||
case .meta: return DS.Palette.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
static func lineBackground(_ kind: DiffLineKind) -> Color {
|
||||
switch kind {
|
||||
case .added: return .green.opacity(Metrics.lineBackgroundOpacity)
|
||||
case .removed: return .red.opacity(Metrics.lineBackgroundOpacity)
|
||||
case .hunk: return .blue.opacity(Metrics.lineBackgroundOpacity)
|
||||
case .added: return DS.Palette.statusWorking.opacity(Metrics.lineTintOpacity)
|
||||
case .removed: return DS.Palette.statusStuck.opacity(Metrics.lineTintOpacity)
|
||||
case .hunk: return DS.Palette.accent.opacity(Metrics.lineTintOpacity)
|
||||
case .context, .meta: return .clear
|
||||
}
|
||||
}
|
||||
@@ -240,10 +244,10 @@ enum DiffStatusStyle {
|
||||
|
||||
static func color(for status: DiffFileStatus) -> Color {
|
||||
switch status {
|
||||
case .added, .untracked: return .green
|
||||
case .deleted: return .red
|
||||
case .renamed: return .blue
|
||||
case .modified, .binary: return .secondary
|
||||
case .added, .untracked: return DS.Palette.statusWorking
|
||||
case .deleted: return DS.Palette.statusStuck
|
||||
case .renamed: return DS.Palette.accent
|
||||
case .modified, .binary: return DS.Palette.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,15 @@ import UIKit
|
||||
import VisionKit
|
||||
#endif
|
||||
|
||||
/// T-iOS-12 · Pairing screen: QR scan (real device only) + manual URL entry +
|
||||
/// probe UI. Pure presentation over `PairingViewModel` — every rule (input
|
||||
/// validation, the zero-network-before-confirm gate, §5.4 warning tiers,
|
||||
/// error copy/actions) lives in the VM where it is unit-tested.
|
||||
///
|
||||
/// Presentation-agnostic on purpose: T-iOS-15 pushes it as the first-run
|
||||
/// screen, and the session list header presents the SAME screen as a sheet to
|
||||
/// add/switch hosts (the "多 host 切换入口" of the task's step list).
|
||||
///
|
||||
/// Scanner: VisionKit `DataScannerViewController` — compiled out for the
|
||||
/// simulator (`#if targetEnvironment(simulator)`), where manual entry is the
|
||||
/// pairing path; on device the entry also hides when scanning is unsupported.
|
||||
/// `NSCameraUsageDescription` is already declared (project.yml, plan §5.2).
|
||||
/// T-iOS-12 · Pairing screen: QR scan (device only) + manual URL entry + probe.
|
||||
/// Pure presentation over `PairingViewModel` — every rule (input validation, the
|
||||
/// zero-network-before-confirm gate, §5.4 warning tiers, error copy/actions)
|
||||
/// lives in the VM where it is unit-tested. Presented first-run (T-iOS-15) and
|
||||
/// as an add/switch-host sheet. Scanner (`DataScannerViewController`) is compiled
|
||||
/// out on the simulator, where manual entry is the pairing path.
|
||||
struct PairingScreen: View {
|
||||
@Bindable var viewModel: PairingViewModel
|
||||
/// Navigate-on-paired hook for the T-iOS-15 wiring.
|
||||
var onPaired: (HostRegistry.Host) -> Void = { _ in }
|
||||
var onPaired: (HostRegistry.Host) -> Void = { _ in } // T-iOS-15 navigate hook
|
||||
|
||||
@State private var manualURLText = ""
|
||||
@State private var isShowingScanner = false
|
||||
@@ -35,7 +27,6 @@ struct PairingScreen: View {
|
||||
onPaired(paired)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.phase {
|
||||
case .idle:
|
||||
@@ -50,42 +41,64 @@ struct PairingScreen: View {
|
||||
pairedView(host)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle: manual entry + scan entry
|
||||
|
||||
private var idleView: some View {
|
||||
Form {
|
||||
Section(ScreenCopy.manualSectionTitle) {
|
||||
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("pairing.urlField")
|
||||
Button(ScreenCopy.manualSubmit) {
|
||||
viewModel.submitManualURL(manualURLText)
|
||||
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)
|
||||
}
|
||||
.accessibilityIdentifier("pairing.submitButton")
|
||||
}
|
||||
if let rejection = viewModel.inputRejection {
|
||||
Section {
|
||||
Text(rejection).foregroundStyle(.red)
|
||||
.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
if PairingScanAvailability.isAvailable {
|
||||
Section {
|
||||
if let rejection = viewModel.inputRejection {
|
||||
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if PairingScanAvailability.isAvailable {
|
||||
Button {
|
||||
scannerError = nil
|
||||
isShowingScanner = true
|
||||
} label: {
|
||||
Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary)) // hidden on sim
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text(ScreenCopy.qrHint)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
.sheet(isPresented: $isShowingScanner) { scannerSheet }
|
||||
}
|
||||
@@ -105,138 +118,189 @@ struct PairingScreen: View {
|
||||
)
|
||||
if let scannerError {
|
||||
Text(scannerError)
|
||||
.foregroundStyle(.red)
|
||||
.padding()
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.padding(DS.Space.md12)
|
||||
.background(.thinMaterial, in: RoundedRectangle(
|
||||
cornerRadius: Metrics.errorCornerRadius
|
||||
cornerRadius: DS.Radius.sm8
|
||||
))
|
||||
.padding()
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Probing / paired
|
||||
|
||||
private func probingView(_ pending: PairingViewModel.PendingHost) -> some View {
|
||||
VStack(spacing: Metrics.stackSpacing) {
|
||||
VStack(spacing: DS.Space.lg16) {
|
||||
ProgressView()
|
||||
Text(ScreenCopy.probing(pending.displayAddress))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
.padding(DS.Space.xl20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func pairedView(_ host: HostRegistry.Host) -> some View {
|
||||
VStack(spacing: Metrics.stackSpacing) {
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.green)
|
||||
.font(DS.Typography.largeTitle)
|
||||
.foregroundStyle(DS.Palette.statusWorking)
|
||||
Text(ScreenCopy.paired(host.name))
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
.padding()
|
||||
.padding(DS.Space.xl20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear { DS.Haptics.success() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Confirm page (parsed address + §5.4 warning tier + name)
|
||||
|
||||
/// Confirm page — parsed address + §5.4 warning tier + host name.
|
||||
private struct ConfirmHostView: View {
|
||||
let pending: PairingViewModel.PendingHost
|
||||
@Bindable var viewModel: PairingViewModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(ScreenCopy.confirmSectionTitle) {
|
||||
// Single-point-derived scheme://host[:port] — never hand-built.
|
||||
Text(pending.displayAddress)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.xl20) {
|
||||
addressCard
|
||||
warningTier
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
Button(ScreenCopy.connect) {
|
||||
DS.Haptics.selection()
|
||||
Task { await viewModel.confirmConnect() }
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("pairing.confirmButton")
|
||||
Button(ScreenCopy.cancel) { viewModel.cancel() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
}
|
||||
}
|
||||
warningSection
|
||||
Section {
|
||||
Button(ScreenCopy.connect) {
|
||||
Task { await viewModel.confirmConnect() }
|
||||
}
|
||||
.accessibilityIdentifier("pairing.confirmButton")
|
||||
Button(ScreenCopy.cancel, role: .cancel) {
|
||||
viewModel.cancel()
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
}
|
||||
private var addressCard: some View {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.confirmSectionTitle)
|
||||
// Single-point-derived origin (UITest asserts this exact string).
|
||||
Text(pending.displayAddress)
|
||||
.font(DS.Typography.mono())
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
Divider()
|
||||
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
|
||||
.font(DS.Typography.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var warningSection: some View {
|
||||
// §5.4 warning tiers — LOGIC frozen (switch cases), only presentation restyled.
|
||||
@ViewBuilder private var warningTier: some View {
|
||||
switch pending.warning {
|
||||
case .none:
|
||||
EmptyView()
|
||||
case .tailscaleEncrypted:
|
||||
Section {
|
||||
case .tailscaleEncrypted: // positive accent badge — encrypted transport
|
||||
Card {
|
||||
Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
case .plaintextLAN:
|
||||
Section {
|
||||
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
|
||||
.foregroundStyle(.orange)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
case .plaintextLAN: // subtle amber note
|
||||
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .publicHostBlocking:
|
||||
Section {
|
||||
Label(ScreenCopy.publicWarning, systemImage: "exclamationmark.octagon.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.headline)
|
||||
Toggle(ScreenCopy.publicAcknowledge,
|
||||
isOn: $viewModel.hasAcknowledgedPublicRisk)
|
||||
if viewModel.needsPublicRiskAcknowledgement {
|
||||
Text(ScreenCopy.publicAckRequired)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
publicWarningCard
|
||||
}
|
||||
}
|
||||
|
||||
/// Prominent red card + acknowledgement gate (Toggle → `hasAcknowledgedPublicRisk`;
|
||||
/// required-message on `needsPublicRiskAcknowledgement`). Logic unchanged.
|
||||
private var publicWarningCard: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
Label {
|
||||
Text(ScreenCopy.publicWarning)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.octagon.fill")
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
Divider()
|
||||
Toggle(ScreenCopy.publicAcknowledge, isOn: $viewModel.hasAcknowledgedPublicRisk)
|
||||
.font(DS.Typography.callout)
|
||||
.tint(DS.Palette.accent)
|
||||
if viewModel.needsPublicRiskAcknowledgement {
|
||||
Label(ScreenCopy.publicAckRequired, systemImage: "arrow.up")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.md12)
|
||||
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.statusStuck, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Failure page (inline copy + recovery action)
|
||||
|
||||
/// Failure page — inline copy + recovery actions (retry / settings / back).
|
||||
private struct FailureView: View {
|
||||
let pending: PairingViewModel.PendingHost
|
||||
let failure: PairingViewModel.FailureDisplay
|
||||
let viewModel: PairingViewModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(pending.displayAddress) {
|
||||
Label(failure.message, systemImage: "xmark.octagon")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Section {
|
||||
if failure.action == .openLocalNetworkSettings {
|
||||
Button(ScreenCopy.openSettings) { openAppSettings() }
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.xl20) {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
Text(pending.displayAddress)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Label {
|
||||
Text(failure.message)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
} icon: {
|
||||
Image(systemName: "xmark.octagon.fill")
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
Button(ScreenCopy.retry) {
|
||||
Task { await viewModel.retry() }
|
||||
}
|
||||
Button(ScreenCopy.back, role: .cancel) {
|
||||
viewModel.cancel()
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
let needsSettings = failure.action == .openLocalNetworkSettings
|
||||
if needsSettings {
|
||||
Button(ScreenCopy.openSettings) { openAppSettings() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
|
||||
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
|
||||
Button(ScreenCopy.back) { viewModel.cancel() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's Settings pane hosts its 本地网络 toggle — there is no public
|
||||
/// deep link straight to 隐私 → 本地网络 (plan §5.2 guidance).
|
||||
/// The app's Settings pane hosts its 本地网络 toggle (no deep link exists).
|
||||
private func openAppSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scan availability
|
||||
|
||||
enum PairingScanAvailability {
|
||||
/// Simulator: no camera → entry hidden, manual entry is the pairing path
|
||||
/// (task ruling). Device: also requires VisionKit support.
|
||||
/// (`DataScannerViewController.isSupported` is MainActor-isolated.)
|
||||
/// Simulator: no camera → hidden, manual entry is the pairing path. Device:
|
||||
/// requires VisionKit support (`isSupported` is MainActor-isolated).
|
||||
@MainActor static var isAvailable: Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return false
|
||||
@@ -246,12 +310,9 @@ enum PairingScanAvailability {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VisionKit scanner (device only)
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
/// Thin `DataScannerViewController` wrapper: QR symbology only; the FIRST
|
||||
/// recognized code wins (the web QR fills the screen — no tap-to-pick needed).
|
||||
/// The payload is handed to the VM untouched — validation is the VM's job.
|
||||
/// Thin `DataScannerViewController` wrapper: QR only; first recognized code wins;
|
||||
/// payload handed to the VM untouched (validation is the VM's job).
|
||||
private struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onCode: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
@@ -275,8 +336,7 @@ private struct QRScannerView: UIViewControllerRepresentable {
|
||||
do {
|
||||
try scanner.startScanning()
|
||||
} catch {
|
||||
// Explicit surfacing, never a silent swallow (plan §4): camera
|
||||
// denied/busy shows inline in the sheet; manual entry remains.
|
||||
// Explicit surfacing (plan §4): shows inline; manual entry remains.
|
||||
onError(ScreenCopy.scannerStartFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
@@ -309,15 +369,10 @@ private struct QRScannerView: UIViewControllerRepresentable {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Screen constants
|
||||
|
||||
private enum Metrics {
|
||||
static let stackSpacing: CGFloat = 12
|
||||
static let errorCornerRadius: CGFloat = 8
|
||||
}
|
||||
|
||||
private enum ScreenCopy {
|
||||
static let title = "配对主机"
|
||||
static let heroTitle = "连接你的电脑"
|
||||
static let heroSubtitle = "在同一网络里打开电脑上运行的终端会话——手动输入地址,或扫描它的配对二维码。"
|
||||
static let manualSectionTitle = "输入地址"
|
||||
static let manualPlaceholder = "http://192.168.1.5:3000"
|
||||
static let manualSubmit = "连接"
|
||||
@@ -332,21 +387,13 @@ private enum ScreenCopy {
|
||||
static let back = "返回"
|
||||
static let openSettings = "去设置"
|
||||
static let tailscaleBadge = "经 Tailscale 加密(WireGuard 网络层)"
|
||||
static let plaintextNotice =
|
||||
"ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN,推荐 tailscale serve(wss)。"
|
||||
static let publicWarning =
|
||||
"这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
||||
static let plaintextNotice = "ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN,推荐 tailscale serve(wss)。"
|
||||
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
||||
static let publicAcknowledge = "我已了解风险,仍要连接"
|
||||
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
|
||||
|
||||
static func probing(_ address: String) -> String {
|
||||
"正在验证 \(address) …"
|
||||
}
|
||||
|
||||
static func paired(_ name: String) -> String {
|
||||
"已配对:\(name)"
|
||||
}
|
||||
|
||||
static func probing(_ address: String) -> String { "正在验证 \(address) …" }
|
||||
static func paired(_ name: String) -> String { "已配对:\(name)" }
|
||||
static func scannerStartFailed(_ reason: String) -> String {
|
||||
"无法启动相机扫描:\(reason)。可改用手输地址。"
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ struct ProjectDetailScreen: View {
|
||||
@State private var isDiffPresented = false
|
||||
|
||||
private enum Metrics {
|
||||
static let headerSpacing: CGFloat = 4
|
||||
static let chipSpacing: CGFloat = 6
|
||||
/// CLAUDE.md 预览行数上限(内容裁剪,非视觉 token)。
|
||||
static let claudeMdLineLimit = 40
|
||||
}
|
||||
|
||||
@@ -68,6 +67,7 @@ struct ProjectDetailScreen: View {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(DS.Palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,29 +102,29 @@ struct ProjectDetailScreen: View {
|
||||
|
||||
private func headerSection(_ detail: ProjectDetail) -> some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
Text(verbatim: detail.name)
|
||||
.font(.headline)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
// 路径是服务器字节 → verbatim + 等宽单行中段截断。
|
||||
Text(verbatim: detail.path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
HStack(spacing: Metrics.chipSpacing) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
if let branch = detail.branch {
|
||||
Label {
|
||||
Text(verbatim: branch).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
if detail.dirty == true {
|
||||
Text(ProjectsCopy.dirtyBadge)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
DirtyBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,13 +134,16 @@ struct ProjectDetailScreen: View {
|
||||
private func actionsSection(_ detail: ProjectDetail) -> some View {
|
||||
Section {
|
||||
Button {
|
||||
DS.Haptics.selection()
|
||||
onOpenClaude(detail.path)
|
||||
} label: {
|
||||
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.sm8, leading: DS.Space.lg16,
|
||||
bottom: detail.isGit ? DS.Space.xs4 : DS.Space.sm8, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
if detail.isGit {
|
||||
Button {
|
||||
@@ -148,6 +151,12 @@ struct ProjectDetailScreen: View {
|
||||
} label: {
|
||||
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,8 +165,8 @@ struct ProjectDetailScreen: View {
|
||||
Section(ProjectDetailCopy.sessionsHeader) {
|
||||
if sessions.isEmpty {
|
||||
Text(ProjectDetailCopy.noSessions)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
} else {
|
||||
ForEach(sessions, id: \.id) { session in
|
||||
sessionRow(session)
|
||||
@@ -167,37 +176,29 @@ struct ProjectDetailScreen: View {
|
||||
}
|
||||
|
||||
private func sessionRow(_ session: ProjectSessionRef) -> some View {
|
||||
HStack(spacing: Metrics.chipSpacing) {
|
||||
Text(verbatim: Self.statusGlyph(session.status))
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
// 状态 = 色 + 形 + VoiceOver 标签(DS 单一状态真源);退出优先。
|
||||
StatusBadge(status: session.exited ? .exited : DisplayStatus(session.status))
|
||||
// title 是派生的 cwd 尾段(服务器字节)→ verbatim;缺失退回短 id。
|
||||
Text(verbatim: session.title ?? String(
|
||||
session.id.uuidString.lowercased().prefix(8)
|
||||
))
|
||||
.font(DS.Typography.body)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
if session.exited {
|
||||
Text(ProjectDetailCopy.sessionExited)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
} else {
|
||||
// 客户端数含数字 → 等宽 tabular 元信息。
|
||||
Text(ProjectDetailCopy.clientCount(session.clientCount))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.dsMetaText()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 镜像 web claudeIcon(public/tabs.ts:74-80)。
|
||||
static func statusGlyph(_ status: ClaudeStatus) -> String {
|
||||
switch status {
|
||||
case .working: return "⚙"
|
||||
case .waiting: return "⏳"
|
||||
case .idle: return "✓"
|
||||
case .stuck: return "⚠"
|
||||
case .unknown: return ""
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
|
||||
if !worktrees.isEmpty {
|
||||
Section(ProjectDetailCopy.worktreesHeader) {
|
||||
@@ -209,53 +210,79 @@ struct ProjectDetailScreen: View {
|
||||
}
|
||||
|
||||
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
|
||||
HStack(spacing: Metrics.chipSpacing) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
|
||||
.font(.callout)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
if worktree.isMain {
|
||||
badge(ProjectDetailCopy.worktreeMain)
|
||||
TagBadge(text: ProjectDetailCopy.worktreeMain)
|
||||
}
|
||||
if worktree.isCurrent {
|
||||
badge(ProjectDetailCopy.worktreeCurrent)
|
||||
TagBadge(text: ProjectDetailCopy.worktreeCurrent)
|
||||
}
|
||||
if worktree.locked == true {
|
||||
badge(ProjectDetailCopy.worktreeLocked)
|
||||
TagBadge(text: ProjectDetailCopy.worktreeLocked)
|
||||
}
|
||||
}
|
||||
Text(verbatim: worktree.path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
|
||||
private func badge(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
|
||||
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
|
||||
if detail.hasClaudeMd {
|
||||
Section(ProjectDetailCopy.claudeMdHeader) {
|
||||
if let content = detail.claudeMd {
|
||||
// 服务器已截断供展示;仍是不可信字节 → verbatim + 行数上限。
|
||||
Text(verbatim: content)
|
||||
.font(.caption.monospaced())
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(Metrics.claudeMdLineLimit)
|
||||
} else {
|
||||
Text(ProjectDetailCopy.claudeMdPresent)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 小徽标(DS token 化,跨 Projects/详情复用)
|
||||
|
||||
/// 「未提交」脏标:琥珀语义色文字 + 中性胶囊底(同 TelemetryChip 的 `.quaternary`)。
|
||||
struct DirtyBadge: View {
|
||||
var body: some View {
|
||||
Text(ProjectsCopy.dirtyBadge)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// worktree 属性标签(主/当前/已锁定):accent 描边胶囊。
|
||||
struct TagBadge: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.overlay(
|
||||
Capsule().strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan §4)
|
||||
|
||||
enum ProjectDetailCopy {
|
||||
|
||||
@@ -18,15 +18,9 @@ struct ProjectsScreen: View {
|
||||
/// idiom 而非 size class(iPad 表单 sheet 内部恒 compact)。
|
||||
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
|
||||
|
||||
private enum Metrics {
|
||||
static let rowSpacing: CGFloat = 2
|
||||
static let chipSpacing: CGFloat = 6
|
||||
// T-iPad-4 · 多列网格专属度量 —— 只走 iPad(.pad)分支,iPhone 不受影响。
|
||||
static let gridSectionSpacing: CGFloat = 12
|
||||
static let gridPadding: CGFloat = 16
|
||||
static let gridCardVerticalPadding: CGFloat = 8
|
||||
static let gridCardHorizontalPadding: CGFloat = 10
|
||||
static let gridCardCornerRadius: CGFloat = 10
|
||||
private enum Copy {
|
||||
static let emptyNoProjectsTitle = "暂无项目"
|
||||
static let emptyNoMatchTitle = "无匹配项目"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -72,10 +66,10 @@ struct ProjectsScreen: View {
|
||||
List {
|
||||
errorRows
|
||||
if let message = viewModel.emptyStateMessage {
|
||||
Text(message)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
emptyState(message)
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
ForEach(viewModel.groups) { group in
|
||||
groupSection(group)
|
||||
@@ -89,6 +83,18 @@ struct ProjectsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 空态:区分「无项目」与「无搜索结果」(symbol + 文案;friendly,非错误)。
|
||||
private func emptyState(_ message: String) -> some View {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
viewModel.isSearching ? Copy.emptyNoMatchTitle : Copy.emptyNoProjectsTitle,
|
||||
systemImage: viewModel.isSearching ? "magnifyingglass" : "folder"
|
||||
)
|
||||
} description: {
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grid(regular 宽度:多列网格)
|
||||
|
||||
/// iPad 分栏/大屏下的多列网格。列数由 `ProjectsGridLayout.columnCount` 按
|
||||
@@ -102,18 +108,17 @@ struct ProjectsScreen: View {
|
||||
idiom: idiom
|
||||
)
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Metrics.gridSectionSpacing) {
|
||||
LazyVStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
errorRows
|
||||
if let message = viewModel.emptyStateMessage {
|
||||
Text(message)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
emptyState(message)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ForEach(viewModel.groups) { group in
|
||||
gridSection(group, columns: columns)
|
||||
}
|
||||
}
|
||||
.padding(Metrics.gridPadding)
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
.overlay {
|
||||
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
|
||||
@@ -125,25 +130,22 @@ struct ProjectsScreen: View {
|
||||
|
||||
@ViewBuilder private func gridSection(_ group: ProjectGroup, columns: Int) -> some View {
|
||||
let isCollapsed = viewModel.isCollapsed(group)
|
||||
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
if group.kind != .flat {
|
||||
groupHeader(group, isCollapsed: isCollapsed)
|
||||
.padding(.top, Metrics.gridSectionSpacing)
|
||||
.padding(.top, DS.Space.md12)
|
||||
}
|
||||
if !isCollapsed {
|
||||
LazyVGrid(
|
||||
columns: gridColumns(columns),
|
||||
alignment: .leading,
|
||||
spacing: Metrics.chipSpacing
|
||||
spacing: DS.Space.sm8
|
||||
) {
|
||||
ForEach(group.projects, id: \.path) { project in
|
||||
projectRow(project, group: group)
|
||||
.padding(.vertical, Metrics.gridCardVerticalPadding)
|
||||
.padding(.horizontal, Metrics.gridCardHorizontalPadding)
|
||||
.background(
|
||||
.quaternary,
|
||||
in: RoundedRectangle(cornerRadius: Metrics.gridCardCornerRadius)
|
||||
)
|
||||
// 卡片 = DS Card(secondary 底 + 发丝描边 + md12)。
|
||||
Card(padding: DS.Space.md12) {
|
||||
projectRow(project, group: group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +155,7 @@ struct ProjectsScreen: View {
|
||||
/// 等宽弹性列(`columns >= 1` 由 `ProjectsGridLayout` 保证,绝不空网格)。
|
||||
private func gridColumns(_ count: Int) -> [GridItem] {
|
||||
Array(
|
||||
repeating: GridItem(.flexible(), spacing: Metrics.chipSpacing),
|
||||
repeating: GridItem(.flexible(), spacing: DS.Space.sm8),
|
||||
count: max(count, ProjectsGridLayout.singleColumn)
|
||||
)
|
||||
}
|
||||
@@ -170,8 +172,8 @@ struct ProjectsScreen: View {
|
||||
id: \.self
|
||||
) { message in
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
@@ -194,21 +196,26 @@ struct ProjectsScreen: View {
|
||||
}
|
||||
|
||||
private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View {
|
||||
HStack(spacing: Metrics.chipSpacing) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
if group.isCollapsible {
|
||||
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
|
||||
.font(.caption2)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
// namespace label 来自服务器仓库名 → verbatim。
|
||||
Text(verbatim: group.label)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
// 计数含数字 → 等宽 tabular。
|
||||
Text(verbatim: "\(group.projects.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
// 折叠的分区绝不悄悄埋掉活跃会话(镜像 web 组头徽标)。
|
||||
if group.kind != .active && group.activeCount > 0 {
|
||||
Text(ProjectsCopy.activeCountBadge(group.activeCount))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.statusWorking)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -224,22 +231,21 @@ struct ProjectsScreen: View {
|
||||
|
||||
private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View {
|
||||
NavigationLink(value: ProjectRoute(path: project.path)) {
|
||||
HStack(spacing: Metrics.chipSpacing) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
favouriteButton(project)
|
||||
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
Text(verbatim: ProjectGrouping.displayLabel(
|
||||
name: project.name, groupKey: group.key
|
||||
))
|
||||
.font(.body.weight(.medium))
|
||||
.font(DS.Typography.body.weight(.medium))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
projectChips(project)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
// 有运行中会话 → DS 状态徽标(色 + 形 + VoiceOver,非仅靠颜色)。
|
||||
if ProjectGrouping.hasRunningSession(project) {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel(ProjectsCopy.activeCountBadge(1))
|
||||
StatusBadge(status: .working)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,29 +253,29 @@ struct ProjectsScreen: View {
|
||||
|
||||
private func favouriteButton(_ project: ProjectInfo) -> some View {
|
||||
Button {
|
||||
DS.Haptics.selection()
|
||||
Task { await viewModel.toggleFavourite(path: project.path) }
|
||||
} label: {
|
||||
Image(systemName: viewModel.isFavourite(project.path) ? "star.fill" : "star")
|
||||
.foregroundStyle(.yellow)
|
||||
let isFav = viewModel.isFavourite(project.path)
|
||||
Image(systemName: isFav ? "star.fill" : "star")
|
||||
.foregroundStyle(isFav ? DS.Palette.accent : DS.Palette.textTertiary)
|
||||
}
|
||||
.buttonStyle(.borderless) // List 行内独立可点
|
||||
}
|
||||
|
||||
@ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View {
|
||||
HStack(spacing: Metrics.chipSpacing) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
if let branch = project.branch {
|
||||
Label {
|
||||
Text(verbatim: branch).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
if project.dirty == true {
|
||||
Text(ProjectsCopy.dirtyBadge)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
DirtyBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ import WireProtocol
|
||||
/// staleness, optimistic kill and the navigation signal are all VM logic,
|
||||
/// unit-tested in `SessionListViewModelTests`.
|
||||
///
|
||||
/// UX polish (G1-list): the row is restructured for the 5-second glance —
|
||||
/// prominent title, a `StatusBadge` (semantic color + distinct SF Symbol, never
|
||||
/// color alone), a monospaced meta line, and telemetry as proper
|
||||
/// `TelemetryChip`s. Rows are DS `Card`s; active/exited sessions are split under
|
||||
/// `SectionHeader`s (presentation-only — the VM already groups exited last).
|
||||
/// Every color/spacing/radius/font/motion comes from `DS.*`.
|
||||
///
|
||||
/// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with
|
||||
/// `request.sessionId` — nil = new session) and `onAddHost` (present the
|
||||
/// pairing sheet; call `viewModel.reloadHosts()` when it completes).
|
||||
@@ -24,6 +31,10 @@ struct SessionListScreen: View {
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
// Accent is not injected app-wide; scope it here so native chrome
|
||||
// (nav bar, bordered controls) picks up the DS indigo. Presentation
|
||||
// only — no behavior change.
|
||||
.tint(DS.Palette.accent)
|
||||
.navigationTitle(ScreenCopy.title)
|
||||
.toolbar { hostMenu }
|
||||
.onAppear { viewModel.appeared() }
|
||||
@@ -55,30 +66,80 @@ struct SessionListScreen: View {
|
||||
if let message = viewModel.killErrorMessage {
|
||||
errorRow(message)
|
||||
}
|
||||
Button {
|
||||
viewModel.requestNewSession()
|
||||
} label: {
|
||||
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
ForEach(viewModel.rows) { row in
|
||||
Button {
|
||||
viewModel.openSession(id: row.id)
|
||||
} label: {
|
||||
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
|
||||
|
||||
newSessionRow
|
||||
|
||||
if !activeRows.isEmpty {
|
||||
Section {
|
||||
ForEach(activeRows) { row in sessionRow(row) }
|
||||
} header: {
|
||||
SectionHeader(title: ScreenCopy.activeSection)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.kill(sessionId: row.id) }
|
||||
} label: {
|
||||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||||
}
|
||||
}
|
||||
if !exitedRows.isEmpty {
|
||||
Section {
|
||||
ForEach(exitedRows) { row in sessionRow(row) }
|
||||
} header: {
|
||||
SectionHeader(title: ScreenCopy.exitedSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable { await viewModel.refresh() }
|
||||
}
|
||||
|
||||
/// Split the VM's (already exited-last) rows into the two visual groups.
|
||||
/// Presentation only — no ordering/logic decision lives here.
|
||||
private var activeRows: [SessionListViewModel.SessionRow] {
|
||||
viewModel.rows.filter { !$0.info.exited }
|
||||
}
|
||||
|
||||
private var exitedRows: [SessionListViewModel.SessionRow] {
|
||||
viewModel.rows.filter { $0.info.exited }
|
||||
}
|
||||
|
||||
/// Inviting primary entry — accent-tinted card row.
|
||||
private var newSessionRow: some View {
|
||||
Button {
|
||||
viewModel.requestNewSession()
|
||||
} label: {
|
||||
NewSessionRow()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.sm8))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private func sessionRow(_ row: SessionListViewModel.SessionRow) -> some View {
|
||||
Button {
|
||||
viewModel.openSession(id: row.id)
|
||||
} label: {
|
||||
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
|
||||
.listRowBackground(Color.clear)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.kill(sessionId: row.id) }
|
||||
} label: {
|
||||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Carded-list row inset: full-bleed gutter (`lg16`) + a small vertical gap
|
||||
/// so adjacent cards breathe.
|
||||
private func rowInsets(vertical: CGFloat) -> EdgeInsets {
|
||||
EdgeInsets(
|
||||
top: vertical, leading: DS.Space.lg16,
|
||||
bottom: vertical, trailing: DS.Space.lg16
|
||||
)
|
||||
}
|
||||
|
||||
/// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive —
|
||||
/// rows imply a host) → no slot; the request key carries `lastOutputAt`
|
||||
/// so unchanged sessions render exactly once (pipeline cache).
|
||||
@@ -97,9 +158,12 @@ struct SessionListScreen: View {
|
||||
}
|
||||
|
||||
private func errorRow(_ message: String) -> some View {
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// MARK: - Empty states
|
||||
@@ -111,7 +175,7 @@ struct SessionListScreen: View {
|
||||
Text(ScreenCopy.notPairedHint)
|
||||
} actions: {
|
||||
Button(ScreenCopy.addHost) { onAddHost() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +186,7 @@ struct SessionListScreen: View {
|
||||
Text(ScreenCopy.noSessionsHint)
|
||||
} actions: {
|
||||
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
}
|
||||
}
|
||||
@@ -162,6 +226,25 @@ struct SessionListScreen: View {
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
/// Inviting "新建会话" card — accent icon + accent title on a DS `Card`.
|
||||
private struct NewSessionRow: View {
|
||||
var body: some View {
|
||||
Card(padding: DS.Space.md12) {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(DS.Typography.title)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
Text(ScreenCopy.newSession)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One session row: `StatusBadge` · title · mono meta · telemetry chips ·
|
||||
/// optional live thumbnail — laid out inside a DS `Card`.
|
||||
private struct SessionRowView: View {
|
||||
let row: SessionListViewModel.SessionRow
|
||||
/// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot
|
||||
@@ -169,61 +252,69 @@ private struct SessionRowView: View {
|
||||
var thumbnail: SessionThumbnailView?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: Metrics.rowSpacing) {
|
||||
indicator
|
||||
.frame(width: Metrics.indicatorWidth)
|
||||
VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) {
|
||||
HStack(spacing: Metrics.titleSpacing) {
|
||||
// T-iOS-23: OSC titles are attacker-controlled — already
|
||||
// sanitized in the VM, rendered verbatim (no Markdown /
|
||||
// LocalizedStringKey interpretation), one line only.
|
||||
Text(verbatim: title)
|
||||
.font(.body)
|
||||
Card(padding: DS.Space.md12) {
|
||||
HStack(alignment: .top, spacing: DS.Space.md12) {
|
||||
// ⚠ pending / exited outrank the raw status (VM decides pending;
|
||||
// exited is a row fact) — resolved to a single DisplayStatus so
|
||||
// the badge shows color + distinct shape + Chinese VoiceOver.
|
||||
StatusBadge(status: displayStatus)
|
||||
.padding(.top, DS.Space.xs2)
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
titleLine
|
||||
Text(meta)
|
||||
.dsMetaText()
|
||||
.lineLimit(1)
|
||||
if row.isUnread {
|
||||
unreadDot
|
||||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||||
TelemetryChips(model: telemetry)
|
||||
}
|
||||
}
|
||||
Text(meta)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||||
TelemetryChips(model: telemetry)
|
||||
if let thumbnail {
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
thumbnail
|
||||
}
|
||||
}
|
||||
if let thumbnail {
|
||||
Spacer(minLength: Metrics.titleSpacing)
|
||||
thumbnail
|
||||
}
|
||||
}
|
||||
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
|
||||
.opacity(row.info.exited ? DS.Opacity.exited : 1)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// ⚠ badge outranks the status dot (VM decides via `indicator`).
|
||||
@ViewBuilder private var indicator: some View {
|
||||
switch row.indicator {
|
||||
case .pendingApproval:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.accessibilityLabel(ScreenCopy.pendingBadgeLabel)
|
||||
case .status(let status):
|
||||
Circle()
|
||||
.fill(Self.dotColor(status))
|
||||
.frame(width: Metrics.dotSize, height: Metrics.dotSize)
|
||||
.padding(.top, Metrics.dotTopPadding)
|
||||
.accessibilityLabel(Text(status.rawValue))
|
||||
private var titleLine: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
// T-iOS-23: OSC titles are attacker-controlled — already sanitized
|
||||
// in the VM, rendered verbatim (no Markdown / LocalizedStringKey
|
||||
// interpretation), one line only.
|
||||
Text(verbatim: title)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
if row.isUnread {
|
||||
unreadDot
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unread dot (T-iOS-23): output newer than the local last-seen watermark.
|
||||
/// Accent (indigo) continues the web selection color — distinct from the
|
||||
/// gray/green status shapes.
|
||||
private var unreadDot: some View {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: Metrics.unreadDotSize, height: Metrics.unreadDotSize)
|
||||
.fill(DS.Palette.accent)
|
||||
.frame(width: DS.Space.sm8, height: DS.Space.sm8)
|
||||
.accessibilityLabel(ScreenCopy.unreadLabel)
|
||||
}
|
||||
|
||||
/// Resolve the row's status to one `DisplayStatus`. Exited is a terminal
|
||||
/// fact (top precedence); otherwise the VM's badge priority stands
|
||||
/// (pending ⚠ outranks the live status). No VM logic re-implemented here.
|
||||
private var displayStatus: DisplayStatus {
|
||||
if row.info.exited { return .exited }
|
||||
switch row.indicator {
|
||||
case .pendingApproval: return .pendingApproval
|
||||
case .status(let status): return DisplayStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence,
|
||||
/// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied
|
||||
/// display text (untrusted → verbatim Text only).
|
||||
@@ -233,36 +324,14 @@ private struct SessionRowView: View {
|
||||
return URL(fileURLWithPath: cwd).lastPathComponent
|
||||
}
|
||||
|
||||
/// Client count + `cols×rows`, rendered mono-tabular via `.dsMetaText()`.
|
||||
/// The exited state is conveyed by the badge + section + dimming, so it is
|
||||
/// not duplicated here.
|
||||
private var meta: String {
|
||||
var parts = [
|
||||
[
|
||||
ScreenCopy.clientCount(row.info.clientCount),
|
||||
"\(row.info.cols)×\(row.info.rows)",
|
||||
]
|
||||
if row.info.exited {
|
||||
parts.append(ScreenCopy.exitedTag)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private static func dotColor(_ status: ClaudeStatus) -> Color {
|
||||
switch status {
|
||||
case .working: return .green
|
||||
case .waiting: return .orange
|
||||
case .idle: return .blue
|
||||
case .stuck: return .red
|
||||
case .unknown: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private enum Metrics {
|
||||
static let rowSpacing: CGFloat = 10
|
||||
static let rowInnerSpacing: CGFloat = 3
|
||||
static let indicatorWidth: CGFloat = 18
|
||||
static let dotSize: CGFloat = 10
|
||||
static let dotTopPadding: CGFloat = 5
|
||||
static let exitedOpacity = 0.55
|
||||
static let titleSpacing: CGFloat = 6
|
||||
static let unreadDotSize: CGFloat = 8
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,9 +348,9 @@ private enum ScreenCopy {
|
||||
static let noSessionsTitle = "主机上没有运行中的会话"
|
||||
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
|
||||
static let unknownDirectory = "未知目录"
|
||||
static let exitedTag = "已退出"
|
||||
static let pendingBadgeLabel = "等待审批"
|
||||
static let unreadLabel = "有新输出"
|
||||
static let activeSection = "运行中"
|
||||
static let exitedSection = "已结束"
|
||||
|
||||
static func clientCount(_ count: Int) -> String {
|
||||
"\(count) 台设备在看"
|
||||
|
||||
@@ -37,10 +37,8 @@ struct TerminalScreen: View {
|
||||
/// T-iPad-3 · 用户对 KeyBar 的显式覆盖:nil = 跟随自动默认。
|
||||
@State private var keyBarUserOverride: Bool?
|
||||
|
||||
private enum Metrics {
|
||||
static let bannerHorizontalPadding: CGFloat = 12
|
||||
static let bannerTopPadding: CGFloat = 8
|
||||
}
|
||||
/// 无障碍:减弱动态效果时,横幅进出塌成即时切换(DS.Motion.gated)。
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
private enum Copy {
|
||||
static let newSessionInCwd = "在当前目录开新会话"
|
||||
@@ -67,12 +65,15 @@ struct TerminalScreen: View {
|
||||
.overlay(alignment: .top) {
|
||||
if let model = viewModel.bannerModel {
|
||||
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
|
||||
.padding(.horizontal, Metrics.bannerHorizontalPadding)
|
||||
.padding(.top, Metrics.bannerTopPadding)
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.padding(.top, DS.Space.sm8)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.default, value: viewModel.bannerModel)
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: viewModel.bannerModel
|
||||
)
|
||||
.toolbar {
|
||||
newSessionToolbarItem
|
||||
keyBarToggleToolbarItem
|
||||
@@ -122,6 +123,27 @@ struct TerminalScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal theme
|
||||
|
||||
/// Refined dark terminal theme (精致原生). The canvas follows the app surface —
|
||||
/// near-black in dark mode, so the terminal reads as part of the chrome — while
|
||||
/// the caret and selection use the one accent indigo. Every value routes through
|
||||
/// `DS` (no literals); the glyph font is Dynamic-Type-aware SF Mono so terminal
|
||||
/// text respects the user's text-size choice at launch.
|
||||
private enum TerminalTheme {
|
||||
@MainActor
|
||||
static func apply(to terminal: TerminalView) {
|
||||
terminal.font = UIFont.monospacedSystemFont(
|
||||
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
|
||||
weight: .regular
|
||||
)
|
||||
terminal.nativeBackgroundColor = UIColor(DS.Palette.surface)
|
||||
terminal.nativeForegroundColor = UIColor(DS.Palette.textPrimary)
|
||||
terminal.caretColor = DS.Palette.accentUIColor()
|
||||
terminal.selectedTextBackgroundColor = DS.Palette.accentUIColor()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftTerm bridge
|
||||
|
||||
/// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The
|
||||
@@ -142,6 +164,7 @@ private struct TerminalHostView: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> KeyCommandTerminalView {
|
||||
let terminal = KeyCommandTerminalView(frame: .zero)
|
||||
terminal.terminalDelegate = context.coordinator
|
||||
TerminalTheme.apply(to: terminal)
|
||||
|
||||
let viewModel = viewModel
|
||||
terminal.onKeyCommand = { key in viewModel.send(key: key) }
|
||||
|
||||
@@ -12,11 +12,6 @@ import WireProtocol
|
||||
struct TimelineSheet: View {
|
||||
let viewModel: TimelineViewModel
|
||||
|
||||
private enum Metrics {
|
||||
static let rowSpacing: CGFloat = 10
|
||||
static let iconColumnWidth: CGFloat = 24
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
@@ -66,6 +61,7 @@ struct TimelineSheet: View {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(DS.Palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,21 +79,25 @@ struct TimelineSheet: View {
|
||||
}
|
||||
|
||||
private func row(_ event: TimelineEvent) -> some View {
|
||||
HStack(spacing: Metrics.rowSpacing) {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
// Timestamp — mono/tabular so HH:mm columns line up (direction).
|
||||
Text(TimelineRowFormat.timeLabel(atMs: event.at))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
// class → glyph + semantic color (frozen mapping; unit-tested).
|
||||
Text(verbatim: TimelineClassStyle.glyph(for: event.class))
|
||||
.font(.callout)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(TimelineClassStyle.color(for: event.class))
|
||||
.frame(width: Metrics.iconColumnWidth)
|
||||
.frame(width: DS.Space.xxl24)
|
||||
// Server-derived phrase — untrusted: verbatim (never
|
||||
// LocalizedStringKey/Markdown) + hard single-line truncation.
|
||||
Text(verbatim: event.label)
|
||||
.font(.subheadline)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,14 @@ enum TimelineClassStyle {
|
||||
}
|
||||
|
||||
static func color(for cls: String) -> Color {
|
||||
// All from the frozen design system — no raw SwiftUI colors (UX finding).
|
||||
switch cls {
|
||||
case "tool": return .blue
|
||||
case "waiting": return .orange
|
||||
case "done": return .green
|
||||
case "stuck": return .red
|
||||
case "user": return .purple
|
||||
default: return .secondary
|
||||
case "tool": return DS.Palette.timelineTool
|
||||
case "waiting": return DS.Palette.statusWaiting
|
||||
case "done": return DS.Palette.statusWorking
|
||||
case "stuck": return DS.Palette.statusStuck
|
||||
case "user": return DS.Palette.timelineUser
|
||||
default: return DS.Palette.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user