feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5)

Brings the native Android client to parity with the web/iOS Projects + git surface,
consuming the existing server endpoints — ZERO server change. (The "SDK-gated modules"
premise was stale; they were already online.)

- :api-client (pure Kotlin/JVM): PrStatus/GitLog/GitWrite models (tolerant decoders,
  safe-error), ProjectInfo + ahead/behind/lastCommitMs; 8 new Endpoints builders
  (projectPr/projectLog read-only; createWorktree/removeWorktree/prune/gitStage/
  gitCommit/gitPush guarded w/ Origin) + ApiClient methods (PR/log degrade in body;
  writes 200→Ok / 429→RateLimited / 4xx-5xx→Rejected(safe msg)).
- :app presenters (JVM-tested): DiffViewModel base-compare + git stage/commit/push;
  new WorktreeViewModel (create/remove/prune + client branch validation + isMain block);
  ProjectDetailViewModel failure-isolated PR-chip + recent-commits.
- Compose screens wired: ProjectDetail (PR chip tappable only for https, recent commits,
  worktree create/remove-with-force/prune, view-diff), Diff (base input, per-file
  stage/unstage, commit/push bar), Projects (ahead/behind sync chip). Nav closes the
  pre-existing "no inbound link to the diff screen" gap.

Verified independently: `./gradlew :app:assembleDebug test :api-client:koverVerify`
BUILD SUCCESSFUL; 348 unit tests pass (api-client 102 / app 246); coverage gate held;
git status android-only. Compose rendering/interaction deferred to on-device QA
(android/DEVICE_QA_CHECKLIST.md), as prior Android waves did.
This commit is contained in:
Yaojia Wang
2026-07-13 05:58:28 +02:00
parent 469037cb94
commit bc31de85dd
29 changed files with 2180 additions and 156 deletions

View File

@@ -66,6 +66,25 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
no upstream (fields absent).
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
(fail=red / pending=amber / pass=green).
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
log failure does NOT hide the rest of the detail (failure-isolated).
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
carry the sessionId; the gate is still visible in the terminal). MEDIUM.

View File

@@ -9,22 +9,20 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1).
## ⚠️ No-SDK constraint (why only 5 modules build here)
## Build environment (SDK installed — all modules build)
The current build environment has **no Android SDK**. Everything that can be pure
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
against SDK 35/36.
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
- **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
`:client-tls`, `:test-support`, `:transport-okhttp`.
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
To bring the Android modules online later: install an SDK, add
`local.properties``sdk.dir`, add the Android Gradle Plugin + `google()` to
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
each stub.
Setup: `local.properties``sdk.dir=/usr/local/share/android-commandlinetools`;
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
`./gradlew test :app:assembleDebug koverVerify`.
## Module map (mirror of the iOS SPM packages — plan §3)
@@ -35,10 +33,10 @@ each stub.
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
> impls, JVM) is owned by task **A7** and will be added then. The iOS
@@ -47,16 +45,16 @@ each stub.
### Dependency graph (arrows = "depends on")
```
:app (SDK-gated)
:app
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android
(SDK-gated) │ │ (SDK-gated)
│ │
│ │ │ ▼
│ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
:wire-protocol ◀──────────── :transport-okhttp
└──────── :test-support → test source sets only
```

View File

@@ -0,0 +1,32 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
/**
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
*/
@Serializable
public data class CommitLogEntry(
val hash: String,
val at: Long,
val subject: String = "",
)
/**
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
*/
@Serializable
public data class GitLogResult(
@Serializable(with = CommitLogEntryListSerializer::class)
val commits: List<CommitLogEntry> = emptyList(),
val truncated: Boolean = false,
)
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
internal object CommitLogEntryListSerializer :
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
*
* - [Ok] — a 200 with the op's route-specific payload [T].
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
* variant (plan Edge cases / Security).
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
*
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
*/
public sealed interface GitWriteOutcome<out T> {
/** 200 — the op succeeded; [payload] is the route-specific success body. */
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
/** 429 — the server rate-limited this write. */
public data object RateLimited : GitWriteOutcome<Nothing>
}
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
@Serializable
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
@Serializable
public data class CommitResult(val commit: String = "")
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
@Serializable
public data class PushResult(val branch: String? = null, val remote: String? = null)
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
@Serializable
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
@Serializable
public data class RemoveWorktreeResult(val path: String? = null)
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
@Serializable
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
@Serializable
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
/**
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
* (never throws — the caller already knows the status is 200).
*/
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
internal fun decodeGitError(bytes: ByteArray): String? =
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
*/
public enum class PrAvailability(public val wire: String) {
/** A PR exists for the current branch; the sibling fields are populated. */
OK("ok"),
/** gh works but the branch has no PR (or no remote/default repo). */
NO_PR("no-pr"),
/** `gh` binary not found on PATH (ENOENT). */
NOT_INSTALLED("not-installed"),
/** gh present but not logged in (needs `gh auth login`). */
UNAUTHENTICATED("unauthenticated"),
/** `GH_ENABLED=0` — feature off, never spawns gh. */
DISABLED("disabled"),
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
ERROR("error"),
;
public companion object {
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
public fun fromWire(wire: String): PrAvailability =
entries.firstOrNull { it.wire == wire } ?: ERROR
}
}
/**
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
*/
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): PrAvailability =
PrAvailability.fromWire(decoder.decodeString())
override fun serialize(encoder: Encoder, value: PrAvailability) =
encoder.encodeString(value.wire)
}
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
@Serializable
public data class PrCheckSummary(
val total: Int = 0,
val passing: Int = 0,
val failing: Int = 0,
val pending: Int = 0,
)
/**
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
* optional (present only when `availability == ok`); [availability] itself defaults to
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
* as plain text; no enum needed for display).
*/
@Serializable
public data class PrStatus(
@Serializable(with = PrAvailabilitySerializer::class)
val availability: PrAvailability = PrAvailability.ERROR,
val number: Int? = null,
val title: String? = null,
val url: String? = null,
val state: String? = null,
val isDraft: Boolean? = null,
val mergeable: String? = null,
val headRefName: String? = null,
val baseRefName: String? = null,
val checks: PrCheckSummary? = null,
)

View File

@@ -34,6 +34,12 @@ public data class ProjectInfo(
val dirty: Boolean? = null,
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
val lastActiveMs: Long? = null,
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
val ahead: Int? = null,
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
val behind: Int? = null,
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
val lastCommitMs: Long? = null,
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
)

View File

