import APIClient import SwiftUI import WireProtocol /// T-iOS-32 · `claude --resume ` 历史选择器(`GET /sessions`)。 /// /// 只列出 cwd 落在本仓库内的历史会话(在别的仓库跑过的会话,用本仓库的 cwd 去 /// resume 是错的)。选中一条 → 在**该会话自己的 cwd** 里开新会话并注入 /// `claude --resume \r`(worktree 会话因此回到那个 worktree)。 /// /// 安全:id/cwd/preview 全是不可信服务器字节 —— 一律 `Text(verbatim:)`; /// id 未过 `ProjectResumeCommand` 白名单的行**不提供恢复按钮**(那串东西会被送进 /// 一条真的 PTY 命令行)。 struct ResumeHistorySheet: View { @State private var viewModel: ResumeHistoryViewModel /// (cwd, sessionId) —— 由详情屏转交给"在此仓库开会话"的同一条缝。 let onResume: (String, String) -> Void @Environment(\.dismiss) private var dismiss private enum Metrics { /// 首条提示的展示行数上限(服务器已截断到 120 字符)。 static let previewLineLimit = 2 } init(viewModel: ResumeHistoryViewModel, onResume: @escaping (String, String) -> Void) { _viewModel = State(initialValue: viewModel) self.onResume = onResume } var body: some View { NavigationStack { content .navigationTitle(ResumeCopy.sheetTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(WorktreeCopy.cancel) { dismiss() } } } .task { await viewModel.load() } } } @ViewBuilder private var content: some View { switch viewModel.phase { case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) case .empty: ContentUnavailableView { Label(ResumeCopy.emptyTitle, systemImage: "clock.arrow.circlepath") } description: { Text(ResumeCopy.emptyDetail) } case .failed(let message): ContentUnavailableView { Label(ResumeCopy.loadFailed, systemImage: "exclamationmark.triangle") } description: { Text(verbatim: message) } actions: { Button(ResumeCopy.retry) { Task { await viewModel.load() } } .buttonStyle(.borderedProminent) .tint(DS.Palette.accent) } case .loaded(let candidates): List(candidates) { candidate in row(candidate) } .listStyle(.insetGrouped) } } private func row(_ candidate: ResumeHistoryViewModel.ResumeCandidate) -> some View { HStack(spacing: DS.Space.sm8) { VStack(alignment: .leading, spacing: DS.Space.xs2) { // 首条提示(服务器已折叠空白并截断)→ verbatim。 Text(verbatim: candidate.session.preview.isEmpty ? ResumeCopy.noPreview : candidate.session.preview) .font(DS.Typography.callout) .foregroundStyle(DS.Palette.textPrimary) .lineLimit(Metrics.previewLineLimit) Text(verbatim: candidate.cwd) .font(DS.Typography.mono(.caption2)) .foregroundStyle(DS.Palette.textSecondary) .lineLimit(1) .truncationMode(.middle) Text(GitTimeFormat.relative( fromMs: candidate.session.mtimeMs, nowMs: Date().timeIntervalSince1970 * 1_000 )) .dsMetaText() if !candidate.canResume { Text(ResumeCopy.notResumable) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.statusWaiting) } } Spacer(minLength: DS.Space.sm8) if candidate.canResume { Button(ResumeCopy.resume) { DS.Haptics.selection() onResume(candidate.cwd, candidate.session.id) dismiss() } .font(DS.Typography.body.weight(.semibold)) .foregroundStyle(DS.Palette.accent) .frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget) .accessibilityLabel( ResumeCopy.resumeAccessibilityLabel(candidate.session.project) ) } } } }