feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs

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.
This commit is contained in:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

@@ -0,0 +1,161 @@
import APIClient
import Foundation
// C2 · git **** I/O SwiftUI w6 "
// 绿"
//
// `docs/plans/w6-project-git-panel.md`Design rule that drives everything
// + web `public/projects.ts:615-745 makeSyncBand`iOS web
//
// MARK: - w6/G3
/// /worktree ""
///
/// optional 0`ahead`/`behind` `@{u}`
/// `@{u}` **** fetch
/// - `ahead`
/// - `behind` **** `lastFetchMs` ""
/// - `upstream == nil` ""****"西"
/// - `nil` "" `?? 0` 绿
///
struct GitSyncBand: Equatable {
/// HEAD
enum Head: Equatable {
/// HEAD ahead/behind
case detached
/// worktree
case noUpstream
/// nil = `` 0
case tracking(upstream: String, ahead: Int?, behind: Int?)
}
/// `unknown` dirty `PROJECT_DIRTY_CHECK=0`
/// **** clean ""
enum Dirty: Equatable {
case unknown
case clean
case changed(Int)
}
/// `FETCH_HEAD` `behind` web `FETCH_STALE_MS`
/// `public/projects.ts:615`
static let fetchStaleMs: Double = 60 * 60 * 1000
let head: Head
let dirty: Dirty
/// 绿 && 0 && 0 && fetch
let isInSync: Bool
/// `behind` fetch false ""
let isBehindVerified: Bool
/// HEAD fetch 400
let canFetch: Bool
/// nil = git
static func make(sync: SyncState?, dirtyCount: Int?, nowMs: Double) -> GitSyncBand? {
guard let sync else { return nil }
let isStale = Self.isStale(lastFetchMs: sync.lastFetchMs, nowMs: nowMs)
let head = Self.head(for: sync)
let isTracking: Bool
if case .tracking = head { isTracking = true } else { isTracking = false }
return GitSyncBand(
head: head,
dirty: Self.dirty(for: dirtyCount),
isInSync: isTracking && sync.ahead == 0 && sync.behind == 0 && !isStale,
isBehindVerified: isTracking && !isStale && sync.behind != nil,
canFetch: sync.detached != true
)
}
/// fetchnil " fetch "" fetch"
private static func isStale(lastFetchMs: Double?, nowMs: Double) -> Bool {
guard let lastFetchMs else { return true }
return nowMs - lastFetchMs > fetchStaleMs
}
private static func head(for sync: SyncState) -> Head {
if sync.detached == true { return .detached }
guard let upstream = sync.upstream else { return .noUpstream }
return .tracking(upstream: upstream, ahead: sync.ahead, behind: sync.behind)
}
private static func dirty(for dirtyCount: Int?) -> Dirty {
guard let dirtyCount else { return .unknown }
return dirtyCount > 0 ? .changed(dirtyCount) : .clean
}
}
// MARK: - w6/G4
/// `git log` "/"线
///
/// `unpushed` SHA `@{u}..HEAD`
/// **** merge
/// " unpushed"" N "
enum GitLogBoundary {
/// ****nil =
///
static func index(commits: [CommitLogEntry], upstream: String?) -> Int? {
guard upstream != nil else { return nil }
// `unpushed` true nil "" false
return commits.lastIndex { $0.unpushed == true }
}
}
// MARK: -
/// / fetch
/// 退
enum GitTimeFormat {
private static let msPerSecond: Double = 1_000
private static let secondsPerMinute: Double = 60
private static let secondsPerHour: Double = 60 * 60
private static let secondsPerDay: Double = 24 * 60 * 60
///
private static let justNowSeconds: Double = 60
static func relative(fromMs: Double, nowMs: Double) -> String {
let seconds = max(0, (nowMs - fromMs) / msPerSecond)
if seconds < justNowSeconds { return Copy.justNow }
if seconds < secondsPerHour {
return Copy.minutesAgo(Int(seconds / secondsPerMinute))
}
if seconds < secondsPerDay {
return Copy.hoursAgo(Int(seconds / secondsPerHour))
}
return Copy.daysAgo(Int(seconds / secondsPerDay))
}
private enum Copy {
static let justNow = "刚刚"
static func minutesAgo(_ value: Int) -> String { "\(value) 分钟前" }
static func hoursAgo(_ value: Int) -> String { "\(value) 小时前" }
static func daysAgo(_ value: Int) -> String { "\(value) 天前" }
}
}
// MARK: -
/// git stage/commit/push/fetch/worktree****
///
/// git stderr SEC-M10****
///
/// message
enum GitWriteFeedback {
static func message(status: Int, serverMessage: String?, fallback: String) -> String {
guard let serverMessage, !serverMessage.isEmpty else {
return "\(fallback)HTTP \(status)"
}
return serverMessage
}
/// `APIClientError` 401
/// `localizedDescription`
///
static func message(for error: any Error, fallback: String) -> String {
(error as? APIClientError)?.message ?? fallback
}
/// 429****
static let rateLimited = APIClientError.rateLimited.message
}

View File

