From bc31de85dd5cf7f188ef0df2795ab59ddb11aabc Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 13 Jul 2026 05:58:28 +0200 Subject: [PATCH] feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- android/DEVICE_QA_CHECKLIST.md | 19 ++ android/README.md | 36 +-- .../wang/yaojia/webterm/api/models/GitLog.kt | 32 ++ .../yaojia/webterm/api/models/GitWrite.kt | 71 ++++ .../yaojia/webterm/api/models/PrStatus.kt | 88 +++++ .../yaojia/webterm/api/models/Projects.kt | 6 + .../yaojia/webterm/api/routes/ApiClient.kt | 92 ++++++ .../webterm/api/routes/ApiClientError.kt | 3 + .../yaojia/webterm/api/routes/ApiRoute.kt | 4 + .../yaojia/webterm/api/routes/Endpoints.kt | 92 ++++++ .../yaojia/webterm/api/models/GitLogTest.kt | 64 ++++ .../yaojia/webterm/api/models/GitWriteTest.kt | 71 ++++ .../yaojia/webterm/api/models/PrStatusTest.kt | 77 +++++ .../webterm/api/routes/ApiClientGitTest.kt | 112 +++++++ .../webterm/api/routes/GitRouteShapeTest.kt | 161 ++++++++++ .../java/wang/yaojia/webterm/nav/Panes.kt | 12 +- .../wang/yaojia/webterm/nav/ProjectsHome.kt | 1 + .../wang/yaojia/webterm/screens/DiffScreen.kt | 170 ++++++---- .../webterm/screens/ProjectDetailScreen.kt | 304 ++++++++++++++++-- .../yaojia/webterm/screens/ProjectsScreen.kt | 22 +- .../webterm/viewmodels/DiffViewModel.kt | 199 ++++++++++-- .../viewmodels/ProjectDetailViewModel.kt | 108 ++++++- .../webterm/viewmodels/ProjectsViewModel.kt | 31 +- .../webterm/viewmodels/WorktreeViewModel.kt | 149 +++++++++ .../webterm/viewmodels/DiffViewModelTest.kt | 139 +++++++- .../webterm/viewmodels/FakeWorktreeGateway.kt | 68 ++++ .../viewmodels/ProjectDetailPrLogTest.kt | 90 ++++++ .../viewmodels/ProjectsViewModelTest.kt | 5 + .../viewmodels/WorktreeViewModelTest.kt | 110 +++++++ 29 files changed, 2180 insertions(+), 156 deletions(-) create mode 100644 android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt create mode 100644 android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt create mode 100644 android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PrStatus.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/GitLogTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/GitWriteTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PrStatusTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientGitTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/GitRouteShapeTest.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/WorktreeViewModel.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailPrLogTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/WorktreeViewModelTest.kt diff --git a/android/DEVICE_QA_CHECKLIST.md b/android/DEVICE_QA_CHECKLIST.md index e946410..060b3b6 100644 --- a/android/DEVICE_QA_CHECKLIST.md +++ b/android/DEVICE_QA_CHECKLIST.md @@ -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 ` + 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. diff --git a/android/README.md b/android/README.md index ba1859a..0a0a431 100644 --- a/android/README.md +++ b/android/README.md @@ -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 ``` diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt new file mode 100644 index 0000000..5a17990 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitLog.kt @@ -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 = emptyList(), + val truncated: Boolean = false, +) + +/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */ +internal object CommitLogEntryListSerializer : + KSerializer> by LossyListSerializer(CommitLogEntry.serializer()) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt new file mode 100644 index 0000000..e543ea1 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/GitWrite.kt @@ -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 { + /** 200 — the op succeeded; [payload] is the route-specific success body. */ + public data class Ok(val payload: T) : GitWriteOutcome + + /** 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 + + /** 429 — the server rate-limited this write. */ + public data object RateLimited : GitWriteOutcome +} + +// ── 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 = 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 decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer): 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 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PrStatus.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PrStatus.kt new file mode 100644 index 0000000..d05cb54 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/PrStatus.kt @@ -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 { + 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, +) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt index 6595ea3..a96395d 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/Projects.kt @@ -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 = emptyList(), ) diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt index 0bed89b..60005c4 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -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 = + 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 = + gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer()) + + /** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */ + public suspend fun pruneWorktrees(path: String): GitWriteOutcome = + 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, stage: Boolean = true): GitWriteOutcome = + 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 = + 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 = + 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 gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer): GitWriteOutcome { + 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) { diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt index 3d20486..3bd6d2f 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt @@ -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。") } diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt index f3f599b..cbddb4c 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt @@ -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). */ diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt index b37274f..21ac018 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -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=]` — 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, 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 jsonBodyRoute( + method: HttpMethod, + path: String, + serializer: kotlinx.serialization.KSerializer, + 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, val stage: Boolean) + + @Serializable + private data class CommitBody(val path: String, val message: String) + + @Serializable + private data class PushBody(val path: String) } diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/GitLogTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/GitLogTest.kt new file mode 100644 index 0000000..95e4853 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/GitLogTest.kt @@ -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")) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/GitWriteTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/GitWriteTest.kt new file mode 100644 index 0000000..7a1186f --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/GitWriteTest.kt @@ -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())) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PrStatusTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PrStatusTest.kt new file mode 100644 index 0000000..a3f6df1 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/models/PrStatusTest.kt @@ -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) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientGitTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientGitTest.kt new file mode 100644 index 0000000..98097ae --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/ApiClientGitTest.kt @@ -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()) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/GitRouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/GitRouteShapeTest.kt new file mode 100644 index 0000000..c9ae1f5 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/GitRouteShapeTest.kt @@ -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) }) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt index 9305f74..5f22814 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt @@ -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) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/ProjectsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/ProjectsHome.kt index c0102ae..97082a9 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/ProjectsHome.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/ProjectsHome.kt @@ -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("选择一个项目查看详情。") diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/DiffScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/DiffScreen.kt index 761546f..9d5ee69 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/DiffScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/DiffScreen.kt @@ -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") } + 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)) + 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("清除") } } - 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") }, - ) } } @Composable -private fun DiffList(rows: List) { +private fun DiffList( + rows: List, + 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) { } @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( - 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 = {}, ) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt index 969da40..094abc2 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt @@ -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 项目专属指令。") - TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") } + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { + TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") } + if (detail.isGit) TextButton(onClick = { onViewDiff(detail.path) }) { Text("查看改动 (diff)") } + } + } +} + +// ── 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(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 = {}, + ) } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectsScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectsScreen.kt index c300888..beafcb9 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectsScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectsScreen.kt @@ -222,13 +222,21 @@ private fun ProjectCard( } } project.branch?.let { branch -> - Text( - text = branch, - style = WebTermType.metaMono, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + 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) { diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt index fa4475a..b7b88ed 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt @@ -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 = _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 runWrite(op: suspend () -> GitWriteOutcome) { + 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 bannerFor(outcome: GitWriteOutcome): 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, stage: Boolean): GitWriteOutcome + public suspend fun gitCommit(path: String, message: String): GitWriteOutcome + public suspend fun gitPush(path: String): GitWriteOutcome +} + +/** 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, stage: Boolean): GitWriteOutcome = + api.gitStage(path, files, stage) + + override suspend fun gitCommit(path: String, message: String): GitWriteOutcome = + api.gitCommit(path, message) + + override suspend fun gitPush(path: String): GitWriteOutcome = 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 = 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 { val rows = ArrayList() 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, ) -public data class DiffResult(val files: List, val staged: Boolean, val truncated: Boolean) +public data class DiffResult( + val files: List, + val staged: Boolean, + val truncated: Boolean, + /** Echoed by the server when the diff was against a base revision (`?base=`); 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 `://host[:port]/projects/diff?path=&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 `://host[:port]/projects/diff?path=[&staged=1|0][&base=]` 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=` 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). */ diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt index 8ccc9d5..2ac6b27 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt @@ -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.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.Loading) + private val _prChip = MutableStateFlow(PrChip.Hidden) + private val _recentCommits = MutableStateFlow(RecentCommits.Hidden) + + /** The main detail snapshot `ProjectDetailScreen` renders from. */ public val phase: StateFlow = _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.asStateFlow() + + /** The recent-commits snapshot. */ + public val recentCommits: StateFlow = _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 + } } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt index be41fe9..ef6105e 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt @@ -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 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 + public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome + public suspend fun pruneWorktrees(path: String): GitWriteOutcome } /** 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 = + api.createWorktree(path, branch, base) + + override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome = + api.removeWorktree(path, worktreePath, force) + + override suspend fun pruneWorktrees(path: String): GitWriteOutcome = api.pruneWorktrees(path) } /** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */ diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/WorktreeViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/WorktreeViewModel.kt new file mode 100644 index 0000000..c8d351a --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/WorktreeViewModel.kt @@ -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.Idle) + + /** The single snapshot the worktree sheet/dialog renders from. */ + public val phase: StateFlow = _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 runOp(op: suspend () -> GitWriteOutcome): 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 -> "操作完成" + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt index 5e8ee28..f800be2 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt @@ -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() // records the staged arg of each fetch - override suspend fun fetch(path: String, staged: Boolean): DiffResult { + val bases = mutableListOf() // 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 = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)), + var commit: GitWriteOutcome = GitWriteOutcome.Ok(CommitResult(commit = "abc123")), + var push: GitWriteOutcome = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")), + ) : GitWriteGateway { + val stageCalls = mutableListOf, Boolean>>() + var commitCalls = 0; var pushCalls = 0 + override suspend fun gitStage(path: String, files: List, stage: Boolean): GitWriteOutcome { + stageCalls += Triple(path, files, stage); return this.stage + } + override suspend fun gitCommit(path: String, message: String): GitWriteOutcome { commitCalls++; return commit } + override suspend fun gitPush(path: String): GitWriteOutcome { 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) + } } diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt new file mode 100644 index 0000000..85716bf --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/FakeWorktreeGateway.kt @@ -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 = GitWriteOutcome.Ok(CreateWorktreeResult()), + private val removeOutcome: GitWriteOutcome = GitWriteOutcome.Ok(RemoveWorktreeResult()), + private val pruneOutcome: GitWriteOutcome = GitWriteOutcome.Ok(PruneWorktreesResult()), +) : ProjectsGateway { + val createCalls = mutableListOf>() + val removeCalls = mutableListOf>() + val pruneCalls = mutableListOf() + var detailCalls = 0 + private set + + override suspend fun projects(): List = 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 { + createCalls += Triple(path, branch, base) + return createOutcome + } + + override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome { + removeCalls += Triple(path, worktreePath, force) + return removeOutcome + } + + override suspend fun pruneWorktrees(path: String): GitWriteOutcome { + pruneCalls += path + return pruneOutcome + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailPrLogTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailPrLogTest.kt new file mode 100644 index 0000000..819b328 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectDetailPrLogTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt index 20ed3af..67d7f90 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt @@ -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( diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/WorktreeViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/WorktreeViewModelTest.kt new file mode 100644 index 0000000..ecbee00 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/WorktreeViewModelTest.kt @@ -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("没有")) + } +}