Amber-gold accent is designed for the desktop's warm-dark background; on light mode gold text/badges wash out. Match the desktop (dark theme by default): .preferredColorScheme(.dark) at the root. DirtyBadge becomes a soft filled capsule (amber on amber-18%) instead of amber-on-quaternary, readable in any mode.
315 lines
12 KiB
Swift
315 lines
12 KiB
Swift
import APIClient
|
||
import SwiftUI
|
||
import WireProtocol
|
||
|
||
/// T-iOS-26 · 项目详情屏:sessions/worktrees/CLAUDE.md 渲染 + diff 入口
|
||
/// (T-iOS-27 的 `DiffScreen(endpoint:path:http:)`)+ "在此仓库开新会话"。
|
||
///
|
||
/// 安全:名字/路径/分支/CLAUDE.md 内容全是**不可信服务器字节** ——
|
||
/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测)。
|
||
struct ProjectDetailScreen: View {
|
||
@State private var viewModel: ProjectDetailViewModel
|
||
private let endpoint: HostEndpoint
|
||
private let http: any HTTPTransport
|
||
private let onOpenClaude: (String) -> Void
|
||
@State private var isDiffPresented = false
|
||
|
||
private enum Metrics {
|
||
/// CLAUDE.md 预览行数上限(内容裁剪,非视觉 token)。
|
||
static let claudeMdLineLimit = 40
|
||
}
|
||
|
||
init(
|
||
viewModel: ProjectDetailViewModel,
|
||
endpoint: HostEndpoint,
|
||
http: any HTTPTransport,
|
||
onOpenClaude: @escaping (String) -> Void
|
||
) {
|
||
_viewModel = State(initialValue: viewModel)
|
||
self.endpoint = endpoint
|
||
self.http = http
|
||
self.onOpenClaude = onOpenClaude
|
||
}
|
||
|
||
var body: some View {
|
||
content
|
||
.navigationTitle(ProjectDetailCopy.title)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task { await viewModel.load() }
|
||
.navigationDestination(isPresented: $isDiffPresented) {
|
||
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
|
||
}
|
||
}
|
||
|
||
// MARK: - Phase switch
|
||
|
||
@ViewBuilder private var content: some View {
|
||
switch viewModel.phase {
|
||
case .loading:
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
case .failed(let failure):
|
||
failedState(failure)
|
||
case .loaded(let detail):
|
||
detailList(detail)
|
||
}
|
||
}
|
||
|
||
/// 显式、可重试的错误态(400/404/500 `{error}` → 分类文案,任务 Steps)。
|
||
private func failedState(_ failure: ProjectDetailViewModel.Failure) -> some View {
|
||
let copy = Self.failureCopy(failure)
|
||
return ContentUnavailableView {
|
||
Label(copy.title, systemImage: "exclamationmark.triangle")
|
||
} description: {
|
||
Text(copy.detail)
|
||
} actions: {
|
||
Button(ProjectDetailCopy.retry) {
|
||
Task { await viewModel.load() }
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(DS.Palette.accent)
|
||
}
|
||
}
|
||
|
||
static func failureCopy(
|
||
_ failure: ProjectDetailViewModel.Failure
|
||
) -> (title: String, detail: String) {
|
||
switch failure {
|
||
case .pathInvalid:
|
||
return (ProjectDetailCopy.failedPathInvalid,
|
||
ProjectDetailCopy.failedPathInvalidDetail)
|
||
case .notFound:
|
||
return (ProjectDetailCopy.failedNotFound,
|
||
ProjectDetailCopy.failedNotFoundDetail)
|
||
case .unavailable:
|
||
return (ProjectDetailCopy.failedUnavailable,
|
||
ProjectDetailCopy.failedUnavailableDetail)
|
||
}
|
||
}
|
||
|
||
// MARK: - Loaded
|
||
|
||
private func detailList(_ detail: ProjectDetail) -> some View {
|
||
List {
|
||
headerSection(detail)
|
||
actionsSection(detail)
|
||
sessionsSection(detail.sessions)
|
||
worktreesSection(detail.worktrees)
|
||
claudeMdSection(detail)
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
}
|
||
|
||
private func headerSection(_ detail: ProjectDetail) -> some View {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||
Text(verbatim: detail.name)
|
||
.font(DS.Typography.headline)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.lineLimit(1)
|
||
// 路径是服务器字节 → verbatim + 等宽单行中段截断。
|
||
Text(verbatim: detail.path)
|
||
.font(DS.Typography.mono(.caption))
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
HStack(spacing: DS.Space.sm8) {
|
||
if let branch = detail.branch {
|
||
Label {
|
||
Text(verbatim: branch).lineLimit(1)
|
||
} icon: {
|
||
Image(systemName: "arrow.triangle.branch")
|
||
}
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
}
|
||
if detail.dirty == true {
|
||
DirtyBadge()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func actionsSection(_ detail: ProjectDetail) -> some View {
|
||
Section {
|
||
Button {
|
||
DS.Haptics.selection()
|
||
onOpenClaude(detail.path)
|
||
} label: {
|
||
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
|
||
}
|
||
.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 {
|
||
isDiffPresented = true
|
||
} 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
|
||
Section(ProjectDetailCopy.sessionsHeader) {
|
||
if sessions.isEmpty {
|
||
Text(ProjectDetailCopy.noSessions)
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
} else {
|
||
ForEach(sessions, id: \.id) { session in
|
||
sessionRow(session)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sessionRow(_ session: ProjectSessionRef) -> some View {
|
||
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(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
} else {
|
||
// 客户端数含数字 → 等宽 tabular 元信息。
|
||
Text(ProjectDetailCopy.clientCount(session.clientCount))
|
||
.dsMetaText()
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
|
||
if !worktrees.isEmpty {
|
||
Section(ProjectDetailCopy.worktreesHeader) {
|
||
ForEach(worktrees, id: \.path) { worktree in
|
||
worktreeRow(worktree)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
|
||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||
HStack(spacing: DS.Space.sm8) {
|
||
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
|
||
.font(DS.Typography.callout)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.lineLimit(1)
|
||
if worktree.isMain {
|
||
TagBadge(text: ProjectDetailCopy.worktreeMain)
|
||
}
|
||
if worktree.isCurrent {
|
||
TagBadge(text: ProjectDetailCopy.worktreeCurrent)
|
||
}
|
||
if worktree.locked == true {
|
||
TagBadge(text: ProjectDetailCopy.worktreeLocked)
|
||
}
|
||
}
|
||
Text(verbatim: worktree.path)
|
||
.font(DS.Typography.mono(.caption))
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
|
||
if detail.hasClaudeMd {
|
||
Section(ProjectDetailCopy.claudeMdHeader) {
|
||
if let content = detail.claudeMd {
|
||
// 服务器已截断供展示;仍是不可信字节 → verbatim + 行数上限。
|
||
Text(verbatim: content)
|
||
.font(DS.Typography.mono(.caption))
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.lineLimit(Metrics.claudeMdLineLimit)
|
||
} else {
|
||
Text(ProjectDetailCopy.claudeMdPresent)
|
||
.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.weight(.medium))
|
||
// 软填充胶囊:amber 字 + amber 淡底(非浅灰底),明暗两态都清晰。
|
||
.foregroundStyle(DS.Palette.statusWaiting)
|
||
.padding(.horizontal, DS.Space.sm8)
|
||
.padding(.vertical, DS.Space.xs2)
|
||
.background(DS.Palette.statusWaiting.opacity(0.18), 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 {
|
||
static let title = "项目详情"
|
||
static let openClaude = "在此仓库开新会话"
|
||
static let viewDiff = "查看代码差异"
|
||
static let sessionsHeader = "运行中的会话"
|
||
static let noSessions = "暂无运行中的会话。"
|
||
static let sessionExited = "已退出"
|
||
static let worktreesHeader = "Worktrees"
|
||
static let worktreeMain = "主"
|
||
static let worktreeCurrent = "当前"
|
||
static let worktreeLocked = "已锁定"
|
||
static let detachedHead = "(分离 HEAD)"
|
||
static let claudeMdHeader = "CLAUDE.md"
|
||
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
|
||
static let retry = "重试"
|
||
static let failedPathInvalid = "路径无效"
|
||
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400),请从项目列表重新进入。"
|
||
static let failedNotFound = "项目未找到"
|
||
static let failedNotFoundDetail = "该路径不在主机的项目根目录下,或已被移除(404)。"
|
||
static let failedUnavailable = "详情加载失败"
|
||
static let failedUnavailableDetail = "无法从主机获取项目详情,请检查连接后重试。"
|
||
|
||
static func clientCount(_ count: Int) -> String {
|
||
"\(count) 个客户端"
|
||
}
|
||
}
|