App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
560 lines
22 KiB
Swift
560 lines
22 KiB
Swift
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<Bool> {
|
||
Binding(
|
||
get: { worktreePendingRemoval != nil },
|
||
set: { presented in if !presented { worktreePendingRemoval = nil } }
|
||
)
|
||
}
|
||
|
||
/// 409 → 二次确认。关闭(含滑走)等于取消,绝不残留在待 force 态。
|
||
private var forceDialogBinding: Binding<Bool> {
|
||
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 <id>`:挑一条历史会话在本仓库接着跑(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) 个客户端"
|
||
}
|
||
}
|