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) .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 { 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) 个客户端" } }