@@ -0,0 +1,369 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// C2 · git `docs/plans/w6-project-git-panel.md`
///
/// **** web
/// + + fetch· stage/unstage · commit · push ·
/// `git log`· PR/CI
///
///
/// 1. ** git **/
/// `ahead`/`behind`/`lastFetchMs` w6
/// push
/// 2. ****PRgh
/// ****""
/// 3. ****SEC-M10 stderr
///
@MainActor
@Observable
final class GitPanelViewModel {
///
///
/// `stagePaths` **** oldPath newPath
/// `git add` web `public/diff.ts`
/// """"
struct ChangedFile: Equatable, Identifiable, Sendable {
let displayPath: String
let stagePaths: [String]
let status: DiffFileStatus
let added: Int
let removed: Int
var id: String { displayPath }
static func make(from file: DiffFile) -> ChangedFile {
let primary = file.newPath.isEmpty ? file.oldPath : file.newPath
let isRename = file.status == .renamed && !file.oldPath.isEmpty
&& file.oldPath != file.newPath
return ChangedFile(
displayPath: isRename ? "\(file.oldPath)\(file.newPath)" : primary,
stagePaths: isRename ? [file.oldPath, file.newPath] : [primary],
status: file.status,
added: file.added,
removed: file.removed
)
}
}
/// + spinner
enum Operation: Equatable {
case fetch
case commit
case push
case stage(String)
case unstage(String)
}
/// `forProject` `APIClient`/`DiffFetcher` fake
struct Dependencies: Sendable {
let detail: @Sendable () async throws -> ProjectDetail
let log: @Sendable () async throws -> GitLogResult
let pr: @Sendable () async throws -> PrStatus
let changes: @Sendable (_ staged: Bool) async throws -> [ChangedFile]
let stage: @Sendable (_ files: [String], _ stage: Bool) async throws
-> GitWriteOutcome<StageResult>
let commit: @Sendable (_ message: String) async throws -> GitWriteOutcome<CommitResult>
let push: @Sendable () async throws -> GitWriteOutcome<PushResult>
let fetchRemote: @Sendable () async throws -> GitWriteOutcome<FetchResult>
/// fetch "" 1h
let nowMs: @Sendable () -> Double
init(
detail: @escaping @Sendable () async throws -> ProjectDetail,
log: @escaping @Sendable () async throws -> GitLogResult,
pr: @escaping @Sendable () async throws -> PrStatus,
changes: @escaping @Sendable (Bool) async throws -> [ChangedFile],
stage: @escaping @Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>,
commit: @escaping @Sendable (String) async throws -> GitWriteOutcome<CommitResult>,
push: @escaping @Sendable () async throws -> GitWriteOutcome<PushResult>,
fetchRemote: @escaping @Sendable () async throws -> GitWriteOutcome<FetchResult>,
nowMs: @escaping @Sendable () -> Double = { Date().timeIntervalSince1970 * 1_000 }
) {
self.detail = detail
self.log = log
self.pr = pr
self.changes = changes
self.stage = stage
self.commit = commit
self.push = push
self.fetchRemote = fetchRemote
self.nowMs = nowMs
}
}
let path: String
private(set) var isLoaded = false
private(set) var branch: String?
/// nil = git **** `stateErrorMessage`
private(set) var band: GitSyncBand?
private(set) var stateErrorMessage: String?
private(set) var log: GitLogResult?
private(set) var logErrorMessage: String?
private(set) var pr: PrStatus?
private(set) var unstaged: [ChangedFile] = []
private(set) var staged: [ChangedFile] = []
private(set) var changesErrorMessage: String?
private(set) var busy: Operation?
///
private(set) var errorMessage: String?
///
private(set) var noticeMessage: String?
/// 稿`TextField`
var commitMessage = ""
@ObservationIgnored
private let dependencies: Dependencies
init(path: String, dependencies: Dependencies) {
self.path = path
self.dependencies = dependencies
}
/// ProjectDetailScreen endpoint/http/path
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> GitPanelViewModel {
let client = APIClient(endpoint: endpoint, http: http)
let diff = DiffFetcher(endpoint: endpoint, http: http)
return GitPanelViewModel(path: path, dependencies: Dependencies(
detail: { try await client.projectDetail(path: path) },
log: { try await client.gitLog(path: path) },
pr: { try await client.prStatus(path: path) },
changes: { staged in
try await diff.fetch(path: path, staged: staged).files.map(ChangedFile.make(from:))
},
stage: { files, stage in
try await client.gitStage(path: path, files: files, stage: stage)
},
commit: { message in try await client.gitCommit(path: path, message: message) },
push: { try await client.gitPush(path: path) },
fetchRemote: { try await client.gitFetch(path: path) }
))
}
var canCommit: Bool {
busy == nil && !commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// HEAD fetch 400
var canFetch: Bool { busy == nil && band?.canFetch == true }
// MARK: -
/// +
func load() async {
await loadState()
await loadLog()
await loadChanges()
isLoaded = true
}
/// PR/CI gh
func loadPullRequest() async {
do {
pr = try await dependencies.pr()
} catch {
// 200 + availability "gh "
//
pr = nil
}
}
private func loadState() async {
do {
let detail = try await dependencies.detail()
branch = detail.branch
band = GitSyncBand.make(
sync: detail.sync, dirtyCount: detail.dirtyCount, nowMs: dependencies.nowMs()
)
stateErrorMessage = nil
} catch {
branch = nil
band = nil // ""
stateErrorMessage = GitWriteFeedback.message(
for: error, fallback: GitPanelCopy.stateFailed
)
}
}
private func loadLog() async {
do {
log = try await dependencies.log()
logErrorMessage = nil
} catch {
logErrorMessage = GitWriteFeedback.message(for: error, fallback: GitPanelCopy.logFailed)
}
}
private func loadChanges() async {
do {
unstaged = try await dependencies.changes(false)
staged = try await dependencies.changes(true)
changesErrorMessage = nil
} catch {
changesErrorMessage = GitWriteFeedback.message(
for: error, fallback: GitPanelCopy.changesFailed
)
}
}
// MARK: -
func setStaged(_ file: ChangedFile, staged: Bool) async {
await perform(
staged ? .stage(file.id) : .unstage(file.id),
fallback: staged ? GitPanelCopy.stageFailed : GitPanelCopy.unstageFailed,
write: { try await self.dependencies.stage(file.stagePaths, staged) },
onSuccess: { [weak self] (result: StageResult) in
self?.noticeMessage = staged
? GitPanelCopy.staged(result.count)
: GitPanelCopy.unstaged(result.count)
}
)
}
func commit() async {
let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else {
errorMessage = GitPanelCopy.commitMessageRequired
return
}
await perform(
.commit,
fallback: GitPanelCopy.commitFailed,
write: { try await self.dependencies.commit(message) },
onSuccess: { [weak self] (result: CommitResult) in
// 稿409
self?.commitMessage = ""
self?.noticeMessage = GitPanelCopy.committed(result.commit)
}
)
}
func push() async {
await perform(
.push,
fallback: GitPanelCopy.pushFailed,
write: { try await self.dependencies.push() },
onSuccess: { [weak self] (result: PushResult) in
self?.noticeMessage = GitPanelCopy.pushed(
branch: result.branch, remote: result.remote
)
}
)
}
func fetchRemote() async {
await perform(
.fetch,
fallback: GitPanelCopy.fetchFailed,
write: { try await self.dependencies.fetchRemote() },
onSuccess: { [weak self] (_: FetchResult) in
self?.noticeMessage = GitPanelCopy.fetched
}
)
}
/// **
/// **`write`/`onSuccess` MainActor
private func perform<Payload: Sendable & Equatable>(
_ operation: Operation,
fallback: String,
write: () async throws -> GitWriteOutcome<Payload>,
onSuccess: (Payload) -> Void
) async {
guard busy == nil else { return } //
busy = operation
errorMessage = nil
noticeMessage = nil
do {
switch try await write() {
case .ok(let payload):
onSuccess(payload)
busy = nil
await load() // w6 push
return
case .rejected(let status, let message):
errorMessage = GitWriteFeedback.message(
status: status, serverMessage: message, fallback: fallback
)
case .rateLimited:
errorMessage = GitWriteFeedback.rateLimited //
}
} catch {
errorMessage = GitWriteFeedback.message(for: error, fallback: fallback)
}
busy = nil
}
}
// MARK: - plan §4
enum GitPanelCopy {
static let title = "Git 面板"
static let entryLabel = "Git 面板"
static let stateSection = "同步状态"
static let changesSection = "改动"
static let commitSection = "提交"
static let logSection = "最近提交"
static let prSection = "Pull Request"
static let upstreamCaption = "上游"
static let toPushCaption = "待推送"
static let toPullCaption = "待拉取"
static let unknownCount = ""
static let inSync = "已同步"
static let unverified = "未核实"
static let noUpstream = "无上游"
static let detachedHead = "分离 HEAD"
static let dirtyUnknown = "未检查改动"
static let clean = "工作区干净"
static let neverFetched = "从未 fetch —— 待拉取数不可信"
static let staleFetch = "上次 fetch 已久 —— 待拉取数不可信"
static let noUpstreamDetail = "此分支不跟踪任何上游,没有可报告的待推送/待拉取。"
static let detachedDetail = "HEAD 处于分离状态:没有分支,也无法 fetch。"
static let fetchButton = "Fetch"
static let fetchHint = "只刷新远端引用,不 pull、不合并"
static let commitButton = "提交"
static let pushButton = "推送"
static let commitPlaceholder = "提交信息"
static let stageButton = "暂存"
static let unstageButton = "取消暂存"
static let noChanges = "工作区没有待提交的改动。"
static let noStaged = "暂存区为空。"
static let noCommits = "暂无提交记录。"
static let truncatedLog = "仅显示最近若干条提交。"
static let commitMessageRequired = "请先填写提交信息。"
static let stateFailed = "读取 git 状态失败"
static let logFailed = "读取提交记录失败"
static let changesFailed = "读取改动列表失败"
static let stageFailed = "暂存失败"
static let unstageFailed = "取消暂存失败"
static let commitFailed = "提交失败"
static let pushFailed = "推送失败"
static let fetchFailed = "Fetch 失败"
static let fetched = "已刷新远端引用。"
static func staged(_ count: Int) -> String { "已暂存 \(count) 个文件。" }
static func unstaged(_ count: Int) -> String { "已取消暂存 \(count) 个文件。" }
static func committed(_ commit: String) -> String {
commit.isEmpty ? "已提交。" : "已提交 \(commit)"
}
static func pushed(branch: String?, remote: String?) -> String {
guard let branch, let remote else { return "已推送。" }
return "已推送 \(branch)\(remote)"
}
static func unpushedBoundary(_ upstream: String) -> String { "以上未推送到 \(upstream)" }
static func dirtyCount(_ count: Int) -> String { "\(count) 处未提交改动" }
static func lastFetched(_ label: String) -> String { "上次 fetch\(label)" }
}

View File

@@ -42,9 +42,59 @@ import WireProtocol
@MainActor
@Observable
final class PairingViewModel {
/// The probe, injected as a closure so tests can both fake results AND
/// assert non-invocation before the user confirms (task RED list).
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
/// Everything the pairing flow needs from the network/platform, injected as
/// ONE value built by the composition root (`AppEnvironment.probe`).
///
/// Why a struct and not three parameters: `AppEnvironment.probe` is handed to
/// this VM by `AppCoordinator`, so growing the capability set inside the
/// value keeps the composition root the single place they are built adding
/// separately-defaulted parameters instead would silently fall back to
/// defaults in production, i.e. a dead hook.
///
/// Tests fake results AND assert non-invocation before the user confirms
/// (task RED list); `validateToken`/`unregisterPush` default to the
/// zero-config behaviour so existing call sites stay one-liners.
struct Probe: Sendable {
/// Two-step host verification (`runPairingProbe`), carrying an optional
/// candidate access token for a host that gates its surface (§1.1).
let verifyHost: @Sendable (HostEndpoint, AccessToken?) async
-> Result<HostEndpoint, PairingError>
/// One-shot `POST /auth` validation of a candidate token §1.1's four
/// outcomes, or a transport-level failure.
let validateToken: @Sendable (HostEndpoint, AccessToken) async
-> Result<AccessTokenProbeResult, TokenProbeFailure>
/// Side effect of removing a host: de-register THIS device's APNs token
/// on that host (`PushRegistrar.handleHostRemoved`). Failure is the
/// registrar's to log removal must never be blocked by it.
let unregisterPush: @Sendable (HostRegistry.Host) async -> Void
init(
verifyHost: @escaping @Sendable (HostEndpoint, AccessToken?) async
-> Result<HostEndpoint, PairingError>,
/// Unscripted default = "this host has auth disabled", which is the
/// zero-config LAN reality and never fabricates an authenticated state.
validateToken: @escaping @Sendable (HostEndpoint, AccessToken) async
-> Result<AccessTokenProbeResult, TokenProbeFailure> = { _, _ in
.success(.authDisabled)
},
unregisterPush: @escaping @Sendable (HostRegistry.Host) async -> Void = { _ in }
) {
self.verifyHost = verifyHost
self.validateToken = validateToken
self.unregisterPush = unregisterPush
}
}
/// Why a `POST /auth` probe never produced one of the four §1.1 outcomes.
/// Deliberately payload-free about the token itself (§5.3).
enum TokenProbeFailure: Error, Equatable, Sendable {
/// Transport-level failure / unexpected status; carries the network
/// description only (never the token).
case unreachable(String)
/// The candidate violated the frozen shape before any I/O defensive:
/// the VM validates first, so this should be unreachable in practice.
case malformed
}
// MARK: - UI state model
@@ -81,6 +131,11 @@ final class PairingViewModel {
/// iOS Local Network permission was denied deep-link to the app's
/// Settings pane (its toggle lives there).
case openLocalNetworkSettings
/// C1 · The host answered **401**, which is ambiguous by design (Origin
/// or access token same status). Offer the one remedy that can be
/// applied from the phone: type the access token. Retry stays available
/// for the Origin case.
case enterAccessToken
}
struct FailureDisplay: Equatable, Sendable {
@@ -93,6 +148,10 @@ final class PairingViewModel {
case confirming(PendingHost)
case probing(PendingHost)
case failed(PendingHost, FailureDisplay)
/// C1 · Access-token prompt for a host that answered 401.
case awaitingToken(PendingHost)
/// C1 · `POST /auth` in flight for the typed candidate.
case validatingToken(PendingHost)
case paired(HostRegistry.Host)
}
@@ -112,11 +171,24 @@ final class PairingViewModel {
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
/// observes it to move on to the session list).
private(set) var pairedHost: HostRegistry.Host?
/// Inline copy under the token field (wrong token / rate-limited / this host
/// has no auth at all / shape violation). NEVER echoes the token itself.
private(set) var tokenRejection: String?
/// C1 · Already-paired hosts, for the manage section (token update + remove).
/// Loaded explicitly by the screen pairing itself never needs it.
private(set) var pairedHosts: [HostRegistry.Host] = []
/// Explicit store-failure copy for the manage section (never a silent
/// empty list hiding a broken keychain).
private(set) var hostsErrorMessage: String?
// MARK: - Dependencies (not observed)
@ObservationIgnored private let store: any HostStore
@ObservationIgnored private let probe: Probe
/// The token validated by `POST /auth` for the host being paired, held in
/// memory ONLY until the host record is written (Keychain via `HostStore`).
/// nil = no token (LAN default, or the host reported auth disabled).
@ObservationIgnored private var candidateToken: AccessToken?
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
/// production reads the keychain. Defaulted so existing call sites compile.
@@ -124,7 +196,7 @@ final class PairingViewModel {
init(
store: any HostStore,
probe: @escaping Probe,
probe: Probe, // C1 · a value now (three capabilities), no longer a closure
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
KeychainClientIdentityStore().hasInstalledIdentity()
}
@@ -170,11 +242,15 @@ final class PairingViewModel {
}
/// Back out of confirm/failed to a clean entry state. Never probes.
/// Drops the in-memory token candidate too an abandoned pairing must not
/// leave a secret behind for the next target.
func cancel() {
phase = .idle
inputRejection = nil
hasAcknowledgedPublicRisk = false
needsPublicRiskAcknowledgement = false
tokenRejection = nil
candidateToken = nil
}
// MARK: - Confirm probe store
@@ -200,7 +276,9 @@ final class PairingViewModel {
switch phase {
case .idle, .confirming, .failed:
return true
case .probing, .paired:
case .probing, .awaitingToken, .validatingToken, .paired:
// Mid-credential-entry counts as busy: a scan landing while the
// token prompt is up must not silently retarget the token.
return false
}
}
@@ -209,6 +287,8 @@ final class PairingViewModel {
inputRejection = nil
hasAcknowledgedPublicRisk = false
needsPublicRiskAcknowledgement = false
tokenRejection = nil
candidateToken = nil // a new target never inherits the previous token
hostName = endpoint.baseURL.host ?? ""
phase = .confirming(PendingHost(
endpoint: endpoint, warning: Self.warning(for: endpoint)
@@ -228,7 +308,10 @@ final class PairingViewModel {
return
}
phase = .probing(pending)
switch await probe(pending.endpoint) {
// The candidate token (if any) travels with BOTH probe legs the HTTP
// one as a `Cookie` header via APIClient, the WS one via the
// probe-scoped transport the composition root builds (§1.1).
switch await probe.verifyHost(pending.endpoint, candidateToken) {
case .failure(let error):
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
case .success(let endpoint):
@@ -239,13 +322,23 @@ final class PairingViewModel {
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
/// endpoint. A store failure is surfaced explicitly (never swallowed);
/// retry re-runs the whole confirm flow.
///
/// C1 · Re-pairing the SAME origin updates that host in place (its `id` is
/// reused) instead of appending a duplicate. That is what makes "this host
/// just got a WEBTERM_TOKEN" recoverable: pair it again, type the token, and
/// the existing record the one every `lastSessionId`/watermark is keyed by
/// gains the token.
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
let existing = await existingHost(matching: endpoint)
let host = HostRegistry.Host(
id: UUID(),
name: trimmedName.isEmpty ? fallbackName : trimmedName,
endpoint: endpoint
id: existing?.id ?? UUID(),
name: resolvedName(for: endpoint, existing: existing),
endpoint: endpoint,
// A validated candidate wins; otherwise keep whatever the record
// already had (re-pairing to fix a name must not wipe a working
// credential). `.authDisabled` clears the candidate first, so a
// host that reports no auth can never persist a token here.
accessToken: candidateToken ?? existing?.accessToken
)
do {
_ = try await store.upsert(host)
@@ -255,10 +348,128 @@ final class PairingViewModel {
))
return
}
candidateToken = nil // persisted drop the in-memory copy
pairedHost = host
phase = .paired(host)
}
/// The already-paired host at the same origin, or nil. A store read failure
/// degrades to nil (pair as new) rather than blocking the pairing.
private func existingHost(matching endpoint: HostEndpoint) async -> HostRegistry.Host? {
let hosts = try? await store.loadAll()
return hosts?.first { $0.endpoint.originHeader == endpoint.originHeader }
}
/// User-typed name wins; an untouched field keeps the existing host's name
/// (re-pairing must not rename "MacBook" to "192.168.1.5").
private func resolvedName(
for endpoint: HostEndpoint, existing: HostRegistry.Host?
) -> String {
let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
let derived = endpoint.baseURL.host ?? endpoint.originHeader
if trimmed.isEmpty || trimmed == derived {
return existing?.name ?? derived
}
return trimmed
}
// MARK: - Access token (§1.1: POST /auth is a one-shot pairing probe)
/// Failure page token prompt. Only from a 401-shaped failure, which is the
/// only failure whose remedy might be a token.
func beginAccessTokenEntry() {
guard case .failed(let pending, let failure) = phase,
failure.action == .enterAccessToken else { return }
tokenRejection = nil
phase = .awaitingToken(pending)
}
/// Token prompt back to the confirm page (the URL is still fine; the user
/// may prefer to fix `ALLOWED_ORIGINS` instead).
func cancelAccessTokenEntry() {
guard case .awaitingToken(let pending) = phase else { return }
tokenRejection = nil
candidateToken = nil
phase = .confirming(pending)
}
/// Validate a typed token against the host, then re-run the host probe with
/// it. Shape is checked BEFORE any I/O (§1.1 charset/length is the server's
/// own rule, so a shape violation can never be the right token).
///
/// The four §1.1 outcomes are all handled explicitly, and `authDisabled`
/// deliberately does NOT persist anything: a 204 without `Set-Cookie` means
/// the host never enabled auth, so claiming "authenticated" would be a lie
/// and storing the token would gate nothing.
func submitAccessToken(_ raw: String) async {
guard case .awaitingToken(let pending) = phase else { return }
let token: AccessToken
do {
token = try AccessToken(validating: raw)
} catch let error as AccessTokenError {
tokenRejection = PairingCopy.tokenShapeRejected(error)
return
} catch {
tokenRejection = PairingCopy.tokenShapeUnknown
return
}
tokenRejection = nil
phase = .validatingToken(pending)
switch await probe.validateToken(pending.endpoint, token) {
case .success(.valid):
candidateToken = token
await runProbe(for: pending) // re-verify, now authenticated
case .success(.authDisabled):
candidateToken = nil
tokenRejection = PairingCopy.tokenNotRequired
phase = .awaitingToken(pending)
case .success(.invalidToken):
tokenRejection = PairingCopy.tokenInvalid
phase = .awaitingToken(pending)
case .success(.rateLimited):
tokenRejection = PairingCopy.tokenRateLimited
phase = .awaitingToken(pending)
case .failure(.unreachable(let underlying)):
tokenRejection = PairingCopy.hostUnreachable(underlying)
phase = .awaitingToken(pending)
case .failure(.malformed):
tokenRejection = PairingCopy.tokenShapeUnknown
phase = .awaitingToken(pending)
}
}
// MARK: - Paired-host management (C1 · the removal path `handleHostRemoved` lacked)
/// Load the manage section's host list. Explicit error copy on failure.
func loadPairedHosts() async {
do {
pairedHosts = try await store.loadAll()
hostsErrorMessage = nil
} catch {
hostsErrorMessage = PairingCopy.hostsLoadFailed
}
}
/// Remove a paired host: de-register this device's APNs token on it, then
/// delete the record (which takes its access token with it the token is a
/// field of the record, so no orphaned secret can survive).
///
/// ORDER MATTERS: the de-registration POST goes out FIRST, while the record
/// (and therefore the host's access token, which the request needs to get
/// past a token gate) still exists. A failing de-registration never blocks
/// the removal the user asked for the host to be gone, and the server also
/// prunes tokens itself on an APNs 410.
func removeHost(id: UUID) async {
guard let host = pairedHosts.first(where: { $0.id == id }) else { return }
await probe.unregisterPush(host)
do {
pairedHosts = try await store.remove(id: id)
hostsErrorMessage = nil
} catch {
hostsErrorMessage = PairingCopy.hostRemoveFailed
}
}
// MARK: - PairingError copy + action (task RED list, one case each)
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
@@ -289,7 +500,14 @@ final class PairingViewModel {
case .originRejected(let hint):
// The probe already derived the complete actionable copy from
// endpoint.originHeader surface it VERBATIM, never re-derive.
return FailureDisplay(message: hint, action: .retry)
//
// C1 · The action is `.enterAccessToken`, not `.retry`: this case now
// also carries the AMBIGUOUS 401 (Origin *or* token the server
// answers the same status for both, see `unauthorizedPairingHint`),
// and typing a token is the only remedy reachable from the phone.
// The failure view keeps a retry button alongside it, so the pure
// Origin case (the 403 on the guarded kill) loses nothing.
return FailureDisplay(message: hint, action: .enterAccessToken)
case .atsBlocked(let host):
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
case .tlsFailure:
@@ -418,40 +636,3 @@ final class PairingViewModel {
private static let ipv4OctetCount = 4
private static let ipv4OctetRange = 0...255
}
/// User-facing pairing copy (plan §3.4 taxonomy actionable wording; §5.2
/// Local-Network guidance including the iOS 18 restart caveat).
enum PairingCopy {
static let scanRejected =
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
static let manualRejected =
"无法解析这个地址。请输入完整 URL例如 http://192.168.1.5:3000"
static let storeFailed =
"主机已通过验证,但保存到本机失败,请重试。"
static let localNetworkDenied =
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
+ "iOS 18 存在需要重启手机才生效的已知问题)。"
static let notWebTerminal =
"对方在响应 HTTP但不是 web-terminal——端口对吗"
static let tlsFailure =
"TLS 连接失败:证书无效或不受信任。"
static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。"
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
static let deviceCertRequired =
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
static let clientCertRejected =
"本设备证书无效或已吊销,请重新导入。"
static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)"
}
/// §3.4 wording for the ATS cleartext block.
static func atsBlocked(host: String) -> String {
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
+ "请改用 https / tailscale serve或反馈该网段。"
}
}

View File

@@ -178,6 +178,28 @@ final class ProjectsViewModel {
)
}
/// T-iOS-32C2· **** `attach(null, cwd)`
/// bootstrap `claude --resume <id>\r` web
/// `public/tabs.ts:918 newTabForResume`
///
/// request cwd
/// id `ProjectResumeCommand`
/// PTY web iOS
func requestResumeClaude(cwd: String, sessionId: String) {
guard Validation.isAbsoluteCwd(cwd) else {
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
return
}
guard let bootstrap = ProjectResumeCommand.bootstrapInput(sessionId: sessionId) else {
openErrorMessage = ResumeCopy.notResumable
return
}
openErrorMessage = nil
openRequest = ProjectOpenRequest(
id: UUID(), host: host, cwd: cwd, bootstrapInput: bootstrap
)
}
// MARK: - Detail assembly/
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {

View File

@@ -0,0 +1,146 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-32 · `claude --resume <id>` `GET /sessions`
///
/// `~/.claude/projects`
/// `src/http/history.ts` `claude --resume`
/// ""`attach(null, cwd)` + attach
/// `claude --resume <id>\r` web `public/tabs.ts:918 newTabForResume`
///
/// ****`id`/`cwd`/`preview`
/// `id` ** PTY ** `ProjectResumeCommand`
/// id`;` `` ` `` `$(`
/// web `claude --resume ${sessionId}\r` iOS
@MainActor
@Observable
final class ResumeHistoryViewModel {
/// `bootstrapInput == nil` id
///
struct ResumeCandidate: Equatable, Identifiable, Sendable {
let session: HistorySession
/// cwdworktree worktree
let cwd: String
let bootstrapInput: String?
var id: String { session.id }
var canResume: Bool { bootstrapInput != nil }
}
enum Phase: Equatable {
case loading
case loaded([ResumeCandidate])
///
case empty
case failed(String)
}
let projectPath: String
private(set) var phase: Phase = .loading
@ObservationIgnored
private let fetch: @Sendable () async throws -> [HistorySession]
init(projectPath: String, fetch: @escaping @Sendable () async throws -> [HistorySession]) {
self.projectPath = projectPath
self.fetch = fetch
}
///
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> ResumeHistoryViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return ResumeHistoryViewModel(projectPath: path, fetch: {
try await client.claudeSessions()
})
}
/// Fetch
func load() async {
phase = .loading
do {
let candidates = Self.candidates(from: try await fetch(), projectPath: projectPath)
phase = candidates.isEmpty ? .empty : .loaded(candidates)
} catch {
phase = .failed(GitWriteFeedback.message(for: error, fallback: ResumeCopy.loadFailed))
}
}
/// + mtime ****
///
/// cwd cwd
/// resume `/` `/repos/web-terminal-old`
/// `/repos/web-terminal`
static func candidates(
from sessions: [HistorySession], projectPath: String
) -> [ResumeCandidate] {
let root = normalized(projectPath)
guard Validation.isAbsoluteCwd(root) else { return [] }
return sessions.compactMap { session in
let cwd = normalized(session.cwd)
guard Validation.isAbsoluteCwd(cwd), isInside(cwd: cwd, root: root) else { return nil }
return ResumeCandidate(
session: session,
cwd: cwd,
bootstrapInput: ProjectResumeCommand.bootstrapInput(sessionId: session.id)
)
}
}
/// `/` `/`
private static func normalized(_ path: String) -> String {
guard path.count > 1, path.hasSuffix("/") else { return path }
return String(path.dropLast())
}
private static func isInside(cwd: String, root: String) -> Bool {
cwd == root || cwd.hasPrefix(root.hasSuffix("/") ? root : root + "/")
}
}
// MARK: -
/// id PTY
///
/// id `.jsonl` UUID
/// `[A-Za-z0-9._-]` `;``|``&``` ` ```$`
/// " id" id
enum ProjectResumeCommand {
/// UUID 36
static let maxIdLength = 128
private static let allowed = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
/// nil = id `\r`0x0D Enter `\r`
/// `\n`CLAUDE.md Gotchas
static func bootstrapInput(sessionId: String) -> String? {
guard isWellFormed(sessionId) else { return nil }
return "claude --resume \(sessionId)\r"
}
static func isWellFormed(_ sessionId: String) -> Bool {
!sessionId.isEmpty
&& sessionId.count <= maxIdLength
&& sessionId.allSatisfy(allowed.contains)
}
}
// MARK: - plan §4
enum ResumeCopy {
static let entryLabel = "恢复历史会话"
static let sheetTitle = "历史会话"
static let emptyTitle = "暂无历史会话"
static let emptyDetail = "主机在此仓库下还没有 Claude Code 历史记录(`~/.claude/projects`)。"
static let loadFailed = "读取历史会话失败"
static let retry = "重试"
static let resume = "恢复"
static let notResumable = "会话标识不合法,无法恢复。"
static let noPreview = "(无首条提示)"
static func resumeAccessibilityLabel(_ project: String) -> String { "恢复 \(project) 的会话" }
}

View File

@@ -57,6 +57,19 @@ final class TerminalViewModel {
static let replayTooLargeMessage =
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
/// Actionable copy for `.failed(.unauthorized)` (C1 over B3, ios-completion
/// §1.1). The server answers **401 on the WS upgrade for two different
/// reasons** the Origin/CSWSH check runs first, the `webterm_auth` cookie
/// second (src/server.ts:1367-1379) and the status is identical, so the
/// client provably cannot tell them apart. Hence the copy names BOTH
/// remedies instead of guessing one. Retrying is pointless (the engine
/// already treats this as terminal), so the message asks for a credential
/// change, not patience.
static let unauthorizedMessage =
"主机拒绝了连接401访问令牌缺失或不正确也可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
+ "请在会话列表的主机菜单里「管理主机与令牌」重新输入访问令牌;"
+ "若令牌无误,就把主机的 ALLOWED_ORIGINS 设成 App 连接的完整地址后重启 web-terminal。"
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
/// Read by the wiring layer for `notifyForegrounded(dims:)` the frozen
/// §3.2 signature needs real cols/rows and this is their single source.
@@ -271,6 +284,11 @@ final class TerminalViewModel {
case .failed(.replayTooLarge):
banner = .none
phase = .failed(message: Self.replayTooLargeMessage)
case .failed(.unauthorized):
// Same shape as replayTooLarge: a terminal state, so the spinner
// must go and the terminal becomes read-only with actionable copy.
banner = .none
phase = .failed(message: Self.unauthorizedMessage)
}
}

View File

@@ -0,0 +1,327 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-32 · worktree create / prune / remove
///
/// `src/http/worktrees.ts` + `docs/plans/w4-worktree-lifecycle.md`web
/// `public/projects.ts``validateBranchNameClient``confirmAndRemoveWorktree`
/// `confirmAndPruneWorktrees`
///
///
/// 1. ****
///
/// 2. **** 409** force**
/// UI
/// 3. ****SEC-M10 git stderr
@MainActor
@Observable
final class WorktreeViewModel {
struct Dependencies: Sendable {
let create: @Sendable (_ branch: String, _ base: String?) async throws
-> GitWriteOutcome<CreateWorktreeResult>
let prune: @Sendable () async throws -> GitWriteOutcome<PruneWorktreesResult>
let remove: @Sendable (_ worktreePath: String, _ force: Bool) async throws
-> GitWriteOutcome<RemoveWorktreeResult>
}
enum CreatePhase: Equatable {
case idle
case creating
/// canonical path/branch
case created(path: String, branch: String)
case failed(String)
}
enum PrunePhase: Equatable {
case idle
case pruning
case done(String)
case failed(String)
}
enum RemovePhase: Equatable {
case idle
case removing
/// 409 force`confirmForceRemove`
/// `cancelForceRemove`
case forceConfirming(path: String, message: String)
case removed(path: String)
case failed(String)
}
/// `TextField`
var branchText = ""
/// ref = HEAD ****
var baseText = ""
private(set) var createPhase: CreatePhase = .idle
private(set) var prunePhase: PrunePhase = .idle
private(set) var removePhase: RemovePhase = .idle
@ObservationIgnored
private let dependencies: Dependencies
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
///
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> WorktreeViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return WorktreeViewModel(dependencies: Dependencies(
create: { branch, base in
try await client.createWorktree(path: path, branch: branch, base: base)
},
prune: { try await client.pruneWorktrees(path: path) },
remove: { worktreePath, force in
try await client.removeWorktree(
path: path, worktreePath: worktreePath, force: force
)
}
))
}
///
var branchValidationMessage: String? {
let branch = trimmedBranch
guard !branch.isEmpty else { return nil }
return WorktreeBranchRule.validate(branch)
}
var canSubmitCreate: Bool {
createPhase != .creating && WorktreeBranchRule.validate(trimmedBranch) == nil
}
var isBusy: Bool {
createPhase == .creating || prunePhase == .pruning || removePhase == .removing
}
/// ""worktree worktree 400
/// unlock 409 UI
static func canRemove(_ worktree: WorktreeInfo) -> Bool {
!worktree.isMain && worktree.locked != true
}
private var trimmedBranch: String {
branchText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedBase: String? {
let base = baseText.trimmingCharacters(in: .whitespacesAndNewlines)
return base.isEmpty ? nil : base
}
// MARK: -
func create() async {
guard createPhase != .creating else { return } //
let branch = trimmedBranch
if let invalid = WorktreeBranchRule.validate(branch) {
createPhase = .failed(invalid) // I/O
return
}
createPhase = .creating
do {
switch try await dependencies.create(branch, trimmedBase) {
case .ok(let result):
createPhase = .created(
path: result.path ?? "", branch: result.branch ?? branch
)
case .rejected(let status, let message):
createPhase = .failed(GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.createFailed
))
case .rateLimited:
createPhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
createPhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.createFailed
))
}
}
/// sheet
func resetCreate() {
branchText = ""
baseText = ""
createPhase = .idle
}
// MARK: - prune
func prune() async {
guard prunePhase != .pruning else { return }
prunePhase = .pruning
do {
switch try await dependencies.prune() {
case .ok(let result):
// =
prunePhase = .done(
result.pruned.isEmpty
? WorktreeCopy.pruneNothing
: WorktreeCopy.pruneDone(result.pruned.count)
)
case .rejected(let status, let message):
prunePhase = .failed(GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.pruneFailed
))
case .rateLimited:
prunePhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
prunePhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.pruneFailed
))
}
}
func clearPruneResult() {
prunePhase = .idle
}
// MARK: - remove
/// `force: false`
func remove(worktreePath: String) async {
await runRemove(worktreePath: worktreePath, force: false)
}
/// `.forceConfirming`
func confirmForceRemove() async {
guard case .forceConfirming(let path, _) = removePhase else { return }
await runRemove(worktreePath: path, force: true)
}
func cancelForceRemove() {
guard case .forceConfirming = removePhase else { return }
removePhase = .idle
}
func clearRemoveResult() {
removePhase = .idle
}
private func runRemove(worktreePath: String, force: Bool) async {
guard removePhase != .removing else { return }
removePhase = .removing
do {
switch try await dependencies.remove(worktreePath, force) {
case .ok(let result):
removePhase = .removed(path: result.path ?? worktreePath)
case .rejected(let status, let message):
let text = GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.removeFailed
)
// 409 = force
// force 409
removePhase = (status == WorktreeStatus.conflict && !force)
? .forceConfirming(path: worktreePath, message: text)
: .failed(text)
case .rateLimited:
removePhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
removePhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.removeFailed
))
}
}
}
/// VM HTTP APIClient internal
private enum WorktreeStatus {
static let conflict = 409
}
// MARK: -
/// web `validateBranchNameClient``public/projects.ts:982`
/// `git check-ref-format`
///
/// "" `execFile` argv shell `--`
/// ****
enum WorktreeBranchRule {
/// web
static let maxLength = 250
static func validate(_ branch: String) -> String? {
if branch.isEmpty { return Message.empty }
if branch.count > maxLength { return Message.tooLong }
if branch.unicodeScalars.contains(where: isForbiddenControlScalar) {
return Message.whitespaceOrControl
}
if branch.hasPrefix("-") { return Message.leadingHyphen }
if branch.contains("..") { return Message.doubleDot }
if branch.hasSuffix(".lock") { return Message.lockSuffix }
if branch.contains(where: forbiddenCharacters.contains) { return Message.forbiddenCharacter }
if branch.contains("@{") { return Message.atBrace }
if branch.hasPrefix("/") || branch.hasSuffix("/") || branch.contains("//") {
return Message.slashUsage
}
return nil
}
/// `[\x00-\x20\x7f]` C0 + DEL
private static func isForbiddenControlScalar(_ scalar: Unicode.Scalar) -> Bool {
scalar.value <= 0x20 || scalar.value == 0x7F
}
private static let forbiddenCharacters: Set<Character> = ["~", "^", ":", "?", "*", "[", "\\"]
private enum Message {
static let empty = "分支名不能为空。"
static let tooLong = "分支名过长(最多 \(maxLength) 个字符)。"
static let whitespaceOrControl = "分支名不能包含空格或控制字符。"
static let leadingHyphen = "分支名不能以 - 开头。"
static let doubleDot = "分支名不能包含 \"..\""
static let lockSuffix = "分支名不能以 \".lock\" 结尾。"
static let forbiddenCharacter = "分支名包含非法字符(~ ^ : ? * [ \\)。"
static let atBrace = "分支名不能包含 \"@{\""
static let slashUsage = "分支名的 / 用法不合法。"
}
}
// MARK: - plan §4
enum WorktreeCopy {
static let sheetTitle = "新建 Worktree"
static let branchField = "新分支名"
static let baseField = "起点(留空 = 当前 HEAD"
static let submit = "创建 Worktree"
static let cancel = "取消"
static let createdTitle = "Worktree 已创建"
static let openInNewWorktree = "在新 Worktree 开会话"
static let createFailed = "创建 worktree 失败"
static let pruneButton = "回收失效 Worktree"
static let pruneConfirmTitle = "回收文件夹已消失的 worktree"
static let pruneConfirmMessage = "只清理 git 中已失效的登记项,不会删除任何仍存在的工作树。"
static let pruneConfirm = "回收"
static let pruneNothing = "没有可回收的 worktree。"
static let pruneFailed = "回收失败"
static let removeButton = "删除"
static let removeAccessibilityLabel = "删除 worktree"
static let removeConfirmTitle = "删除这个 worktree"
static let removeConfirm = "删除"
static let forceConfirmTitle = "有未提交的改动"
static let forceConfirmMessage = "继续将丢失该 worktree 里未提交的改动。"
static let forceConfirm = "强制删除"
static let removeFailed = "删除 worktree 失败"
static let lockedHint = "已锁定:请先在终端里 unlock。"
static func pruneDone(_ count: Int) -> String { "已回收 \(count) 个失效 worktree。" }
static func removeConfirmMessage(_ path: String) -> String {
"将删除工作树目录:\(path)"
}
static func removed(_ path: String) -> String { "已删除 \(path)" }
static func created(path: String, branch: String) -> String {
"已在 \(path) 创建分支 \(branch)"
}
}