import APIClient import SwiftUI 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 { @State private var viewModel: ProjectDetailViewModel private let endpoint: HostEndpoint private let http: any HTTPTransport private let onOpenClaude: (String) -> Void 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)。 static let claudeMdLineLimit = 40 } init( viewModel: ProjectDetailViewModel, endpoint: HostEndpoint, http: any HTTPTransport, 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 { content .navigationTitle(ProjectDetailCopy.title) .navigationBarTitleDisplayMode(.inline) .task { await viewModel.load() } .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 { Binding( get: { worktreePendingRemoval != nil }, set: { presented in if !presented { worktreePendingRemoval = nil } } ) } /// 409 → 二次确认。关闭(含滑走)等于取消,绝不残留在待 force 态。 private var forceDialogBinding: Binding { 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 phaseContent: 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) worktreesSection(detail) 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() } } // 环境同步态(w6/G3):不敲 git 命令就知道有没有没推的提交。 if let band = GitSyncBand.make( sync: detail.sync, dirtyCount: detail.dirtyCount, nowMs: Date().timeIntervalSince1970 * 1_000 ) { SyncSummaryChips(band: band) } } } } 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 { secondaryAction(GitPanelCopy.entryLabel, symbol: "point.3.filled.connected.trianglepath.dotted") { route = .gitPanel } secondaryAction(ProjectDetailCopy.viewDiff, symbol: "plus.forwardslash.minus") { route = .diff } } } } 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 detail.sessions.isEmpty { Text(ProjectDetailCopy.noSessions) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) } else { ForEach(detail.sessions, id: \.id) { session in sessionRow(session) } } // `claude --resume `:挑一条历史会话在本仓库接着跑(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) } } 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() } } } // 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) { 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) } 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) } } } @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 worktreePrunable = "可回收" static let worktreeSwipeHint = "左滑一行可删除该 worktree(主 worktree 与已锁定的不可删)。" 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) 个客户端" } }