@@ -1,13 +1,24 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
@@ -87,6 +98,43 @@ public class ApiClient(
}
}
/**
* `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
* repo. Empty path rejected client-side before any I/O.
*/
public suspend fun projectPr(path: String): PrStatus {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectPr(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
* client-side before any I/O; `n` is clamped in the route builder.
*/
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectLog(path, n))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs {
@@ -132,6 +180,50 @@ public class ApiClient(
}
}
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
*/
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
public suspend fun registerFcmToken(token: String) {

View File

@@ -44,6 +44,9 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
/** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
}

View File

@@ -14,6 +14,10 @@ internal object HttpStatus {
const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400
const val SERVER_ERROR_MAX = 599
}
/** Header / content-type names (no magic strings inline). */

View File

@@ -57,6 +57,28 @@ internal object Endpoints {
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/pr",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
/**
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
*/
fun projectLog(path: String, n: Int?): ApiRoute {
val query = StringBuilder("path=").append(percentEncode(path))
if (n != null) {
val clamped = n.coerceIn(1, GIT_LOG_MAX)
query.append("&n=").append(clamped)
}
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
}
// ── G ────────────────────────────────────────────────────────────────────────────────
fun killSession(id: UUID): ApiRoute =
@@ -92,6 +114,58 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
jsonBodyRoute(
HttpMethod.POST,
"/projects/worktree",
CreateWorktreeBody.serializer(),
CreateWorktreeBody(path, branch, base),
)
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
jsonBodyRoute(
HttpMethod.DELETE,
"/projects/worktree",
RemoveWorktreeBody.serializer(),
RemoveWorktreeBody(path, worktreePath, force),
)
/** `POST /projects/worktree/prune` — `{ path }`. */
fun pruneWorktrees(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
}
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
private const val GIT_LOG_MAX = 50
/**
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
@@ -124,4 +198,22 @@ internal object Endpoints {
@Serializable
private data class FcmTokenBody(val token: String)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
@Serializable
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
@Serializable
private data class PruneBody(val path: String)
@Serializable
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
@Serializable
private data class CommitBody(val path: String, val message: String)
@Serializable
private data class PushBody(val path: String)
}

View File

@@ -0,0 +1,64 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
* subject-less commit still decodes (subject defaults to empty).
*/
class GitLogTest {
private fun decode(json: String): GitLogResult? =
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
@Test
fun `decodes commits and truncated`() {
val json = """
{ "truncated": true, "commits": [
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
] }
""".trimIndent()
val result = decode(json)!!
assertTrue(result.truncated)
assertEquals(2, result.commits.size)
assertEquals("abc123", result.commits[0].hash)
assertEquals(1710000000000L, result.commits[0].at)
assertEquals("first", result.commits[0].subject)
}
@Test
fun `drops a commit missing hash or at, keeping the rest`() {
val json = """
{ "truncated": false, "commits": [
{ "at": 1, "subject":"no hash" },
{ "hash":"keep", "at": 2, "subject":"kept" },
{ "hash":"noAt", "subject":"no at" }
] }
""".trimIndent()
val result = decode(json)!!
assertFalse(result.truncated)
assertEquals(1, result.commits.size)
assertEquals("keep", result.commits.single().hash)
}
@Test
fun `a subject-less commit still decodes with an empty subject`() {
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
assertEquals(1, result.commits.size)
assertEquals("", result.commits.single().subject)
assertFalse(result.truncated) // default
}
@Test
fun `a non-object body degrades to null`() {
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
}
}

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
* exercised (server can return `{ok:true, commit:""}`).
*/
class GitWriteTest {
@Test
fun `stage payload decodes staged and count`() {
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
assertEquals(StageResult(staged = true, count = 3), r)
}
@Test
fun `commit payload decodes the sha and tolerates an empty sha`() {
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
}
@Test
fun `push payload decodes branch and remote`() {
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
assertEquals("main", r.branch)
assertEquals("origin", r.remote)
}
@Test
fun `worktree create and remove and prune payloads decode`() {
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
assertEquals("/wt/x", create.path)
assertEquals("feat", create.branch)
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
assertEquals("/wt/x", remove.path)
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
assertEquals(listOf("a", "b"), prune.pruned)
}
@Test
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
}
@Test
fun `a git-ops failure body yields the safe error string`() {
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
}
@Test
fun `a worktree failure body (no ok field) still yields the error string`() {
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
}
@Test
fun `an empty or errorless failure body yields null`() {
assertNull(decodeGitError(ByteArray(0)))
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
assertNull(decodeGitError("not json".toByteArray()))
}
}

View File

@@ -0,0 +1,77 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
* non-`ok` availability as an HTTP error.
*/
class PrStatusTest {
private fun decode(json: String): PrStatus? =
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
@Test
fun `decodes a full ok body with all fields and check counts`() {
val json = """
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
"state":"open", "isDraft":false, "mergeable":"mergeable",
"headRefName":"feat/wt", "baseRefName":"main",
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
""".trimIndent()
val pr = decode(json)!!
assertEquals(PrAvailability.OK, pr.availability)
assertEquals(42, pr.number)
assertEquals("Add worktrees", pr.title)
assertEquals("https://x/pull/42", pr.url)
assertEquals("open", pr.state)
assertEquals(false, pr.isDraft)
assertEquals("mergeable", pr.mergeable)
assertEquals("feat/wt", pr.headRefName)
assertEquals("main", pr.baseRefName)
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
}
@Test
fun `an unknown availability degrades to ERROR, never throwing`() {
val pr = decode("""{ "availability":"quantum-flux" }""")!!
assertEquals(PrAvailability.ERROR, pr.availability)
}
@Test
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
val pr = decode("""{ "number":7 }""")!!
assertEquals(PrAvailability.ERROR, pr.availability)
assertEquals(7, pr.number)
assertNull(pr.title)
assertNull(pr.checks)
}
@Test
fun `each known availability maps from its wire value`() {
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
}
@Test
fun `a non-object body degrades to null rather than throwing`() {
assertNull(decode("[]"))
assertNull(decode("not json"))
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
}
@Test
fun `unknown top-level keys are ignored`() {
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
assertEquals(PrAvailability.OK, pr.availability)
}
}

View File

@@ -0,0 +1,112 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* Status-code → outcome mapping for the W5 git surface (plan Phase A.5): PR 200/400/404; log
* decode + errors; each guarded write 200→Ok, 403→Rejected(body.error), 409→Rejected, 429→
* RateLimited. Also asserts the transport RECEIVED an Origin on writes and NOT on reads.
*/
class ApiClientGitTest {
private companion object {
const val BASE = "http://h:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
// ── PR (RO; degrade lives in the 200 body, not the status) ───────────────────────────────
@Test
fun `projectPr decodes a 200 degrade body and maps 400 404`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"not-installed"}""".toByteArray())
assertEquals(PrAvailability.NOT_INSTALLED, client.projectPr("/r").availability)
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 400)
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("/r") })
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectPr("/r") })
// Empty path is rejected before any I/O.
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("") })
}
@Test
fun `projectPr never treats a garbled 200 body as an error (degrades to ERROR)`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = "not json".toByteArray())
assertEquals(PrAvailability.ERROR, client.projectPr("/r").availability)
}
// ── log ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `projectLog decodes 200 and maps 404 and 500`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/log?path=%2Fr",
body = """{"commits":[{"hash":"h","at":1,"subject":"s"}],"truncated":false}""".toByteArray(),
)
assertEquals(1, client.projectLog("/r").commits.size)
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectLog("/r") })
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 500)
assertEquals(ApiClientError.GitLogUnavailable, errorOf { client.projectLog("/r") })
}
// ── guarded writes: outcome mapping ──────────────────────────────────────────────────────
@Test
fun `a guarded write 200 yields Ok with the decoded payload`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"abc"}""".toByteArray())
val outcome = client.gitCommit("/r", "msg")
assertTrue(outcome is GitWriteOutcome.Ok)
assertEquals("abc", (outcome as GitWriteOutcome.Ok).payload.commit)
// The write stamped an Origin.
assertTrue(transport.recordedRequests.last().headers.containsKey(HeaderName.ORIGIN))
}
@Test
fun `403 disabled and 409 both surface Rejected with the safe error string`() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/projects/worktree",
status = 403, body = """{"error":"Worktree creation is disabled."}""".toByteArray(),
)
val disabled = client.createWorktree("/r", "b", null)
assertEquals(GitWriteOutcome.Rejected(403, "Worktree creation is disabled."), disabled)
transport.queueSuccess(
method = HttpMethod.DELETE, url = "$BASE/projects/worktree",
status = 409, body = """{"error":"Worktree has uncommitted changes; force required."}""".toByteArray(),
)
val dirty = client.removeWorktree("/r", "/r/x", false)
assertEquals(GitWriteOutcome.Rejected(409, "Worktree has uncommitted changes; force required."), dirty)
}
@Test
fun `429 yields RateLimited and never auto-retries`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 429, body = """{"error":"Too many requests."}""".toByteArray())
assertEquals(GitWriteOutcome.RateLimited, client.gitPush("/r"))
assertEquals(1, transport.recordedRequests.size) // exactly one attempt
}
@Test
fun `a stage 200 decodes staged and count and threads the files body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true,"staged":true,"count":2}""".toByteArray())
val outcome = client.gitStage("/r", listOf("a", "b"), stage = true)
assertTrue(outcome is GitWriteOutcome.Ok)
assertEquals(2, (outcome as GitWriteOutcome.Ok).payload.count)
assertEquals("""{"path":"/r","files":["a","b"],"stage":true}""", transport.recordedRequests.last().body?.decodeToString())
}
}

View File

@@ -0,0 +1,161 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) for the W5 git surface: the two NEW reads
* (`/projects/pr`, `/projects/log`) carry **no** Origin; the six writes (worktree×3, git×3) carry a
* byte-equal Origin and a JSON body — including a `DELETE /projects/worktree` that carries a body
* (the highest-risk integration gotcha). A route reclassified read↔write turns this red.
*/
class GitRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun last(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(r: HttpRequest) =
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
private fun assertReadOnly(r: HttpRequest) =
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
// ── reads: no Origin, correct verb + strict-encoded query ────────────────────────────────
@Test
fun `projectPr is a read-only GET with a strict-encoded path and no Origin`() = runTest {
val path = "/home/me/my repo/a+b&c"
val url = "$BASE/projects/pr?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c"
transport.queueSuccess(url = url, body = """{"availability":"no-pr"}""".toByteArray())
client.projectPr(path)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertEquals(url, r.url)
assertReadOnly(r)
}
@Test
fun `projectLog omits n when null and appends a clamped n when set`() = runTest {
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = null)
assertEquals("$BASE/projects/log?path=%2Fp", last().url)
assertReadOnly(last())
// n above GIT_LOG_MAX (50) clamps to 50; below 1 clamps to 1.
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=50", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = 999)
assertEquals("$BASE/projects/log?path=%2Fp&n=50", last().url)
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=1", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = 0)
assertEquals("$BASE/projects/log?path=%2Fp&n=1", last().url)
}
// ── writes: Origin stamped, correct verb, JSON body ──────────────────────────────────────
@Test
fun `createWorktree is a guarded POST with a path-branch-base body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.createWorktree("/repo", "feat/x", base = "main")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals("$BASE/projects/worktree", r.url)
assertGuarded(r)
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
assertEquals("""{"path":"/repo","branch":"feat/x","base":"main"}""", r.body?.decodeToString())
}
@Test
fun `createWorktree omits base when null`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.createWorktree("/repo", "feat/x", base = null)
assertEquals("""{"path":"/repo","branch":"feat/x"}""", last().body?.decodeToString())
}
@Test
fun `removeWorktree is a guarded DELETE that CARRIES a JSON body`() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.removeWorktree("/repo", "/repo-worktrees/x", force = true)
val r = last()
assertEquals(HttpMethod.DELETE, r.method)
assertEquals("$BASE/projects/worktree", r.url)
assertGuarded(r)
assertNotNull(r.body, "DELETE /projects/worktree MUST carry a request body")
assertEquals("""{"path":"/repo","worktreePath":"/repo-worktrees/x","force":true}""", r.body?.decodeToString())
}
@Test
fun `pruneWorktrees is a guarded POST with a path body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true,"pruned":[]}""".toByteArray())
client.pruneWorktrees("/repo")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals("$BASE/projects/worktree/prune", r.url)
assertGuarded(r)
assertEquals("""{"path":"/repo"}""", r.body?.decodeToString())
}
@Test
fun `gitStage commit push are guarded POSTs with exact bodies`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
client.gitStage("/repo", listOf("a.kt", "b.kt"), stage = true)
assertEquals("$BASE/projects/git/stage", last().url)
assertGuarded(last())
assertEquals("""{"path":"/repo","files":["a.kt","b.kt"],"stage":true}""", last().body?.decodeToString())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"x"}""".toByteArray())
client.gitCommit("/repo", "a message")
assertEquals("""{"path":"/repo","message":"a message"}""", last().body?.decodeToString())
assertGuarded(last())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
client.gitPush("/repo")
assertEquals("$BASE/projects/git/push", last().url)
assertEquals("""{"path":"/repo"}""", last().body?.decodeToString())
assertGuarded(last())
}
@Test
fun `every guarded write carries Origin and every read does not (batch invariant)`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"ok"}""".toByteArray())
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", body = """{"commits":[],"truncated":false}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
client.projectPr("/r"); client.projectLog("/r", null)
assertReadOnly(transport.recordedRequests[0])
assertReadOnly(transport.recordedRequests[1])
client.createWorktree("/r", "b", null)
client.removeWorktree("/r", "/r/x", false)
client.pruneWorktrees("/r")
client.gitStage("/r", listOf("f"), true)
client.gitCommit("/r", "m")
client.gitPush("/r")
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
}
}

View File

@@ -22,6 +22,7 @@ import wang.yaojia.webterm.screens.ClientCertScreen
import wang.yaojia.webterm.screens.DiffScreen
import wang.yaojia.webterm.screens.PairingScreen
import wang.yaojia.webterm.screens.ProjectDetailScreen
import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
import wang.yaojia.webterm.viewmodels.DiffViewModel
@@ -106,6 +107,7 @@ public fun ProjectDetailPane(
path = path,
onBack = { navController.popBackStack() },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
modifier = modifier,
)
}
@@ -118,9 +120,10 @@ public fun ProjectDetailContent(
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
onViewDiff: (String) -> Unit = {},
) {
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier, onViewDiff = onViewDiff)
}
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
@@ -151,7 +154,12 @@ public fun DiffPane(
return
}
val viewModel = remember(resolved, path) {
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
DiffViewModel(
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
path = path,
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
)
}
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
}

View File

@@ -83,6 +83,7 @@ public fun ProjectsHome(
path = path,
onBack = { selectedPath = null },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
)
} else {
DetailPlaceholder("选择一个项目查看详情。")

View File

@@ -15,13 +15,17 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -44,15 +48,17 @@ import wang.yaojia.webterm.viewmodels.DiffPhase
import wang.yaojia.webterm.viewmodels.DiffRow
import wang.yaojia.webterm.viewmodels.DiffUiState
import wang.yaojia.webterm.viewmodels.DiffViewModel
import wang.yaojia.webterm.viewmodels.DiffWriteBanner
/**
* # DiffScreen (A24) — the read-only staged/unstaged git-diff viewer.
* # DiffScreen (A24 + W5) — the git-diff viewer with base-compare + git-write.
*
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
* toggle in the header. Every server-derived string (paths, hunk headers, code lines) is rendered as
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7).
* toggle (hidden in base mode), a **base-rev** input (a third mode), per-file **Stage/Unstage** buttons
* (working/staged mode only), a **commit** message field + **Commit** / **Push** buttons, and a result
* **banner**. Every server-derived string (paths, hunk headers, code lines, git error messages) is
* rendered as **inert [Text]** — never `ClickableText`/autolink/markdown (plan §8). Interaction is
* device-QA (plan §7).
*/
@Composable
public fun DiffScreen(
@@ -61,29 +67,37 @@ public fun DiffScreen(
modifier: Modifier = Modifier,
onRefresh: () -> Unit = {},
onBack: (() -> Unit)? = null,
onSetBase: (String?) -> Unit = {},
onToggleStage: (String, Boolean) -> Unit = { _, _ -> },
onCommit: (String) -> Unit = {},
onPush: () -> Unit = {},
onDismissBanner: () -> Unit = {},
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack)
if (state.truncated) {
DiffNotice("Diff truncated — too large to display fully.")
}
DiffHeader(state = state, onSelectStaged = onSelectStaged, onBack = onBack, onSetBase = onSetBase)
if (state.truncated) DiffNotice("Diff truncated — too large to display fully.")
state.writeBanner?.let { WriteBanner(it, onDismissBanner) }
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (state.phase) {
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
DiffPhase.EMPTY -> CenteredMessage("No changes")
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
DiffPhase.LOADED -> DiffList(rows = state.rows)
DiffPhase.LOADED -> DiffList(rows = state.rows, writeEnabled = state.writeEnabled, staged = state.staged, onToggleStage = onToggleStage)
}
}
if (state.writeEnabled) {
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
CommitBar(writing = state.writing, onCommit = onCommit, onPush = onPush)
}
}
}
}
/**
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
* toggle/refresh callbacks. The nav layer supplies the already-constructed presenter (host + path).
* toggle/refresh/base/git-write callbacks. The nav layer supplies the already-constructed presenter.
*/
@Composable
public fun DiffScreen(
@@ -92,7 +106,6 @@ public fun DiffScreen(
onBack: (() -> Unit)? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
LaunchedEffect(viewModel) { viewModel.bind(this) }
DiffScreen(
state = state,
@@ -100,50 +113,60 @@ public fun DiffScreen(
modifier = modifier,
onRefresh = viewModel::refresh,
onBack = onBack,
onSetBase = viewModel::setBase,
onToggleStage = viewModel::toggleStage,
onCommit = viewModel::commit,
onPush = viewModel::push,
onDismissBanner = viewModel::clearWriteBanner,
)
}
@Composable
private fun DiffHeader(
staged: Boolean,
state: DiffUiState,
onSelectStaged: (Boolean) -> Unit,
onBack: (() -> Unit)?,
onSetBase: (String?) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
if (onBack != null) {
TextButton(onClick = onBack) { Text("Back") }
}
Text(
text = "Diff",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
var baseInput by remember(state.base) { mutableStateOf(state.base ?: "") }
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
if (onBack != null) TextButton(onClick = onBack) { Text("Back") }
Text(text = "Diff", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
Spacer(modifier = Modifier.width(Spacing.sm8))
FilterChip(
selected = !staged,
onClick = { onSelectStaged(false) },
label = { Text("Working") },
)
FilterChip(
selected = staged,
onClick = { onSelectStaged(true) },
label = { Text("Staged") },
if (state.base == null) {
// Working/Staged toggle is suppressed in base mode (server ignores staged then).
FilterChip(selected = !state.staged, onClick = { onSelectStaged(false) }, label = { Text("Working") })
FilterChip(selected = state.staged, onClick = { onSelectStaged(true) }, label = { Text("Staged") })
} else {
Text(text = "vs ${state.base}", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(top = Spacing.xs4)) {
OutlinedTextField(
value = baseInput,
onValueChange = { baseInput = it },
label = { Text("对比基点 (base rev)") },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = { onSetBase(baseInput.takeIf { it.isNotBlank() }) }) { Text("对比") }
if (state.base != null) OutlinedButton(onClick = { baseInput = ""; onSetBase(null) }) { Text("清除") }
}
}
}
@Composable
private fun DiffList(rows: List<DiffRow>) {
private fun DiffList(
rows: List<DiffRow>,
writeEnabled: Boolean,
staged: Boolean,
onToggleStage: (String, Boolean) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { row ->
when (row) {
is DiffFileHeaderRow -> FileHeader(row)
is DiffFileHeaderRow -> FileHeader(row, writeEnabled = writeEnabled, staged = staged, onToggleStage = onToggleStage)
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
@@ -153,11 +176,14 @@ private fun DiffList(rows: List<DiffRow>) {
}
@Composable
private fun FileHeader(row: DiffFileHeaderRow) {
private fun FileHeader(
row: DiffFileHeaderRow,
writeEnabled: Boolean,
staged: Boolean,
onToggleStage: (String, Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -172,6 +198,42 @@ private fun FileHeader(row: DiffFileHeaderRow) {
)
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
if (writeEnabled) {
// In staged view we offer Unstage; in working view we offer Stage.
TextButton(onClick = { onToggleStage(row.stagePath, !staged) }) { Text(if (staged) "取消暂存" else "暂存") }
}
}
}
@Composable
private fun CommitBar(writing: Boolean, onCommit: (String) -> Unit, onPush: () -> Unit) {
var message by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
OutlinedTextField(
value = message,
onValueChange = { message = it },
label = { Text("提交信息") },
singleLine = true,
enabled = !writing,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(enabled = !writing, onClick = { onCommit(message); message = "" }) { Text("提交") }
OutlinedButton(enabled = !writing, onClick = onPush) { Text("推送") }
}
}
}
@Composable
private fun WriteBanner(banner: DiffWriteBanner, onDismiss: () -> Unit) {
val color = if (banner.isError) WebTermColors.statusStuck else WebTermColors.statusWorking
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(text = banner.message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("×") }
}
}
@@ -185,9 +247,7 @@ private fun DiffText(text: String, color: Color) {
softWrap = false,
maxLines = 1,
overflow = TextOverflow.Clip,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = 1.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = 1.dp),
)
}
@@ -207,17 +267,13 @@ private fun DiffNotice(message: String) {
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
)
}
@Composable
private fun CenteredMessage(message: String) {
CenteredContent {
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
CenteredContent { Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
@Composable
@@ -246,17 +302,15 @@ private fun markerFor(kind: DiffLineKind): String = when (kind) {
@Composable
private fun DiffScreenPreview() {
val rows = listOf<DiffRow>(
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1),
DiffFileHeaderRow(0, "src/app/Main.kt", "src/app/Main.kt", "modified", added = 2, removed = 1),
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
)
WebTermTheme {
DiffScreen(
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true),
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true, canWrite = true),
onSelectStaged = {},
)
}

View File

@@ -9,23 +9,36 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.WorktreeInfo
@@ -36,19 +49,19 @@ import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.ProjectsCopy
import wang.yaojia.webterm.viewmodels.WorktreeViewModel
import java.net.URI
/**
* # ProjectDetailScreen (A23) — one project's detail (branch · worktrees · sessions · CLAUDE.md) plus
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`.
* # ProjectDetailScreen (A23 + W5) — one project's detail (branch · worktrees · sessions · CLAUDE.md),
* plus the W5 additions: a **PR + CI chip** (tappable only when the PR url parses as https), a
* **recent-commits** section, and guarded **worktree create / remove / prune** actions.
*
* Every server string (name/path/branch/worktree/CLAUDE.md body) is rendered as **inert [Text]** — no
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action.
*
* @param onBack pop back to the projects grid.
* @param onOpenClaude open a new session in the project cwd (`attach(null, cwd)`); the nav layer routes
* it through [wang.yaojia.webterm.viewmodels.ProjectsViewModel.requestOpenClaude] (path re-validated).
* Every server string (name/path/branch/worktree/CLAUDE.md/commit subject/PR title/error) is rendered
* as **inert [Text]** — no autolink/markdown (plan §8). The single exception is the PR chip, which is a
* link ONLY when its url is a valid https URL (scheme-validated before it is made clickable). The
* worktree actions drive [ProjectDetailViewModel.worktree]; a remove force-confirms in a dialog and a
* main worktree is never removable.
*/
@Composable
public fun ProjectDetailScreen(
@@ -56,8 +69,11 @@ public fun ProjectDetailScreen(
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
onViewDiff: (String) -> Unit = {},
) {
val phase by viewModel.phase.collectAsStateWithLifecycle()
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LaunchedEffect(viewModel) { viewModel.load() }
@@ -71,7 +87,14 @@ public fun ProjectDetailScreen(
is ProjectDetailViewModel.Phase.Failed ->
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
is ProjectDetailViewModel.Phase.Loaded ->
DetailBody(detail = current.detail, onOpenClaude = onOpenClaude)
DetailBody(
detail = current.detail,
prChip = prChip,
recent = recent,
worktree = viewModel.worktree,
onOpenClaude = onOpenClaude,
onViewDiff = onViewDiff,
)
}
}
}
@@ -90,7 +113,14 @@ private fun DetailHeaderBar(onBack: () -> Unit) {
}
@Composable
private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
private fun DetailBody(
detail: ProjectDetail,
prChip: ProjectDetailViewModel.PrChip,
recent: ProjectDetailViewModel.RecentCommits,
worktree: WorktreeViewModel?,
onOpenClaude: (String) -> Unit,
onViewDiff: (String) -> Unit = {},
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -113,37 +143,236 @@ private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
}
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (!detail.isGit) {
EmptyLine("不是 git 仓库。")
} else if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
PrChipRow(prChip)
if (detail.isGit && worktree != null) {
WorktreeSection(detail = detail, worktree = worktree)
} else {
for (worktree in detail.worktrees) WorktreeRow(worktree)
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (!detail.isGit) EmptyLine("不是 git 仓库。")
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
else for (w in detail.worktrees) WorktreeRow(w, onRemove = null)
}
val running = detail.sessions.filter { !it.exited }
SectionTitle("运行中的会话(${running.size}")
if (running.isEmpty()) {
EmptyLine("没有运行中的会话 —— 在下方开一个。")
} else {
for (session in running) SessionRow(session)
}
if (running.isEmpty()) EmptyLine("没有运行中的会话 —— 在下方开一个。")
else for (session in running) SessionRow(session)
RecentCommitsSection(recent)
SectionTitle("CLAUDE.md")
val claudeMd = detail.claudeMd
if (detail.hasClaudeMd && claudeMd != null) {
ClaudeMdBlock(claudeMd)
} else {
EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
if (detail.hasClaudeMd && claudeMd != null) ClaudeMdBlock(claudeMd)
else EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
if (detail.isGit) TextButton(onClick = { onViewDiff(detail.path) }) { Text("查看改动 (diff)") }
}
}
}
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
@Composable
private fun PrChipRow(prChip: ProjectDetailViewModel.PrChip) {
when (prChip) {
ProjectDetailViewModel.PrChip.Hidden -> Unit
ProjectDetailViewModel.PrChip.Loading -> EmptyLine("正在读取 PR 状态…")
ProjectDetailViewModel.PrChip.Unavailable -> EmptyLine("PR 状态不可用。")
is ProjectDetailViewModel.PrChip.Loaded -> PrChipContent(prChip.status)
}
}
@Composable
private fun WorktreeRow(worktree: WorktreeInfo) {
private fun PrChipContent(pr: PrStatus) {
val uriHandler = LocalUriHandler.current
val httpsUrl = pr.url?.let { if (isHttpsUrl(it)) it else null } // link ONLY when https (plan §Security)
val label = prChipLabel(pr)
val color = prChipColor(pr)
if (httpsUrl != null && pr.availability == PrAvailability.OK) {
AssistChip(
onClick = { runCatching { uriHandler.openUri(httpsUrl) } },
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = AssistChipDefaults.assistChipColors(labelColor = color),
)
} else {
// Non-ok / non-https → an INERT, non-clickable line (never make a hostile url tappable).
Text(text = label, style = WebTermType.metaMono, color = color)
}
}
private fun prChipLabel(pr: PrStatus): String = when (pr.availability) {
PrAvailability.OK -> {
val num = pr.number?.let { "#$it " } ?: ""
val checks = pr.checks?.let { " (${it.passing}/${it.total})" } ?: ""
"PR $num${pr.title ?: ""}$checks".trim()
}
PrAvailability.NO_PR -> "当前分支没有 PR"
PrAvailability.NOT_INSTALLED -> "未安装 gh无法读取 PR"
PrAvailability.UNAUTHENTICATED -> "gh 未登录,无法读取 PR"
PrAvailability.DISABLED -> "PR 集成已禁用"
PrAvailability.ERROR -> "PR 状态读取失败"
}
@Composable
private fun prChipColor(pr: PrStatus): androidx.compose.ui.graphics.Color = when {
pr.availability != PrAvailability.OK -> MaterialTheme.colorScheme.onSurfaceVariant
(pr.checks?.failing ?: 0) > 0 -> WebTermColors.statusStuck
(pr.checks?.pending ?: 0) > 0 -> WebTermColors.statusWaiting
else -> WebTermColors.statusWorking
}
private fun isHttpsUrl(url: String): Boolean =
runCatching { URI(url.trim()).scheme?.lowercase() == "https" }.getOrDefault(false)
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
@Composable
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
val scope = rememberCoroutineScope()
val phase by worktree.phase.collectAsStateWithLifecycle()
var branch by remember { mutableStateOf("") }
var base by remember { mutableStateOf("") }
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
} else {
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
}
// New-worktree inline form.
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
OutlinedTextField(
value = branch,
onValueChange = { branch = it },
label = { Text("新工作树分支名") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = base,
onValueChange = { base = it },
label = { Text("基点(可选)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(
enabled = phase != WorktreeViewModel.Phase.Working,
onClick = { scope.launch { worktree.create(branch, base) } },
) { Text("新建工作树") }
OutlinedButton(
enabled = phase != WorktreeViewModel.Phase.Working,
onClick = { scope.launch { worktree.prune() } },
) { Text("清理") }
}
WorktreePhaseBanner(phase, onDismiss = { worktree.reset() })
}
}
val target = removeTarget
if (target != null) {
RemoveWorktreeDialog(
worktree = target,
onConfirm = { force ->
removeTarget = null
scope.launch { worktree.remove(target, force) }
},
onDismiss = { removeTarget = null },
)
}
}
@Composable
private fun WorktreePhaseBanner(phase: WorktreeViewModel.Phase, onDismiss: () -> Unit) {
when (phase) {
WorktreeViewModel.Phase.Idle -> Unit
WorktreeViewModel.Phase.Working -> Text("处理中…", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
is WorktreeViewModel.Phase.Done -> BannerLine(phase.message, WebTermColors.statusWorking, onDismiss)
is WorktreeViewModel.Phase.Failed -> BannerLine(phase.message, WebTermColors.statusStuck, onDismiss)
}
}
@Composable
private fun BannerLine(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(text = message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("知道了") }
}
}
@Composable
private fun RemoveWorktreeDialog(
worktree: WorktreeInfo,
onConfirm: (force: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var force by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("删除工作树") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = force, onCheckedChange = { force = it })
Text("强制删除(丢弃未提交改动)")
}
}
},
confirmButton = { TextButton(onClick = { onConfirm(force) }) { Text("删除") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
// ── Recent commits ────────────────────────────────────────────────────────────────────────────────
@Composable
private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
when (recent) {
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
ProjectDetailViewModel.RecentCommits.Loading -> {
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
}
ProjectDetailViewModel.RecentCommits.Unavailable -> {
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
}
is ProjectDetailViewModel.RecentCommits.Loaded -> {
SectionTitle("最近提交")
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
else for (commit in recent.result.commits) CommitRow(commit)
}
}
}
@Composable
private fun CommitRow(commit: CommitLogEntry) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
Text(
text = commit.hash.take(7),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = commit.subject,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
@Composable
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
@@ -152,6 +381,7 @@ private fun WorktreeRow(worktree: WorktreeInfo) {
if (worktree.isMain) Tag("main")
if (worktree.isCurrent) Tag("current")
if (worktree.locked == true) Tag("locked")
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
}
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@@ -178,7 +408,6 @@ private fun SessionRow(session: ProjectSessionRef) {
@Composable
private fun ClaudeMdBlock(text: String) {
WebTermCard(modifier = Modifier.fillMaxWidth()) {
// Inert monospaced block — CLAUDE.md is server content; never linkify/markdown (§8).
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
}
}
@@ -238,6 +467,17 @@ private fun ProjectDetailScreenPreview() {
claudeMd = "# CLAUDE.md\n\nProject instructions…",
)
WebTermTheme {
DetailBody(detail = detail, onOpenClaude = {})
DetailBody(
detail = detail,
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
recent = ProjectDetailViewModel.RecentCommits.Loaded(
wang.yaojia.webterm.api.models.GitLogResult(
commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")),
truncated = false,
),
),
worktree = null,
onOpenClaude = {},
)
}
}

View File

@@ -222,13 +222,21 @@ private fun ProjectCard(
}
}
project.branch?.let { branch ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(
text = branch,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
// W3 sync chip: commits ahead/behind upstream (best-effort; only shown when non-zero).
val ahead = project.ahead ?: 0
val behind = project.behind ?: 0
if (ahead > 0) Text(text = "$ahead", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
if (behind > 0) Text(text = "$behind", style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
}
}
val running = project.sessions.count { !it.exited }
if (running > 0) {

View File

@@ -13,6 +13,11 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
@@ -47,14 +52,17 @@ import java.net.URI
public class DiffViewModel(
private val fetcher: DiffFetcher,
private val path: String,
/** Guarded git-write seam (stage/commit/push). Null → the diff is inert read-only (no buttons). */
private val writer: GitWriteGateway? = null,
) {
private val _uiState = MutableStateFlow(DiffUiState())
private val _uiState = MutableStateFlow(DiffUiState(canWrite = writer != null))
/** The single snapshot `DiffScreen` renders from. */
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null
private var job: Job? = null
private var writeJob: Job? = null
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
public fun bind(scope: CoroutineScope) {
@@ -62,27 +70,46 @@ public class DiffViewModel(
reload()
}
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches. */
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches.
* No-op in base mode (the toggle is hidden there — the server ignores `staged` when `base` is set). */
public fun selectStaged(staged: Boolean) {
if (_uiState.value.base != null) return
if (_uiState.value.staged == staged) return
_uiState.value = _uiState.value.copy(staged = staged)
reload()
}
/**
* Enter/leave base mode: a non-blank [rev] diffs HEAD against that base (staged toggle suppressed,
* git-write disabled — parity with public/diff.ts); null/blank returns to the working/staged view.
*/
public fun setBase(rev: String?) {
val next = rev?.trim()?.takeIf { it.isNotEmpty() }
if (_uiState.value.base == next) return
_uiState.value = _uiState.value.copy(base = next, writeBanner = null)
reload()
}
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
public fun refresh() {
reload()
}
/** Dismiss the git-write result banner. */
public fun clearWriteBanner() {
_uiState.value = _uiState.value.copy(writeBanner = null)
}
private fun reload() {
val scope = scope ?: return
job?.cancel()
val staged = _uiState.value.staged
val base = _uiState.value.base
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
job = scope.launch {
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
val outcome = try {
Result.success(fetcher.fetch(path, staged))
Result.success(fetcher.fetch(path, staged, base))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
@@ -101,6 +128,102 @@ public class DiffViewModel(
)
}
}
// ── Git write (working/staged mode only — never in base mode, plan §Security/Edge cases) ──────
/** Stage (`staged=true`) or unstage a single file, then re-fetch so the view reflects the index. */
public fun toggleStage(newPath: String, staged: Boolean) {
runWrite { writer!!.gitStage(path, listOf(newPath), staged) }
}
/** Commit the staged changes with [message]. An empty message is rejected client-side (no I/O). */
public fun commit(message: String) {
if (message.isBlank()) {
_uiState.value = _uiState.value.copy(writeBanner = DiffWriteBanner(DiffCopy.COMMIT_EMPTY, isError = true))
return
}
runWrite { writer!!.gitCommit(path, message) }
}
/** Push the current branch to its upstream (tighter server-side rate limit; never auto-retried). */
public fun push() {
runWrite { writer!!.gitPush(path) }
}
/**
* Shared guarded-write runner: guards on write availability + base mode, sets [DiffUiState.writing],
* maps the [GitWriteOutcome] to a banner, and re-fetches the diff on success. Serialized via a single
* [writeJob] so a rapid double-tap never races.
*/
private fun <T> runWrite(op: suspend () -> GitWriteOutcome<T>) {
val scope = scope ?: return
if (writer == null || _uiState.value.base != null || _uiState.value.writing) return
writeJob?.cancel()
_uiState.value = _uiState.value.copy(writing = true, writeBanner = null)
writeJob = scope.launch {
val outcome = try {
Result.success(op())
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
val banner = outcome.fold(
onSuccess = { bannerFor(it) },
onFailure = { DiffWriteBanner(DiffCopy.writeFailed(errorDetail(it)), isError = true) },
)
_uiState.value = _uiState.value.copy(writing = false, writeBanner = banner)
if (!banner.isError) reload() // refresh the diff after a successful write
}
}
private fun <T> bannerFor(outcome: GitWriteOutcome<T>): DiffWriteBanner = when (outcome) {
is GitWriteOutcome.Ok -> DiffWriteBanner(DiffCopy.okBanner(outcome.payload), isError = false)
is GitWriteOutcome.Rejected -> DiffWriteBanner(outcome.message ?: DiffCopy.WRITE_REJECTED, isError = true)
GitWriteOutcome.RateLimited -> DiffWriteBanner(DiffCopy.RATE_LIMITED, isError = true)
}
// A thrown ApiClientError's message IS its userMessage (super(userMessage)); transport errors carry
// their own message — so message is already the display copy.
private fun errorDetail(error: Throwable): String = error.message ?: error.toString()
}
/** The guarded git-write seam DiffViewModel drives (stage/commit/push). Prod: [ApiClientGitWriteGateway]. */
public interface GitWriteGateway {
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult>
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult>
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult>
}
/** Production [GitWriteGateway] delegating to a per-host [ApiClient] (Origin stamped in :api-client). */
public class ApiClientGitWriteGateway(private val api: ApiClient) : GitWriteGateway {
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> =
api.gitStage(path, files, stage)
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
api.gitCommit(path, message)
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = api.gitPush(path)
}
/** A one-line git-write result banner. [isError] drives the colour token (green ok / red failure). */
public data class DiffWriteBanner(val message: String, val isError: Boolean)
/** User-visible git-write copy (Chinese named constants; server strings are surfaced verbatim/inert). */
public object DiffCopy {
public const val COMMIT_EMPTY: String = "请填写提交信息。"
public const val WRITE_REJECTED: String = "操作被服务器拒绝。"
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
public fun writeFailed(detail: String): String = "Git 操作失败:$detail"
/** Success banner keyed off the payload type (short sha / branch→remote / staged count). */
public fun okBanner(payload: Any?): String = when (payload) {
is StageResult -> if (payload.staged) "已暂存 ${payload.count} 个文件" else "已取消暂存 ${payload.count} 个文件"
is CommitResult -> if (payload.commit.isEmpty()) "已提交" else "已提交 ${payload.commit}"
is PushResult -> "已推送 ${payload.branch ?: "分支"}${payload.remote ?: "远端"}"
else -> "操作完成"
}
}
/** The load phase the screen renders (loading spinner / empty / error / list). */
@@ -108,14 +231,25 @@ public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }
/** The immutable snapshot the diff screen renders. */
public data class DiffUiState(
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. */
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. Ignored in base mode. */
val staged: Boolean = false,
/** Non-null = base mode: diff HEAD against this revision (staged toggle + git-write suppressed). */
val base: String? = null,
val phase: DiffPhase = DiffPhase.IDLE,
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
val rows: List<DiffRow> = emptyList(),
/** Server capped the diff (too large) — the screen shows a truncation notice. */
val truncated: Boolean = false,
)
/** True once a git-write is in flight — the screen disables the write controls. */
val writing: Boolean = false,
/** The last git-write result (ok/failure), or null. Dismissed via [DiffViewModel.clearWriteBanner]. */
val writeBanner: DiffWriteBanner? = null,
/** Whether git-write controls are offered at all (a writer gateway was supplied). */
val canWrite: Boolean = false,
) {
/** Stage/commit/push are offered only in working/staged mode with a writer bound (never base mode). */
val writeEnabled: Boolean get() = canWrite && base == null
}
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
@@ -125,10 +259,12 @@ public sealed interface DiffRow {
public val id: Long
}
/** A per-file header: the display path plus its `+added/-removed` numstat and status. */
/** A per-file header: the display path plus its `+added/-removed` numstat and status. [stagePath] is the
* file's `newPath` used verbatim for `git add`/`restore` (the display [path] may be an `old → new` rename). */
public data class DiffFileHeaderRow(
override val id: Long,
val path: String,
val stagePath: String,
val status: String,
val added: Int,
val removed: Int,
@@ -153,7 +289,7 @@ public fun flattenDiff(result: DiffResult): List<DiffRow> {
val rows = ArrayList<DiffRow>()
var id = 0L
for (file in result.files) {
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.status, file.added, file.removed))
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.newPath, file.status, file.added, file.removed))
if (file.binary) {
rows.add(DiffBinaryRow(id++))
continue
@@ -212,7 +348,13 @@ public data class DiffFile(
val hunks: List<DiffHunk>,
)
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean)
public data class DiffResult(
val files: List<DiffFile>,
val staged: Boolean,
val truncated: Boolean,
/** Echoed by the server when the diff was against a base revision (`?base=<rev>`); null otherwise. */
val base: String? = null,
)
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
private val DiffJson: Json = Json {
@@ -230,7 +372,12 @@ internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
.getOrNull() as? JsonObject
?: return DiffResult(emptyList(), staged = false, truncated = false)
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
return DiffResult(files = files, staged = root.bool("staged", false), truncated = root.bool("truncated", false))
return DiffResult(
files = files,
staged = root.bool("staged", false),
truncated = root.bool("truncated", false),
base = root.str("base"),
)
}
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
@@ -278,8 +425,12 @@ private fun JsonObject.bool(key: String, default: Boolean): Boolean =
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
public interface DiffFetcher {
/** @throws DiffUnavailable on a non-200 status; transport errors propagate. */
public suspend fun fetch(path: String, staged: Boolean): DiffResult
/**
* @param base when non-null, diff HEAD against this base revision — the server IGNORES [staged]
* in base mode (server.ts:831), so callers omit it and the screen hides the Working/Staged toggle.
* @throws DiffUnavailable on a non-200 status; transport errors propagate.
*/
public suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult
}
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
@@ -293,8 +444,8 @@ public class HttpDiffFetcher(
private val endpoint: HostEndpoint,
private val http: HttpTransport,
) : DiffFetcher {
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
return decodeDiffResult(response.body)
@@ -305,20 +456,28 @@ private const val HTTP_OK = 200
private const val HTTP_BAD_REQUEST = 400
/**
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL,
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value.
* Build `<scheme>://host[:port]/projects/diff?path=<enc>[&staged=1|0][&base=<enc>]` from the dialed
* base URL, keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). In **base mode**
* ([base] non-null/non-blank) the server ignores `staged` (server.ts:831), so `staged` is OMITTED and
* `&base=<enc>` is appended (percent-encoded; the server's `isPlausibleRev` rejects junk with a 400).
* Otherwise `staged` serializes as the literal `"1"`/`"0"` string the server matches with `=== '1'`
* (NOT a boolean). Returns null if the base URL cannot be parsed. `internal` so the JVM test asserts
* the exact query value.
*/
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? {
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean, base: String? = null): String? {
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase() ?: return null
val host = uri.host ?: return null
if (host.isEmpty()) return null
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
val portPart = if (uri.port != -1) ":${uri.port}" else ""
val stagedValue = if (staged) "1" else "0"
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue"
val prefix = "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}"
val trimmedBase = base?.trim()
return if (!trimmedBase.isNullOrEmpty()) {
"$prefix&base=${percentEncode(trimmedBase)}" // base mode: no staged (server ignores it)
} else {
"$prefix&staged=${if (staged) "1" else "0"}"
}
}
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */

View File

@@ -4,26 +4,32 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.routes.ApiClientError
/**
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`).
* # ProjectDetailViewModel (A23 + W5) — one project's detail (`GET /projects/detail?path=`), a phase
* state machine, PLUS two failure-ISOLATED side fetches: the PR + CI chip (`GET /projects/pr`) and the
* recent-commits list (`GET /projects/log`). A failure of either side fetch NEVER fails the detail load
* (each has its own StateFlow) — the chip/list simply render an unavailable state.
*
* The [fetch] closure is injected production wraps [ProjectsGateway.projectDetail] (the builder's
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a
* fake. This VM only reduces the three user-visible outcomes:
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT);
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
* The main [fetch] closure is injected (production wraps [ProjectsGateway.projectDetail]); [fetchPr] /
* [fetchLog] are optional side fetches (null → the chip/list stay [PrChip.Hidden] / [RecentCommits.Hidden]).
* [worktree] (when wired) drives the guarded create/remove/prune actions and re-fetches this detail on
* success (via [load]).
*
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it.
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
* [load] in a lifecycle scope; the retry action re-calls it.
*/
public class ProjectDetailViewModel(
public val path: String,
private val fetch: suspend () -> ProjectDetail,
private val fetchPr: (suspend () -> PrStatus)? = null,
private val fetchLog: (suspend () -> GitLogResult)? = null,
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
public val worktree: WorktreeViewModel? = null,
) {
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
@@ -35,12 +41,40 @@ public class ProjectDetailViewModel(
public data class Failed(val failure: Failure) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
/** The PR + CI chip's own state (isolated from the detail load). */
public sealed interface PrChip {
public data object Hidden : PrChip
public data object Loading : PrChip
public data class Loaded(val status: PrStatus) : PrChip
public data object Unavailable : PrChip
}
/** The single snapshot `ProjectDetailScreen` renders from. */
/** The recent-commits section's own state (isolated from the detail load). */
public sealed interface RecentCommits {
public data object Hidden : RecentCommits
public data object Loading : RecentCommits
public data class Loaded(val result: GitLogResult) : RecentCommits
public data object Unavailable : RecentCommits
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
/** The main detail snapshot `ProjectDetailScreen` renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */
/** The PR chip snapshot (renders one chip from [PrStatus.availability]). */
public val prChip: StateFlow<PrChip> = _prChip.asStateFlow()
/** The recent-commits snapshot. */
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
/**
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
* the detail load nor each other).
*/
public suspend fun load() {
_phase.value = Phase.Loading
_phase.value = try {
@@ -53,6 +87,34 @@ public class ProjectDetailViewModel(
// Transport/decode etc. — a retryable catch-all.
Phase.Failed(Failure.UNAVAILABLE)
}
if (_phase.value is Phase.Loaded) {
loadPr()
loadRecentCommits()
}
}
private suspend fun loadPr() {
val fetcher = fetchPr ?: return
_prChip.value = PrChip.Loading
_prChip.value = try {
PrChip.Loaded(fetcher())
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
PrChip.Unavailable // isolated: a PR fetch failure never touches the detail phase
}
}
private suspend fun loadRecentCommits() {
val fetcher = fetchLog ?: return
_recentCommits.value = RecentCommits.Loading
_recentCommits.value = try {
RecentCommits.Loaded(fetcher())
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
RecentCommits.Unavailable
}
}
private fun failureFor(error: ApiClientError): Failure = when (error) {
@@ -62,8 +124,22 @@ public class ProjectDetailViewModel(
}
public companion object {
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel =
ProjectDetailViewModel(path) { gateway.projectDetail(path) }
/**
* Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). Wires the
* detail + PR + log fetches and a [WorktreeViewModel] whose successes re-fetch this detail.
*/
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel {
var self: ProjectDetailViewModel? = null
val worktree = WorktreeViewModel(gateway, path, onChanged = { self?.load() })
val vm = ProjectDetailViewModel(
path = path,
fetch = { gateway.projectDetail(path) },
fetchPr = { gateway.projectPr(path) },
fetchLog = { gateway.projectLog(path, null) },
worktree = worktree,
)
self = vm
return vm
}
}
}

View File

@@ -5,8 +5,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
@@ -396,12 +402,26 @@ public data class ProjectsUiState(
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
/** Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses. */
/**
* Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses.
* The W5 additions (PR / recent commits / worktree create-remove-prune) let the detail page and the
* [WorktreeViewModel] stay JVM-tested against a fake; the guarded worktree writes flow through the
* :api-client Origin-stamping point (plan §Security).
*/
public interface ProjectsGateway {
public suspend fun projects(): List<ProjectInfo>
public suspend fun prefs(): UiPrefs
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
public suspend fun projectDetail(path: String): ProjectDetail
// ── W5: read-only PR + recent commits ──────────────────────────────────────────────────
public suspend fun projectPr(path: String): PrStatus
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult>
}
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
@@ -410,6 +430,15 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
override suspend fun prefs(): UiPrefs = api.prefs()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
api.createWorktree(path, branch, base)
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> =
api.removeWorktree(path, worktreePath, force)
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> = api.pruneWorktrees(path)
}
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */

View File

@@ -0,0 +1,149 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* # WorktreeViewModel (W5) — the guarded worktree write actions for one project.
*
* A phase machine (`Idle → Working → Done | Failed`) over the three guarded routes
* (`POST /projects/worktree`, `DELETE /projects/worktree`, `POST /projects/worktree/prune`), all
* flowing through the :api-client Origin-stamping point (plan §Security). On a successful op it invokes
* [onChanged] so the detail screen re-fetches and the worktree list refreshes.
*
* ### Defense in depth (UX, not the security boundary)
* The branch name is pre-validated client-side ([isValidBranchName], a mirror of the server's
* `validateBranchName`) so an obviously bad name fails with NO network I/O; a **main** worktree removal
* is blocked client-side ([WorktreeInfo.isMain]) — the server re-validates + realpath-contains
* regardless. Server `error` strings (disabled kill-switch, "uncommitted changes; force required") are
* surfaced INERT (plain text; never linkified).
*
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
* the suspend actions from a lifecycle scope; [reset] clears a settled banner back to [Phase.Idle].
*/
public class WorktreeViewModel(
private val gateway: ProjectsGateway,
private val repoPath: String,
/** Invoked after any successful write so the detail page re-fetches (list refresh). */
private val onChanged: suspend () -> Unit = {},
) {
/** The action phase the screen renders (idle / spinner / success banner / failure banner). */
public sealed interface Phase {
public data object Idle : Phase
public data object Working : Phase
public data class Done(val message: String) : Phase
public data class Failed(val message: String) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Idle)
/** The single snapshot the worktree sheet/dialog renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Create a worktree for [branch] (off optional [base]). Invalid branch → [Phase.Failed], no I/O. */
public suspend fun create(branch: String, base: String? = null) {
val trimmed = branch.trim()
if (!isValidBranchName(trimmed)) {
_phase.value = Phase.Failed(WorktreeCopy.INVALID_BRANCH)
return
}
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
val cleanBase = base?.trim()?.takeIf { it.isNotEmpty() }
_phase.value = runOp { gateway.createWorktree(repoPath, trimmed, cleanBase) }
}
/** Remove [worktree] ([force] to discard uncommitted changes). A **main** worktree is blocked here. */
public suspend fun remove(worktree: WorktreeInfo, force: Boolean) {
if (worktree.isMain) {
_phase.value = Phase.Failed(WorktreeCopy.CANNOT_REMOVE_MAIN)
return
}
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
_phase.value = runOp { gateway.removeWorktree(repoPath, worktree.path, force) }
}
/** Reclaim stale worktree admin dirs (idempotent). */
public suspend fun prune() {
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
_phase.value = runOp { gateway.pruneWorktrees(repoPath) }
}
/** Clear a settled banner (Done/Failed) back to Idle after the user dismisses it. */
public fun reset() {
_phase.value = Phase.Idle
}
/**
* Run one guarded write, mapping its [GitWriteOutcome] to a phase. On success it re-fetches the
* detail (via [onChanged]) BEFORE settling to [Phase.Done] so the list is fresh when the banner shows.
*/
private suspend fun <T> runOp(op: suspend () -> GitWriteOutcome<T>): Phase {
val outcome = try {
op()
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return Phase.Failed(WorktreeCopy.failed(error.message ?: error.toString()))
}
return when (outcome) {
is GitWriteOutcome.Ok -> {
runCatching { onChanged() } // a refresh failure must not turn a successful write into a failure
Phase.Done(WorktreeCopy.okMessage(outcome.payload))
}
is GitWriteOutcome.Rejected -> Phase.Failed(outcome.message ?: WorktreeCopy.REJECTED)
GitWriteOutcome.RateLimited -> Phase.Failed(WorktreeCopy.RATE_LIMITED)
}
}
public companion object {
/** Longest branch name the server accepts (`src/http/worktrees.ts` `MAX_BRANCH_LEN`). */
private const val MAX_BRANCH_LEN = 250
/** Mirror of the server's `FORBIDDEN_BRANCH_CHARS`: control/DEL, whitespace, `~^:?*[\`. */
private val FORBIDDEN_BRANCH_CHARS = Regex("[\\u0000-\\u001f\\u007f\\s~^:?*\\[\\\\]")
/**
* Client-side mirror of `validateBranchName` (worktrees.ts:95) — a fast UX pre-check ONLY; the
* server re-validates. Rejects empty/overlong, leading `-`, bad slashes, `..`, `.lock`/trailing
* `.`, `@{`, and any forbidden char.
*/
public fun isValidBranchName(branch: String): Boolean {
if (branch.isEmpty() || branch.length > MAX_BRANCH_LEN) return false
if (branch.startsWith("-")) return false
if (branch.startsWith("/") || branch.endsWith("/") || branch.contains("//")) return false
if (branch.contains("..")) return false
if (branch.endsWith(".lock") || branch.endsWith(".")) return false
if (branch.contains("@{")) return false
if (FORBIDDEN_BRANCH_CHARS.containsMatchIn(branch)) return false
return true
}
}
}
/** User-visible worktree-action copy (Chinese named constants; server strings surfaced inert). */
public object WorktreeCopy {
public const val INVALID_BRANCH: String = "分支名不合法(含非法字符或格式)。"
public const val CANNOT_REMOVE_MAIN: String = "不能删除主工作树。"
public const val REJECTED: String = "操作被服务器拒绝。"
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
public fun failed(detail: String): String = "工作树操作失败:$detail"
public fun okMessage(payload: Any?): String = when (payload) {
is CreateWorktreeResult -> "已创建工作树 ${payload.branch ?: ""}".trim()
is RemoveWorktreeResult -> "已删除工作树"
is PruneWorktreesResult ->
if (payload.pruned.isEmpty()) "没有可清理的工作树" else "已清理 ${payload.pruned.size} 个工作树"
else -> "操作完成"
}
}

View File

@@ -6,8 +6,14 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
/**
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
@@ -101,8 +107,10 @@ class DiffViewModelTest {
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
val bases = mutableListOf<String?>() // records the base arg of each fetch
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
calls += staged
bases += base
error?.let { throw it }
return result!!
}
@@ -154,4 +162,133 @@ class DiffViewModelTest {
assertTrue(vm.uiState.value.staged)
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
}
// ── diffUrl base mode (Phase B) ───────────────────────────────────────────────────────────────
@Test
fun `diffUrl appends staged in working mode and base (omitting staged) in base mode`() {
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&staged=1",
diffUrl("http://h:3000", "/repo", staged = true, base = null),
)
// base mode: no staged param, base percent-encoded.
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&base=feature%2Fx",
diffUrl("http://h:3000", "/repo", staged = true, base = "feature/x"),
)
// a blank base is treated as working mode.
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&staged=0",
diffUrl("http://h:3000", "/repo", staged = false, base = " "),
)
}
// ── DiffViewModel base mode (Phase B) ─────────────────────────────────────────────────────────
@Test
fun `setBase enters base mode, threads base to the fetcher, and suppresses the staged toggle`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val vm = DiffViewModel(fetcher, "/repo")
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.setBase("main"); advanceUntilIdle()
assertEquals("main", vm.uiState.value.base)
assertEquals(listOf(null, "main"), fetcher.bases) // base threaded on the re-fetch
// In base mode the staged toggle is a no-op (server ignores staged when base is set).
vm.selectStaged(true); advanceUntilIdle()
assertFalse(vm.uiState.value.staged)
assertEquals(2, fetcher.calls.size, "selectStaged must not re-fetch in base mode")
// Leaving base mode returns to the working/staged view.
vm.setBase(null); advanceUntilIdle()
assertNull(vm.uiState.value.base)
assertEquals(listOf(null, "main", null), fetcher.bases)
}
// ── DiffViewModel git-write (Phase C) ─────────────────────────────────────────────────────────
private class FakeWriter(
var stage: GitWriteOutcome<StageResult> = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)),
var commit: GitWriteOutcome<CommitResult> = GitWriteOutcome.Ok(CommitResult(commit = "abc123")),
var push: GitWriteOutcome<PushResult> = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")),
) : GitWriteGateway {
val stageCalls = mutableListOf<Triple<String, List<String>, Boolean>>()
var commitCalls = 0; var pushCalls = 0
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> {
stageCalls += Triple(path, files, stage); return this.stage
}
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> { commitCalls++; return commit }
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> { pushCalls++; return push }
}
@Test
fun `toggleStage posts the file and refreshes the diff`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.toggleStage("src/A.kt", staged = true); advanceUntilIdle()
assertEquals(Triple("/repo", listOf("src/A.kt"), true), writer.stageCalls.single())
assertEquals(2, fetcher.calls.size, "a successful stage must refresh the diff")
assertEquals(false, vm.uiState.value.writeBanner?.isError)
}
@Test
fun `commit surfaces an Ok banner and an empty message is rejected client-side with no I O`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.commit(" "); advanceUntilIdle() // blank → client-side reject
assertEquals(0, writer.commitCalls, "a blank commit message must not hit the network")
assertEquals(true, vm.uiState.value.writeBanner?.isError)
vm.commit("real message"); advanceUntilIdle()
assertEquals(1, writer.commitCalls)
assertEquals(false, vm.uiState.value.writeBanner?.isError)
assertTrue(vm.uiState.value.writeBanner!!.message.contains("abc123"))
}
@Test
fun `push maps a 409 rejection to the inert server message and does not refresh`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter(push = GitWriteOutcome.Rejected(409, "Push rejected: remote has diverged."))
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.push(); advanceUntilIdle()
assertEquals(1, writer.pushCalls)
assertEquals(true, vm.uiState.value.writeBanner?.isError)
assertEquals("Push rejected: remote has diverged.", vm.uiState.value.writeBanner?.message)
assertEquals(1, fetcher.calls.size, "a failed push must NOT refresh the diff")
}
@Test
fun `git-write is disabled in base mode`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.setBase("main"); advanceUntilIdle()
vm.toggleStage("a.kt", true); vm.commit("m"); vm.push(); advanceUntilIdle()
assertTrue(writer.stageCalls.isEmpty() && writer.commitCalls == 0 && writer.pushCalls == 0)
assertFalse(vm.uiState.value.writeEnabled)
}
@Test
fun `writeEnabled is false without a writer and true with one in working mode`() {
assertFalse(DiffUiState(canWrite = false).writeEnabled)
assertTrue(DiffUiState(canWrite = true).writeEnabled)
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
}
}

View File

@@ -0,0 +1,68 @@
package wang.yaojia.webterm.viewmodels
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
/**
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
* ProjectDetailViewModel PR/log). Records the guarded-write call args and returns canned outcomes;
* PR/log return canned values or throw to exercise failure-isolation. The list-page methods
* (projects/prefs) are unused here and throw if called.
*/
class FakeWorktreeGateway(
private val detail: ProjectDetail? = null,
private val prResult: PrStatus? = null,
private val prThrows: Boolean = false,
private val logResult: GitLogResult? = null,
private val logThrows: Boolean = false,
private val createOutcome: GitWriteOutcome<CreateWorktreeResult> = GitWriteOutcome.Ok(CreateWorktreeResult()),
private val removeOutcome: GitWriteOutcome<RemoveWorktreeResult> = GitWriteOutcome.Ok(RemoveWorktreeResult()),
private val pruneOutcome: GitWriteOutcome<PruneWorktreesResult> = GitWriteOutcome.Ok(PruneWorktreesResult()),
) : ProjectsGateway {
val createCalls = mutableListOf<Triple<String, String, String?>>()
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
val pruneCalls = mutableListOf<String>()
var detailCalls = 0
private set
override suspend fun projects(): List<ProjectInfo> = throw NotImplementedError()
override suspend fun prefs(): UiPrefs = throw NotImplementedError()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = throw NotImplementedError()
override suspend fun projectDetail(path: String): ProjectDetail {
detailCalls++
return detail ?: throw NotImplementedError("no detail configured")
}
override suspend fun projectPr(path: String): PrStatus {
if (prThrows) throw RuntimeException("pr unavailable")
return prResult ?: throw NotImplementedError("no pr configured")
}
override suspend fun projectLog(path: String, n: Int?): GitLogResult {
if (logThrows) throw RuntimeException("log unavailable")
return logResult ?: throw NotImplementedError("no log configured")
}
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
createCalls += Triple(path, branch, base)
return createOutcome
}
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> {
removeCalls += Triple(path, worktreePath, force)
return removeOutcome
}
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> {
pruneCalls += path
return pruneOutcome
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
/**
* W5 ProjectDetailViewModel side fetches (JVM). The PR chip and recent-commits list are failure-
* ISOLATED: a failure of either NEVER fails the detail load nor the other; a non-`ok` availability
* renders a degraded (but Loaded) chip; the commit list decodes into its own state.
*/
class ProjectDetailPrLogTest {
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "main")
@Test
fun `detail plus PR plus log all load`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.OK, number = 7, title = "A PR"),
logResult = GitLogResult(commits = listOf(CommitLogEntry("h", 1, "s")), truncated = false),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
assertEquals(PrAvailability.OK, chip.status.availability)
val commits = vm.recentCommits.value as ProjectDetailViewModel.RecentCommits.Loaded
assertEquals(1, commits.result.commits.size)
}
@Test
fun `a PR fetch failure does not fail the detail load nor the log`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prThrows = true,
logResult = GitLogResult(commits = emptyList(), truncated = false),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "detail must still load")
assertEquals(ProjectDetailViewModel.PrChip.Unavailable, vm.prChip.value)
assertTrue(vm.recentCommits.value is ProjectDetailViewModel.RecentCommits.Loaded, "log stays isolated")
}
@Test
fun `a log fetch failure isolates to the recent-commits state only`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.NO_PR),
logThrows = true,
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
assertEquals(ProjectDetailViewModel.RecentCommits.Unavailable, vm.recentCommits.value)
// A non-ok availability is still a Loaded chip (degraded copy is a render concern).
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
assertEquals(PrAvailability.NO_PR, chip.status.availability)
}
@Test
fun `the wired worktree VM shares the repo path and refreshes the detail on a successful create`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.DISABLED),
logResult = GitLogResult(),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
val detailCallsAfterLoad = gateway.detailCalls
vm.worktree!!.create("feat/x")
assertTrue(gateway.detailCalls > detailCallsAfterLoad, "create success re-fetches the detail")
assertEquals("/repo", gateway.createCalls.single().first)
}
}

View File

@@ -202,6 +202,11 @@ class ProjectsViewModelTest {
}
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
override suspend fun projectPr(path: String) = throw NotImplementedError()
override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
}
private fun proj(

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* W5 WorktreeViewModel (JVM). The guarded worktree write phase machine: client-side branch validation
* (no I/O on a bad name), main-worktree removal blocked client-side, the force flag threaded, and the
* server's SAFE error strings (disabled 403 / 429) surfaced inertly. On success it re-fetches the detail.
*/
class WorktreeViewModelTest {
@Test
fun `an invalid branch name fails with no network I O`() = runTest {
val gateway = FakeWorktreeGateway()
val vm = WorktreeViewModel(gateway, "/repo")
vm.create("bad branch~name") // whitespace + '~' are forbidden
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Failed)
assertEquals(WorktreeCopy.INVALID_BRANCH, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
assertEquals(0, gateway.createCalls.size, "an invalid branch must never hit the network")
}
@Test
fun `a leading dash and dotdot and trailing dot are all rejected client-side`() {
assertTrue(WorktreeViewModel.isValidBranchName("feat/ok-name"))
assertTrue(WorktreeViewModel.isValidBranchName("release/1.2.x"))
listOf("-flag", "a..b", "ends.", "has space", "a~b", "a:b", "@{now}", "", "//x", "/lead", "trail/").forEach {
assertTrue(!WorktreeViewModel.isValidBranchName(it), "should reject '$it'")
}
}
@Test
fun `create success settles Done and re-fetches the detail`() = runTest {
val gateway = FakeWorktreeGateway(
createOutcome = GitWriteOutcome.Ok(CreateWorktreeResult(path = "/repo-worktrees/feat", branch = "feat/x")),
)
var refreshes = 0
val vm = WorktreeViewModel(gateway, "/repo", onChanged = { refreshes++ })
vm.create("feat/x", base = "main")
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
assertEquals(1, refreshes, "a successful create must re-fetch the detail")
assertEquals(Triple("/repo", "feat/x", "main"), gateway.createCalls.single())
}
@Test
fun `removing a main worktree is blocked client-side with no I O`() = runTest {
val gateway = FakeWorktreeGateway()
val vm = WorktreeViewModel(gateway, "/repo")
vm.remove(WorktreeInfo(path = "/repo", branch = "main", isMain = true), force = false)
assertEquals(WorktreeCopy.CANNOT_REMOVE_MAIN, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
assertEquals(0, gateway.removeCalls.size)
}
@Test
fun `remove threads the force flag`() = runTest {
val gateway = FakeWorktreeGateway(removeOutcome = GitWriteOutcome.Ok(RemoveWorktreeResult(path = "/wt/x")))
val vm = WorktreeViewModel(gateway, "/repo")
vm.remove(WorktreeInfo(path = "/wt/x", branch = "feat", isMain = false), force = true)
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
assertEquals(Triple("/repo", "/wt/x", true), gateway.removeCalls.single())
}
@Test
fun `a 403 disabled rejection surfaces the safe server message inertly`() = runTest {
val gateway = FakeWorktreeGateway(
createOutcome = GitWriteOutcome.Rejected(403, "Worktree creation is disabled."),
)
val vm = WorktreeViewModel(gateway, "/repo")
vm.create("feat/x")
assertEquals("Worktree creation is disabled.", (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
}
@Test
fun `a 429 rate-limit surfaces the rate-limited copy`() = runTest {
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.RateLimited)
val vm = WorktreeViewModel(gateway, "/repo")
vm.prune()
assertEquals(WorktreeCopy.RATE_LIMITED, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
}
@Test
fun `prune with nothing to reclaim reports an empty result`() = runTest {
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.Ok(PruneWorktreesResult(pruned = emptyList())))
val vm = WorktreeViewModel(gateway, "/repo")
vm.prune()
val done = vm.phase.value as WorktreeViewModel.Phase.Done
assertTrue(done.message.contains("没有"))
}
}