feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5)
Brings the native Android client to parity with the web/iOS Projects + git surface, consuming the existing server endpoints — ZERO server change. (The "SDK-gated modules" premise was stale; they were already online.) - :api-client (pure Kotlin/JVM): PrStatus/GitLog/GitWrite models (tolerant decoders, safe-error), ProjectInfo + ahead/behind/lastCommitMs; 8 new Endpoints builders (projectPr/projectLog read-only; createWorktree/removeWorktree/prune/gitStage/ gitCommit/gitPush guarded w/ Origin) + ApiClient methods (PR/log degrade in body; writes 200→Ok / 429→RateLimited / 4xx-5xx→Rejected(safe msg)). - :app presenters (JVM-tested): DiffViewModel base-compare + git stage/commit/push; new WorktreeViewModel (create/remove/prune + client branch validation + isMain block); ProjectDetailViewModel failure-isolated PR-chip + recent-commits. - Compose screens wired: ProjectDetail (PR chip tappable only for https, recent commits, worktree create/remove-with-force/prune, view-diff), Diff (base input, per-file stage/unstage, commit/push bar), Projects (ahead/behind sync chip). Nav closes the pre-existing "no inbound link to the diff screen" gap. Verified independently: `./gradlew :app:assembleDebug test :api-client:koverVerify` BUILD SUCCESSFUL; 348 unit tests pass (api-client 102 / app 246); coverage gate held; git status android-only. Compose rendering/interaction deferred to on-device QA (android/DEVICE_QA_CHECKLIST.md), as prior Android waves did.
This commit is contained in:
@@ -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` +
|
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
||||||
|
|
||||||
|
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
|
||||||
|
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
|
||||||
|
no upstream (fields absent).
|
||||||
|
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
|
||||||
|
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
|
||||||
|
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
|
||||||
|
(fail=red / pending=amber / pass=green).
|
||||||
|
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
|
||||||
|
log failure does NOT hide the rest of the detail (failure-isolated).
|
||||||
|
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
|
||||||
|
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
|
||||||
|
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
|
||||||
|
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
|
||||||
|
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
|
||||||
|
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
|
||||||
|
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
|
||||||
|
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
|
||||||
|
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
|
||||||
|
|
||||||
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
|
## 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
|
- [ ] 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.
|
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
||||||
|
|||||||
@@ -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
|
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||||
upward* (ARCHITECTURE §1).
|
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
|
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
|
||||||
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
|
alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
|
||||||
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
|
against SDK 35/36.
|
||||||
|
|
||||||
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
|
- **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
|
||||||
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
|
`:client-tls`, `:test-support`, `:transport-okhttp`.
|
||||||
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
|
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
||||||
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
|
|
||||||
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
||||||
|
|
||||||
To bring the Android modules online later: install an SDK, add
|
Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`;
|
||||||
`local.properties` → `sdk.dir`, add the Android Gradle Plugin + `google()` to
|
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
|
||||||
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
|
`./gradlew test :app:assembleDebug koverVerify`.
|
||||||
each stub.
|
|
||||||
|
|
||||||
## Module map (mirror of the iOS SPM packages — plan §3)
|
## Module map (mirror of the iOS SPM packages — plan §3)
|
||||||
|
|
||||||
@@ -35,10 +33,10 @@ each stub.
|
|||||||
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
||||||
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
||||||
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
||||||
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
|
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
|
||||||
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
|
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
|
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
|
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
||||||
|
|
||||||
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
|
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
|
||||||
> impls, JVM) is owned by task **A7** and will be added then. The iOS
|
> 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")
|
### Dependency graph (arrows = "depends on")
|
||||||
|
|
||||||
```
|
```
|
||||||
:app (SDK-gated)
|
:app
|
||||||
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
||||||
▼ ▼ ▼ ▼ ▼
|
▼ ▼ ▼ ▼ ▼
|
||||||
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
||||||
(SDK-gated) │ │ (SDK-gated) │
|
│ │ │ │
|
||||||
│ │ │ ▼
|
│ │ │ ▼
|
||||||
│ │ │ :client-tls (pure)
|
│ │ │ :client-tls (pure)
|
||||||
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
||||||
▼ ▼
|
▼ ▼
|
||||||
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
|
:wire-protocol ◀──────────── :transport-okhttp
|
||||||
▲
|
▲
|
||||||
└──────── :test-support → test source sets only
|
└──────── :test-support → test source sets only
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
|
||||||
|
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
|
||||||
|
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
|
||||||
|
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class CommitLogEntry(
|
||||||
|
val hash: String,
|
||||||
|
val at: Long,
|
||||||
|
val subject: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
|
||||||
|
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class GitLogResult(
|
||||||
|
@Serializable(with = CommitLogEntryListSerializer::class)
|
||||||
|
val commits: List<CommitLogEntry> = emptyList(),
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
|
||||||
|
internal object CommitLogEntryListSerializer :
|
||||||
|
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
|
||||||
|
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
|
||||||
|
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
|
||||||
|
*
|
||||||
|
* - [Ok] — a 200 with the op's route-specific payload [T].
|
||||||
|
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
|
||||||
|
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
|
||||||
|
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
|
||||||
|
* variant (plan Edge cases / Security).
|
||||||
|
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
|
||||||
|
*
|
||||||
|
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
|
||||||
|
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
|
||||||
|
*/
|
||||||
|
public sealed interface GitWriteOutcome<out T> {
|
||||||
|
/** 200 — the op succeeded; [payload] is the route-specific success body. */
|
||||||
|
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
|
||||||
|
|
||||||
|
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
|
||||||
|
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
|
||||||
|
|
||||||
|
/** 429 — the server rate-limited this write. */
|
||||||
|
public data object RateLimited : GitWriteOutcome<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
|
||||||
|
@Serializable
|
||||||
|
public data class CommitResult(val commit: String = "")
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class PushResult(val branch: String? = null, val remote: String? = null)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
|
||||||
|
@Serializable
|
||||||
|
public data class RemoveWorktreeResult(val path: String? = null)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
|
||||||
|
@Serializable
|
||||||
|
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
|
||||||
|
|
||||||
|
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
|
||||||
|
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
|
||||||
|
@Serializable
|
||||||
|
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
|
||||||
|
* (never throws — the caller already knows the status is 200).
|
||||||
|
*/
|
||||||
|
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
|
||||||
|
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
|
||||||
|
|
||||||
|
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
|
||||||
|
internal fun decodeGitError(bytes: ByteArray): String? =
|
||||||
|
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
|
||||||
|
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
|
||||||
|
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
|
||||||
|
*/
|
||||||
|
public enum class PrAvailability(public val wire: String) {
|
||||||
|
/** A PR exists for the current branch; the sibling fields are populated. */
|
||||||
|
OK("ok"),
|
||||||
|
|
||||||
|
/** gh works but the branch has no PR (or no remote/default repo). */
|
||||||
|
NO_PR("no-pr"),
|
||||||
|
|
||||||
|
/** `gh` binary not found on PATH (ENOENT). */
|
||||||
|
NOT_INSTALLED("not-installed"),
|
||||||
|
|
||||||
|
/** gh present but not logged in (needs `gh auth login`). */
|
||||||
|
UNAUTHENTICATED("unauthenticated"),
|
||||||
|
|
||||||
|
/** `GH_ENABLED=0` — feature off, never spawns gh. */
|
||||||
|
DISABLED("disabled"),
|
||||||
|
|
||||||
|
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
|
||||||
|
ERROR("error"),
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
|
||||||
|
public fun fromWire(wire: String): PrAvailability =
|
||||||
|
entries.firstOrNull { it.wire == wire } ?: ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
|
||||||
|
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
|
||||||
|
*/
|
||||||
|
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): PrAvailability =
|
||||||
|
PrAvailability.fromWire(decoder.decodeString())
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: PrAvailability) =
|
||||||
|
encoder.encodeString(value.wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
|
||||||
|
@Serializable
|
||||||
|
public data class PrCheckSummary(
|
||||||
|
val total: Int = 0,
|
||||||
|
val passing: Int = 0,
|
||||||
|
val failing: Int = 0,
|
||||||
|
val pending: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
|
||||||
|
* optional (present only when `availability == ok`); [availability] itself defaults to
|
||||||
|
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
|
||||||
|
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
|
||||||
|
* as plain text; no enum needed for display).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class PrStatus(
|
||||||
|
@Serializable(with = PrAvailabilitySerializer::class)
|
||||||
|
val availability: PrAvailability = PrAvailability.ERROR,
|
||||||
|
val number: Int? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
|
val state: String? = null,
|
||||||
|
val isDraft: Boolean? = null,
|
||||||
|
val mergeable: String? = null,
|
||||||
|
val headRefName: String? = null,
|
||||||
|
val baseRefName: String? = null,
|
||||||
|
val checks: PrCheckSummary? = null,
|
||||||
|
)
|
||||||
@@ -34,6 +34,12 @@ public data class ProjectInfo(
|
|||||||
val dirty: Boolean? = null,
|
val dirty: Boolean? = null,
|
||||||
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
||||||
val lastActiveMs: Long? = null,
|
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)
|
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||||
val sessions: List<ProjectSessionRef> = emptyList(),
|
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
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.HookDecision
|
||||||
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||||
import wang.yaojia.webterm.api.models.LossyDecode
|
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.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
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.SessionPreview
|
||||||
|
import wang.yaojia.webterm.api.models.StageResult
|
||||||
import wang.yaojia.webterm.api.models.UiConfig
|
import wang.yaojia.webterm.api.models.UiConfig
|
||||||
import wang.yaojia.webterm.api.models.UiPrefs
|
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.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpResponse
|
import wang.yaojia.webterm.wire.HttpResponse
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
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
|
/** `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). */
|
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
||||||
public suspend fun prefs(): UiPrefs {
|
public suspend fun prefs(): UiPrefs {
|
||||||
@@ -132,6 +180,50 @@ public class ApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
|
||||||
|
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
|
||||||
|
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
|
||||||
|
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
|
||||||
|
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
|
||||||
|
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
|
||||||
|
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
|
||||||
|
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
|
||||||
|
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
|
||||||
|
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
|
||||||
|
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
|
||||||
|
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
|
||||||
|
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
|
||||||
|
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
|
||||||
|
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
|
||||||
|
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
|
||||||
|
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
|
||||||
|
*/
|
||||||
|
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
|
||||||
|
val response = perform(route)
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
|
||||||
|
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
|
||||||
|
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
|
/** `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. */
|
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
|
||||||
public suspend fun registerFcmToken(token: String) {
|
public suspend fun registerFcmToken(token: String) {
|
||||||
|
|||||||
@@ -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. */
|
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
||||||
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
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. */
|
/** Any other non-success status code. */
|
||||||
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ internal object HttpStatus {
|
|||||||
const val NOT_FOUND = 404
|
const val NOT_FOUND = 404
|
||||||
const val TOO_MANY_REQUESTS = 429
|
const val TOO_MANY_REQUESTS = 429
|
||||||
const val INTERNAL_SERVER_ERROR = 500
|
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). */
|
/** Header / content-type names (no magic strings inline). */
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ internal object Endpoints {
|
|||||||
fun getPrefs(): ApiRoute =
|
fun getPrefs(): ApiRoute =
|
||||||
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
|
||||||
|
fun projectPr(path: String): ApiRoute =
|
||||||
|
ApiRoute(
|
||||||
|
HttpMethod.GET,
|
||||||
|
"/projects/pr",
|
||||||
|
OriginPolicy.READ_ONLY,
|
||||||
|
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
|
||||||
|
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
|
||||||
|
*/
|
||||||
|
fun projectLog(path: String, n: Int?): ApiRoute {
|
||||||
|
val query = StringBuilder("path=").append(percentEncode(path))
|
||||||
|
if (n != null) {
|
||||||
|
val clamped = n.coerceIn(1, GIT_LOG_MAX)
|
||||||
|
query.append("&n=").append(clamped)
|
||||||
|
}
|
||||||
|
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
|
||||||
|
}
|
||||||
|
|
||||||
// ── G ────────────────────────────────────────────────────────────────────────────────
|
// ── G ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun killSession(id: UUID): ApiRoute =
|
fun killSession(id: UUID): ApiRoute =
|
||||||
@@ -92,6 +114,58 @@ internal object Endpoints {
|
|||||||
|
|
||||||
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
||||||
|
|
||||||
|
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||||
|
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
||||||
|
jsonBodyRoute(
|
||||||
|
HttpMethod.POST,
|
||||||
|
"/projects/worktree",
|
||||||
|
CreateWorktreeBody.serializer(),
|
||||||
|
CreateWorktreeBody(path, branch, base),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
|
||||||
|
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
|
||||||
|
jsonBodyRoute(
|
||||||
|
HttpMethod.DELETE,
|
||||||
|
"/projects/worktree",
|
||||||
|
RemoveWorktreeBody.serializer(),
|
||||||
|
RemoveWorktreeBody(path, worktreePath, force),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` — `{ path }`. */
|
||||||
|
fun pruneWorktrees(path: String): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
|
||||||
|
|
||||||
|
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
||||||
|
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` — `{ path, message }`. */
|
||||||
|
fun gitCommit(path: String, message: String): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` — `{ path }`. */
|
||||||
|
fun gitPush(path: String): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||||
|
|
||||||
|
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
|
||||||
|
private fun <T> jsonBodyRoute(
|
||||||
|
method: HttpMethod,
|
||||||
|
path: String,
|
||||||
|
serializer: kotlinx.serialization.KSerializer<T>,
|
||||||
|
value: T,
|
||||||
|
): ApiRoute {
|
||||||
|
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
|
||||||
|
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
||||||
|
private const val GIT_LOG_MAX = 50
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
|
* 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
|
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
|
||||||
@@ -124,4 +198,22 @@ internal object Endpoints {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class FcmTokenBody(val token: String)
|
private data class FcmTokenBody(val token: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class PruneBody(val path: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CommitBody(val path: String, val message: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class PushBody(val path: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import wang.yaojia.webterm.screens.ClientCertScreen
|
|||||||
import wang.yaojia.webterm.screens.DiffScreen
|
import wang.yaojia.webterm.screens.DiffScreen
|
||||||
import wang.yaojia.webterm.screens.PairingScreen
|
import wang.yaojia.webterm.screens.PairingScreen
|
||||||
import wang.yaojia.webterm.screens.ProjectDetailScreen
|
import wang.yaojia.webterm.screens.ProjectDetailScreen
|
||||||
|
import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway
|
||||||
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
|
||||||
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
|
||||||
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
||||||
@@ -106,6 +107,7 @@ public fun ProjectDetailPane(
|
|||||||
path = path,
|
path = path,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||||
|
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,9 +120,10 @@ public fun ProjectDetailContent(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenClaude: (String) -> Unit,
|
onOpenClaude: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onViewDiff: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
|
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) ─────────────────────────────────────────────────────────────────────────────────
|
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -151,7 +154,12 @@ public fun DiffPane(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val viewModel = remember(resolved, path) {
|
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)
|
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ public fun ProjectsHome(
|
|||||||
path = path,
|
path = path,
|
||||||
onBack = { selectedPath = null },
|
onBack = { selectedPath = null },
|
||||||
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
|
||||||
|
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
DetailPlaceholder("选择一个项目查看详情。")
|
DetailPlaceholder("选择一个项目查看详情。")
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.DiffRow
|
||||||
import wang.yaojia.webterm.viewmodels.DiffUiState
|
import wang.yaojia.webterm.viewmodels.DiffUiState
|
||||||
import wang.yaojia.webterm.viewmodels.DiffViewModel
|
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
|
* 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
|
* toggle (hidden in base mode), a **base-rev** input (a third mode), per-file **Stage/Unstage** buttons
|
||||||
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown
|
* (working/staged mode only), a **commit** message field + **Commit** / **Push** buttons, and a result
|
||||||
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13
|
* **banner**. Every server-derived string (paths, hunk headers, code lines, git error messages) is
|
||||||
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7).
|
* rendered as **inert [Text]** — never `ClickableText`/autolink/markdown (plan §8). Interaction is
|
||||||
|
* device-QA (plan §7).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
public fun DiffScreen(
|
public fun DiffScreen(
|
||||||
@@ -61,29 +67,37 @@ public fun DiffScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onRefresh: () -> Unit = {},
|
onRefresh: () -> Unit = {},
|
||||||
onBack: (() -> Unit)? = null,
|
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) {
|
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack)
|
DiffHeader(state = state, onSelectStaged = onSelectStaged, onBack = onBack, onSetBase = onSetBase)
|
||||||
if (state.truncated) {
|
if (state.truncated) DiffNotice("Diff truncated — too large to display fully.")
|
||||||
DiffNotice("Diff truncated — too large to display fully.")
|
state.writeBanner?.let { WriteBanner(it, onDismissBanner) }
|
||||||
}
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
when (state.phase) {
|
when (state.phase) {
|
||||||
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
|
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
|
||||||
DiffPhase.EMPTY -> CenteredMessage("No changes")
|
DiffPhase.EMPTY -> CenteredMessage("No changes")
|
||||||
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
|
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
|
* 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
|
@Composable
|
||||||
public fun DiffScreen(
|
public fun DiffScreen(
|
||||||
@@ -92,7 +106,6 @@ public fun DiffScreen(
|
|||||||
onBack: (() -> Unit)? = null,
|
onBack: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
|
|
||||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||||
DiffScreen(
|
DiffScreen(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -100,50 +113,60 @@ public fun DiffScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onRefresh = viewModel::refresh,
|
onRefresh = viewModel::refresh,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
onSetBase = viewModel::setBase,
|
||||||
|
onToggleStage = viewModel::toggleStage,
|
||||||
|
onCommit = viewModel::commit,
|
||||||
|
onPush = viewModel::push,
|
||||||
|
onDismissBanner = viewModel::clearWriteBanner,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiffHeader(
|
private fun DiffHeader(
|
||||||
staged: Boolean,
|
state: DiffUiState,
|
||||||
onSelectStaged: (Boolean) -> Unit,
|
onSelectStaged: (Boolean) -> Unit,
|
||||||
onBack: (() -> Unit)?,
|
onBack: (() -> Unit)?,
|
||||||
|
onSetBase: (String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
var baseInput by remember(state.base) { mutableStateOf(state.base ?: "") }
|
||||||
modifier = Modifier
|
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8)) {
|
||||||
.fillMaxWidth()
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
if (onBack != null) TextButton(onClick = onBack) { Text("Back") }
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Text(text = "Diff", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
|
||||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
Spacer(modifier = Modifier.width(Spacing.sm8))
|
||||||
) {
|
if (state.base == null) {
|
||||||
if (onBack != null) {
|
// Working/Staged toggle is suppressed in base mode (server ignores staged then).
|
||||||
TextButton(onClick = onBack) { Text("Back") }
|
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
|
@Composable
|
||||||
private fun DiffList(rows: List<DiffRow>) {
|
private fun DiffList(
|
||||||
|
rows: List<DiffRow>,
|
||||||
|
writeEnabled: Boolean,
|
||||||
|
staged: Boolean,
|
||||||
|
onToggleStage: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(items = rows, key = { it.id }) { row ->
|
items(items = rows, key = { it.id }) { row ->
|
||||||
when (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 DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
|
||||||
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
|
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
|
||||||
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
|
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -153,11 +176,14 @@ private fun DiffList(rows: List<DiffRow>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FileHeader(row: DiffFileHeaderRow) {
|
private fun FileHeader(
|
||||||
|
row: DiffFileHeaderRow,
|
||||||
|
writeEnabled: Boolean,
|
||||||
|
staged: Boolean,
|
||||||
|
onToggleStage: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -172,6 +198,42 @@ private fun FileHeader(row: DiffFileHeaderRow) {
|
|||||||
)
|
)
|
||||||
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
|
||||||
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
|
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,
|
softWrap = false,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Clip,
|
overflow = TextOverflow.Clip,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = 1.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = Spacing.md12, vertical = 1.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,17 +267,13 @@ private fun DiffNotice(message: String) {
|
|||||||
text = message,
|
text = message,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CenteredMessage(message: String) {
|
private fun CenteredMessage(message: String) {
|
||||||
CenteredContent {
|
CenteredContent { Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -246,17 +302,15 @@ private fun markerFor(kind: DiffLineKind): String = when (kind) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DiffScreenPreview() {
|
private fun DiffScreenPreview() {
|
||||||
val rows = listOf<DiffRow>(
|
val rows = listOf<DiffRow>(
|
||||||
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1),
|
DiffFileHeaderRow(0, "src/app/Main.kt", "src/app/Main.kt", "modified", added = 2, removed = 1),
|
||||||
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
|
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
|
||||||
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
|
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
|
||||||
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
|
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
|
||||||
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
|
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
|
||||||
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
|
|
||||||
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
|
|
||||||
)
|
)
|
||||||
WebTermTheme {
|
WebTermTheme {
|
||||||
DiffScreen(
|
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 = {},
|
onSelectStaged = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,23 +9,36 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
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.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
import wang.yaojia.webterm.api.models.ProjectSessionRef
|
||||||
import wang.yaojia.webterm.api.models.WorktreeInfo
|
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.WebTermTheme
|
||||||
import wang.yaojia.webterm.designsystem.WebTermType
|
import wang.yaojia.webterm.designsystem.WebTermType
|
||||||
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
|
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
|
* # ProjectDetailScreen (A23 + W5) — one project's detail (branch · worktrees · sessions · CLAUDE.md),
|
||||||
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`.
|
* 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
|
* Every server string (name/path/branch/worktree/CLAUDE.md/commit subject/PR title/error) is rendered
|
||||||
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three
|
* as **inert [Text]** — no autolink/markdown (plan §8). The single exception is the PR chip, which is a
|
||||||
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action.
|
* 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
|
||||||
* @param onBack pop back to the projects grid.
|
* main worktree is never removable.
|
||||||
* @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).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
public fun ProjectDetailScreen(
|
public fun ProjectDetailScreen(
|
||||||
@@ -56,8 +69,11 @@ public fun ProjectDetailScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenClaude: (String) -> Unit,
|
onOpenClaude: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onViewDiff: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
||||||
|
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
|
||||||
|
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
LaunchedEffect(viewModel) { viewModel.load() }
|
LaunchedEffect(viewModel) { viewModel.load() }
|
||||||
|
|
||||||
@@ -71,7 +87,14 @@ public fun ProjectDetailScreen(
|
|||||||
is ProjectDetailViewModel.Phase.Failed ->
|
is ProjectDetailViewModel.Phase.Failed ->
|
||||||
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
|
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
|
||||||
is ProjectDetailViewModel.Phase.Loaded ->
|
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
|
@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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -113,37 +143,236 @@ private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
||||||
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
PrChipRow(prChip)
|
||||||
if (!detail.isGit) {
|
|
||||||
EmptyLine("不是 git 仓库。")
|
if (detail.isGit && worktree != null) {
|
||||||
} else if (detail.worktrees.isEmpty()) {
|
WorktreeSection(detail = detail, worktree = worktree)
|
||||||
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
|
||||||
} else {
|
} 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 }
|
val running = detail.sessions.filter { !it.exited }
|
||||||
SectionTitle("运行中的会话(${running.size})")
|
SectionTitle("运行中的会话(${running.size})")
|
||||||
if (running.isEmpty()) {
|
if (running.isEmpty()) EmptyLine("没有运行中的会话 —— 在下方开一个。")
|
||||||
EmptyLine("没有运行中的会话 —— 在下方开一个。")
|
else for (session in running) SessionRow(session)
|
||||||
} else {
|
|
||||||
for (session in running) SessionRow(session)
|
RecentCommitsSection(recent)
|
||||||
}
|
|
||||||
|
|
||||||
SectionTitle("CLAUDE.md")
|
SectionTitle("CLAUDE.md")
|
||||||
val claudeMd = detail.claudeMd
|
val claudeMd = detail.claudeMd
|
||||||
if (detail.hasClaudeMd && claudeMd != null) {
|
if (detail.hasClaudeMd && claudeMd != null) ClaudeMdBlock(claudeMd)
|
||||||
ClaudeMdBlock(claudeMd)
|
else EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
|
||||||
} 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
|
@Composable
|
||||||
private fun WorktreeRow(worktree: WorktreeInfo) {
|
private fun PrChipContent(pr: PrStatus) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val httpsUrl = pr.url?.let { if (isHttpsUrl(it)) it else null } // link ONLY when https (plan §Security)
|
||||||
|
val label = prChipLabel(pr)
|
||||||
|
val color = prChipColor(pr)
|
||||||
|
if (httpsUrl != null && pr.availability == PrAvailability.OK) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { runCatching { uriHandler.openUri(httpsUrl) } },
|
||||||
|
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||||
|
colors = AssistChipDefaults.assistChipColors(labelColor = color),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Non-ok / non-https → an INERT, non-clickable line (never make a hostile url tappable).
|
||||||
|
Text(text = label, style = WebTermType.metaMono, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prChipLabel(pr: PrStatus): String = when (pr.availability) {
|
||||||
|
PrAvailability.OK -> {
|
||||||
|
val num = pr.number?.let { "#$it " } ?: ""
|
||||||
|
val checks = pr.checks?.let { " (${it.passing}/${it.total})" } ?: ""
|
||||||
|
"PR $num${pr.title ?: ""}$checks".trim()
|
||||||
|
}
|
||||||
|
PrAvailability.NO_PR -> "当前分支没有 PR"
|
||||||
|
PrAvailability.NOT_INSTALLED -> "未安装 gh,无法读取 PR"
|
||||||
|
PrAvailability.UNAUTHENTICATED -> "gh 未登录,无法读取 PR"
|
||||||
|
PrAvailability.DISABLED -> "PR 集成已禁用"
|
||||||
|
PrAvailability.ERROR -> "PR 状态读取失败"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun prChipColor(pr: PrStatus): androidx.compose.ui.graphics.Color = when {
|
||||||
|
pr.availability != PrAvailability.OK -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
(pr.checks?.failing ?: 0) > 0 -> WebTermColors.statusStuck
|
||||||
|
(pr.checks?.pending ?: 0) > 0 -> WebTermColors.statusWaiting
|
||||||
|
else -> WebTermColors.statusWorking
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isHttpsUrl(url: String): Boolean =
|
||||||
|
runCatching { URI(url.trim()).scheme?.lowercase() == "https" }.getOrDefault(false)
|
||||||
|
|
||||||
|
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val phase by worktree.phase.collectAsStateWithLifecycle()
|
||||||
|
var branch by remember { mutableStateOf("") }
|
||||||
|
var base by remember { mutableStateOf("") }
|
||||||
|
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
|
||||||
|
|
||||||
|
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
|
||||||
|
if (detail.worktrees.isEmpty()) {
|
||||||
|
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
|
||||||
|
} else {
|
||||||
|
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
|
||||||
|
}
|
||||||
|
|
||||||
|
// New-worktree inline form.
|
||||||
|
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = branch,
|
||||||
|
onValueChange = { branch = it },
|
||||||
|
label = { Text("新工作树分支名") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = base,
|
||||||
|
onValueChange = { base = it },
|
||||||
|
label = { Text("基点(可选)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = phase != WorktreeViewModel.Phase.Working,
|
||||||
|
onClick = { scope.launch { worktree.create(branch, base) } },
|
||||||
|
) { Text("新建工作树") }
|
||||||
|
OutlinedButton(
|
||||||
|
enabled = phase != WorktreeViewModel.Phase.Working,
|
||||||
|
onClick = { scope.launch { worktree.prune() } },
|
||||||
|
) { Text("清理") }
|
||||||
|
}
|
||||||
|
WorktreePhaseBanner(phase, onDismiss = { worktree.reset() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = removeTarget
|
||||||
|
if (target != null) {
|
||||||
|
RemoveWorktreeDialog(
|
||||||
|
worktree = target,
|
||||||
|
onConfirm = { force ->
|
||||||
|
removeTarget = null
|
||||||
|
scope.launch { worktree.remove(target, force) }
|
||||||
|
},
|
||||||
|
onDismiss = { removeTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorktreePhaseBanner(phase: WorktreeViewModel.Phase, onDismiss: () -> Unit) {
|
||||||
|
when (phase) {
|
||||||
|
WorktreeViewModel.Phase.Idle -> Unit
|
||||||
|
WorktreeViewModel.Phase.Working -> Text("处理中…", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
is WorktreeViewModel.Phase.Done -> BannerLine(phase.message, WebTermColors.statusWorking, onDismiss)
|
||||||
|
is WorktreeViewModel.Phase.Failed -> BannerLine(phase.message, WebTermColors.statusStuck, onDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BannerLine(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
|
Text(text = message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onDismiss) { Text("知道了") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RemoveWorktreeDialog(
|
||||||
|
worktree: WorktreeInfo,
|
||||||
|
onConfirm: (force: Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var force by remember { mutableStateOf(false) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("删除工作树") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
|
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = force, onCheckedChange = { force = it })
|
||||||
|
Text("强制删除(丢弃未提交改动)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(force) }) { Text("删除") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recent commits ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
|
||||||
|
when (recent) {
|
||||||
|
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
|
||||||
|
ProjectDetailViewModel.RecentCommits.Loading -> {
|
||||||
|
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
|
||||||
|
}
|
||||||
|
ProjectDetailViewModel.RecentCommits.Unavailable -> {
|
||||||
|
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
|
||||||
|
}
|
||||||
|
is ProjectDetailViewModel.RecentCommits.Loaded -> {
|
||||||
|
SectionTitle("最近提交")
|
||||||
|
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
|
||||||
|
else for (commit in recent.result.commits) CommitRow(commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CommitRow(commit: CommitLogEntry) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = commit.hash.take(7),
|
||||||
|
style = WebTermType.metaMono,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = commit.subject,
|
||||||
|
style = WebTermType.metaMono,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
|
||||||
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
|
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
|
||||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
@@ -152,6 +381,7 @@ private fun WorktreeRow(worktree: WorktreeInfo) {
|
|||||||
if (worktree.isMain) Tag("main")
|
if (worktree.isMain) Tag("main")
|
||||||
if (worktree.isCurrent) Tag("current")
|
if (worktree.isCurrent) Tag("current")
|
||||||
if (worktree.locked == true) Tag("locked")
|
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)
|
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
|
@Composable
|
||||||
private fun ClaudeMdBlock(text: String) {
|
private fun ClaudeMdBlock(text: String) {
|
||||||
WebTermCard(modifier = Modifier.fillMaxWidth()) {
|
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)
|
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +467,17 @@ private fun ProjectDetailScreenPreview() {
|
|||||||
claudeMd = "# CLAUDE.md\n\nProject instructions…",
|
claudeMd = "# CLAUDE.md\n\nProject instructions…",
|
||||||
)
|
)
|
||||||
WebTermTheme {
|
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 = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,13 +222,21 @@ private fun ProjectCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
project.branch?.let { branch ->
|
project.branch?.let { branch ->
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
|
||||||
text = branch,
|
Text(
|
||||||
style = WebTermType.metaMono,
|
text = branch,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = WebTermType.metaMono,
|
||||||
maxLines = 1,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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 }
|
val running = project.sessions.count { !it.exited }
|
||||||
if (running > 0) {
|
if (running > 0) {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
import kotlinx.serialization.json.intOrNull
|
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.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
@@ -47,14 +52,17 @@ import java.net.URI
|
|||||||
public class DiffViewModel(
|
public class DiffViewModel(
|
||||||
private val fetcher: DiffFetcher,
|
private val fetcher: DiffFetcher,
|
||||||
private val path: String,
|
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. */
|
/** The single snapshot `DiffScreen` renders from. */
|
||||||
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
|
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var scope: CoroutineScope? = null
|
private var scope: CoroutineScope? = null
|
||||||
private var job: Job? = 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. */
|
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
|
||||||
public fun bind(scope: CoroutineScope) {
|
public fun bind(scope: CoroutineScope) {
|
||||||
@@ -62,27 +70,46 @@ public class DiffViewModel(
|
|||||||
reload()
|
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) {
|
public fun selectStaged(staged: Boolean) {
|
||||||
|
if (_uiState.value.base != null) return
|
||||||
if (_uiState.value.staged == staged) return
|
if (_uiState.value.staged == staged) return
|
||||||
_uiState.value = _uiState.value.copy(staged = staged)
|
_uiState.value = _uiState.value.copy(staged = staged)
|
||||||
reload()
|
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). */
|
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
|
||||||
public fun refresh() {
|
public fun refresh() {
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dismiss the git-write result banner. */
|
||||||
|
public fun clearWriteBanner() {
|
||||||
|
_uiState.value = _uiState.value.copy(writeBanner = null)
|
||||||
|
}
|
||||||
|
|
||||||
private fun reload() {
|
private fun reload() {
|
||||||
val scope = scope ?: return
|
val scope = scope ?: return
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
val staged = _uiState.value.staged
|
val staged = _uiState.value.staged
|
||||||
|
val base = _uiState.value.base
|
||||||
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
|
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
|
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
|
||||||
val outcome = try {
|
val outcome = try {
|
||||||
Result.success(fetcher.fetch(path, staged))
|
Result.success(fetcher.fetch(path, staged, base))
|
||||||
} catch (cancel: CancellationException) {
|
} catch (cancel: CancellationException) {
|
||||||
throw cancel
|
throw cancel
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
@@ -101,6 +128,102 @@ public class DiffViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Git write (working/staged mode only — never in base mode, plan §Security/Edge cases) ──────
|
||||||
|
|
||||||
|
/** Stage (`staged=true`) or unstage a single file, then re-fetch so the view reflects the index. */
|
||||||
|
public fun toggleStage(newPath: String, staged: Boolean) {
|
||||||
|
runWrite { writer!!.gitStage(path, listOf(newPath), staged) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit the staged changes with [message]. An empty message is rejected client-side (no I/O). */
|
||||||
|
public fun commit(message: String) {
|
||||||
|
if (message.isBlank()) {
|
||||||
|
_uiState.value = _uiState.value.copy(writeBanner = DiffWriteBanner(DiffCopy.COMMIT_EMPTY, isError = true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runWrite { writer!!.gitCommit(path, message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push the current branch to its upstream (tighter server-side rate limit; never auto-retried). */
|
||||||
|
public fun push() {
|
||||||
|
runWrite { writer!!.gitPush(path) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared guarded-write runner: guards on write availability + base mode, sets [DiffUiState.writing],
|
||||||
|
* maps the [GitWriteOutcome] to a banner, and re-fetches the diff on success. Serialized via a single
|
||||||
|
* [writeJob] so a rapid double-tap never races.
|
||||||
|
*/
|
||||||
|
private fun <T> runWrite(op: suspend () -> GitWriteOutcome<T>) {
|
||||||
|
val scope = scope ?: return
|
||||||
|
if (writer == null || _uiState.value.base != null || _uiState.value.writing) return
|
||||||
|
writeJob?.cancel()
|
||||||
|
_uiState.value = _uiState.value.copy(writing = true, writeBanner = null)
|
||||||
|
writeJob = scope.launch {
|
||||||
|
val outcome = try {
|
||||||
|
Result.success(op())
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Result.failure(error)
|
||||||
|
}
|
||||||
|
val banner = outcome.fold(
|
||||||
|
onSuccess = { bannerFor(it) },
|
||||||
|
onFailure = { DiffWriteBanner(DiffCopy.writeFailed(errorDetail(it)), isError = true) },
|
||||||
|
)
|
||||||
|
_uiState.value = _uiState.value.copy(writing = false, writeBanner = banner)
|
||||||
|
if (!banner.isError) reload() // refresh the diff after a successful write
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> bannerFor(outcome: GitWriteOutcome<T>): DiffWriteBanner = when (outcome) {
|
||||||
|
is GitWriteOutcome.Ok -> DiffWriteBanner(DiffCopy.okBanner(outcome.payload), isError = false)
|
||||||
|
is GitWriteOutcome.Rejected -> DiffWriteBanner(outcome.message ?: DiffCopy.WRITE_REJECTED, isError = true)
|
||||||
|
GitWriteOutcome.RateLimited -> DiffWriteBanner(DiffCopy.RATE_LIMITED, isError = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A thrown ApiClientError's message IS its userMessage (super(userMessage)); transport errors carry
|
||||||
|
// their own message — so message is already the display copy.
|
||||||
|
private fun errorDetail(error: Throwable): String = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The guarded git-write seam DiffViewModel drives (stage/commit/push). Prod: [ApiClientGitWriteGateway]. */
|
||||||
|
public interface GitWriteGateway {
|
||||||
|
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult>
|
||||||
|
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult>
|
||||||
|
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Production [GitWriteGateway] delegating to a per-host [ApiClient] (Origin stamped in :api-client). */
|
||||||
|
public class ApiClientGitWriteGateway(private val api: ApiClient) : GitWriteGateway {
|
||||||
|
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> =
|
||||||
|
api.gitStage(path, files, stage)
|
||||||
|
|
||||||
|
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
|
||||||
|
api.gitCommit(path, message)
|
||||||
|
|
||||||
|
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = api.gitPush(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A one-line git-write result banner. [isError] drives the colour token (green ok / red failure). */
|
||||||
|
public data class DiffWriteBanner(val message: String, val isError: Boolean)
|
||||||
|
|
||||||
|
/** User-visible git-write copy (Chinese named constants; server strings are surfaced verbatim/inert). */
|
||||||
|
public object DiffCopy {
|
||||||
|
public const val COMMIT_EMPTY: String = "请填写提交信息。"
|
||||||
|
public const val WRITE_REJECTED: String = "操作被服务器拒绝。"
|
||||||
|
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
|
||||||
|
|
||||||
|
public fun writeFailed(detail: String): String = "Git 操作失败:$detail"
|
||||||
|
|
||||||
|
/** Success banner keyed off the payload type (short sha / branch→remote / staged count). */
|
||||||
|
public fun okBanner(payload: Any?): String = when (payload) {
|
||||||
|
is StageResult -> if (payload.staged) "已暂存 ${payload.count} 个文件" else "已取消暂存 ${payload.count} 个文件"
|
||||||
|
is CommitResult -> if (payload.commit.isEmpty()) "已提交" else "已提交 ${payload.commit}"
|
||||||
|
is PushResult -> "已推送 ${payload.branch ?: "分支"} → ${payload.remote ?: "远端"}"
|
||||||
|
else -> "操作完成"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The load phase the screen renders (loading spinner / empty / error / list). */
|
/** 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. */
|
/** The immutable snapshot the diff screen renders. */
|
||||||
public data class DiffUiState(
|
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,
|
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,
|
val phase: DiffPhase = DiffPhase.IDLE,
|
||||||
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
|
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
|
||||||
val rows: List<DiffRow> = emptyList(),
|
val rows: List<DiffRow> = emptyList(),
|
||||||
/** Server capped the diff (too large) — the screen shows a truncation notice. */
|
/** Server capped the diff (too large) — the screen shows a truncation notice. */
|
||||||
val truncated: Boolean = false,
|
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) ──────────────────────────────
|
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
|
||||||
|
|
||||||
@@ -125,10 +259,12 @@ public sealed interface DiffRow {
|
|||||||
public val id: Long
|
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(
|
public data class DiffFileHeaderRow(
|
||||||
override val id: Long,
|
override val id: Long,
|
||||||
val path: String,
|
val path: String,
|
||||||
|
val stagePath: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val added: Int,
|
val added: Int,
|
||||||
val removed: Int,
|
val removed: Int,
|
||||||
@@ -153,7 +289,7 @@ public fun flattenDiff(result: DiffResult): List<DiffRow> {
|
|||||||
val rows = ArrayList<DiffRow>()
|
val rows = ArrayList<DiffRow>()
|
||||||
var id = 0L
|
var id = 0L
|
||||||
for (file in result.files) {
|
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) {
|
if (file.binary) {
|
||||||
rows.add(DiffBinaryRow(id++))
|
rows.add(DiffBinaryRow(id++))
|
||||||
continue
|
continue
|
||||||
@@ -212,7 +348,13 @@ public data class DiffFile(
|
|||||||
val hunks: List<DiffHunk>,
|
val hunks: List<DiffHunk>,
|
||||||
)
|
)
|
||||||
|
|
||||||
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean)
|
public data class DiffResult(
|
||||||
|
val files: List<DiffFile>,
|
||||||
|
val staged: Boolean,
|
||||||
|
val truncated: Boolean,
|
||||||
|
/** Echoed by the server when the diff was against a base revision (`?base=<rev>`); null otherwise. */
|
||||||
|
val base: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
|
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
|
||||||
private val DiffJson: Json = Json {
|
private val DiffJson: Json = Json {
|
||||||
@@ -230,7 +372,12 @@ internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
|
|||||||
.getOrNull() as? JsonObject
|
.getOrNull() as? JsonObject
|
||||||
?: return DiffResult(emptyList(), staged = false, truncated = false)
|
?: return DiffResult(emptyList(), staged = false, truncated = false)
|
||||||
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
|
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). */
|
/** 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. */
|
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
|
||||||
public interface DiffFetcher {
|
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). */
|
/** 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 endpoint: HostEndpoint,
|
||||||
private val http: HttpTransport,
|
private val http: HttpTransport,
|
||||||
) : DiffFetcher {
|
) : DiffFetcher {
|
||||||
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
|
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||||
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
||||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
||||||
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
||||||
return decodeDiffResult(response.body)
|
return decodeDiffResult(response.body)
|
||||||
@@ -305,20 +456,28 @@ private const val HTTP_OK = 200
|
|||||||
private const val HTTP_BAD_REQUEST = 400
|
private const val HTTP_BAD_REQUEST = 400
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL,
|
* Build `<scheme>://host[:port]/projects/diff?path=<enc>[&staged=1|0][&base=<enc>]` from the dialed
|
||||||
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the
|
* base URL, keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). In **base mode**
|
||||||
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the
|
* ([base] non-null/non-blank) the server ignores `staged` (server.ts:831), so `staged` is OMITTED and
|
||||||
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value.
|
* `&base=<enc>` is appended (percent-encoded; the server's `isPlausibleRev` rejects junk with a 400).
|
||||||
|
* Otherwise `staged` serializes as the literal `"1"`/`"0"` string the server matches with `=== '1'`
|
||||||
|
* (NOT a boolean). Returns null if the base URL cannot be parsed. `internal` so the JVM test asserts
|
||||||
|
* the exact query value.
|
||||||
*/
|
*/
|
||||||
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? {
|
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean, base: String? = null): String? {
|
||||||
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
|
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
|
||||||
val scheme = uri.scheme?.lowercase() ?: return null
|
val scheme = uri.scheme?.lowercase() ?: return null
|
||||||
val host = uri.host ?: return null
|
val host = uri.host ?: return null
|
||||||
if (host.isEmpty()) return null
|
if (host.isEmpty()) return null
|
||||||
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||||
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||||
val stagedValue = if (staged) "1" else "0"
|
val prefix = "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}"
|
||||||
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue"
|
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). */
|
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */
|
||||||
|
|||||||
@@ -4,26 +4,32 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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.models.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
import wang.yaojia.webterm.api.routes.ApiClientError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state
|
* # ProjectDetailViewModel (A23 + W5) — one project's detail (`GET /projects/detail?path=`), a phase
|
||||||
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`).
|
* 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
|
* The main [fetch] closure is injected (production wraps [ProjectsGateway.projectDetail]); [fetchPr] /
|
||||||
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a
|
* [fetchLog] are optional side fetches (null → the chip/list stay [PrChip.Hidden] / [RecentCommits.Hidden]).
|
||||||
* fake. This VM only reduces the three user-visible outcomes:
|
* [worktree] (when wired) drives the guarded create/remove/prune actions and re-fetches this detail on
|
||||||
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT);
|
* success (via [load]).
|
||||||
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
|
|
||||||
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
|
|
||||||
*
|
*
|
||||||
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no
|
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
|
||||||
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it.
|
* [load] in a lifecycle scope; the retry action re-calls it.
|
||||||
*/
|
*/
|
||||||
public class ProjectDetailViewModel(
|
public class ProjectDetailViewModel(
|
||||||
public val path: String,
|
public val path: String,
|
||||||
private val fetch: suspend () -> ProjectDetail,
|
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`). */
|
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
|
||||||
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
|
||||||
@@ -35,12 +41,40 @@ public class ProjectDetailViewModel(
|
|||||||
public data class Failed(val failure: Failure) : Phase
|
public data class Failed(val failure: Failure) : Phase
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
|
/** The PR + CI chip's own state (isolated from the detail load). */
|
||||||
|
public sealed interface PrChip {
|
||||||
|
public data object Hidden : PrChip
|
||||||
|
public data object Loading : PrChip
|
||||||
|
public data class Loaded(val status: PrStatus) : PrChip
|
||||||
|
public data object Unavailable : PrChip
|
||||||
|
}
|
||||||
|
|
||||||
/** The single snapshot `ProjectDetailScreen` renders from. */
|
/** The recent-commits section's own state (isolated from the detail load). */
|
||||||
|
public sealed interface RecentCommits {
|
||||||
|
public data object Hidden : RecentCommits
|
||||||
|
public data object Loading : RecentCommits
|
||||||
|
public data class Loaded(val result: GitLogResult) : RecentCommits
|
||||||
|
public data object Unavailable : RecentCommits
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
|
||||||
|
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
|
||||||
|
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
|
||||||
|
|
||||||
|
/** The main detail snapshot `ProjectDetailScreen` renders from. */
|
||||||
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||||
|
|
||||||
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */
|
/** The PR chip snapshot (renders one chip from [PrStatus.availability]). */
|
||||||
|
public val prChip: StateFlow<PrChip> = _prChip.asStateFlow()
|
||||||
|
|
||||||
|
/** The recent-commits snapshot. */
|
||||||
|
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
|
||||||
|
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
|
||||||
|
* the detail load nor each other).
|
||||||
|
*/
|
||||||
public suspend fun load() {
|
public suspend fun load() {
|
||||||
_phase.value = Phase.Loading
|
_phase.value = Phase.Loading
|
||||||
_phase.value = try {
|
_phase.value = try {
|
||||||
@@ -53,6 +87,34 @@ public class ProjectDetailViewModel(
|
|||||||
// Transport/decode etc. — a retryable catch-all.
|
// Transport/decode etc. — a retryable catch-all.
|
||||||
Phase.Failed(Failure.UNAVAILABLE)
|
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) {
|
private fun failureFor(error: ApiClientError): Failure = when (error) {
|
||||||
@@ -62,8 +124,22 @@ public class ProjectDetailViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public companion object {
|
public companion object {
|
||||||
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */
|
/**
|
||||||
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel =
|
* Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). Wires the
|
||||||
ProjectDetailViewModel(path) { gateway.projectDetail(path) }
|
* 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
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.ProjectDetail
|
||||||
import wang.yaojia.webterm.api.models.ProjectInfo
|
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.models.UiPrefs
|
||||||
import wang.yaojia.webterm.api.routes.ApiClient
|
import wang.yaojia.webterm.api.routes.ApiClient
|
||||||
import wang.yaojia.webterm.api.routes.ApiClientError
|
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) ─────────────────────
|
// ── 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 interface ProjectsGateway {
|
||||||
public suspend fun projects(): List<ProjectInfo>
|
public suspend fun projects(): List<ProjectInfo>
|
||||||
public suspend fun prefs(): UiPrefs
|
public suspend fun prefs(): UiPrefs
|
||||||
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
|
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
|
||||||
public suspend fun projectDetail(path: String): ProjectDetail
|
public suspend fun projectDetail(path: String): ProjectDetail
|
||||||
|
|
||||||
|
// ── W5: read-only PR + recent commits ──────────────────────────────────────────────────
|
||||||
|
public suspend fun projectPr(path: String): PrStatus
|
||||||
|
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
|
||||||
|
|
||||||
|
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
|
||||||
|
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
|
||||||
|
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
|
||||||
|
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
|
/** 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 prefs(): UiPrefs = api.prefs()
|
||||||
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
|
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
|
||||||
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
|
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
|
||||||
|
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
|
||||||
|
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
|
||||||
|
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
|
||||||
|
api.createWorktree(path, branch, base)
|
||||||
|
|
||||||
|
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> =
|
||||||
|
api.removeWorktree(path, worktreePath, force)
|
||||||
|
|
||||||
|
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> = api.pruneWorktrees(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */
|
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||||
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.WorktreeInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # WorktreeViewModel (W5) — the guarded worktree write actions for one project.
|
||||||
|
*
|
||||||
|
* A phase machine (`Idle → Working → Done | Failed`) over the three guarded routes
|
||||||
|
* (`POST /projects/worktree`, `DELETE /projects/worktree`, `POST /projects/worktree/prune`), all
|
||||||
|
* flowing through the :api-client Origin-stamping point (plan §Security). On a successful op it invokes
|
||||||
|
* [onChanged] so the detail screen re-fetches and the worktree list refreshes.
|
||||||
|
*
|
||||||
|
* ### Defense in depth (UX, not the security boundary)
|
||||||
|
* The branch name is pre-validated client-side ([isValidBranchName], a mirror of the server's
|
||||||
|
* `validateBranchName`) so an obviously bad name fails with NO network I/O; a **main** worktree removal
|
||||||
|
* is blocked client-side ([WorktreeInfo.isMain]) — the server re-validates + realpath-contains
|
||||||
|
* regardless. Server `error` strings (disabled kill-switch, "uncommitted changes; force required") are
|
||||||
|
* surfaced INERT (plain text; never linkified).
|
||||||
|
*
|
||||||
|
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
|
||||||
|
* the suspend actions from a lifecycle scope; [reset] clears a settled banner back to [Phase.Idle].
|
||||||
|
*/
|
||||||
|
public class WorktreeViewModel(
|
||||||
|
private val gateway: ProjectsGateway,
|
||||||
|
private val repoPath: String,
|
||||||
|
/** Invoked after any successful write so the detail page re-fetches (list refresh). */
|
||||||
|
private val onChanged: suspend () -> Unit = {},
|
||||||
|
) {
|
||||||
|
/** The action phase the screen renders (idle / spinner / success banner / failure banner). */
|
||||||
|
public sealed interface Phase {
|
||||||
|
public data object Idle : Phase
|
||||||
|
public data object Working : Phase
|
||||||
|
public data class Done(val message: String) : Phase
|
||||||
|
public data class Failed(val message: String) : Phase
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _phase = MutableStateFlow<Phase>(Phase.Idle)
|
||||||
|
|
||||||
|
/** The single snapshot the worktree sheet/dialog renders from. */
|
||||||
|
public val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||||
|
|
||||||
|
/** Create a worktree for [branch] (off optional [base]). Invalid branch → [Phase.Failed], no I/O. */
|
||||||
|
public suspend fun create(branch: String, base: String? = null) {
|
||||||
|
val trimmed = branch.trim()
|
||||||
|
if (!isValidBranchName(trimmed)) {
|
||||||
|
_phase.value = Phase.Failed(WorktreeCopy.INVALID_BRANCH)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_phase.value == Phase.Working) return
|
||||||
|
_phase.value = Phase.Working
|
||||||
|
val cleanBase = base?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
_phase.value = runOp { gateway.createWorktree(repoPath, trimmed, cleanBase) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove [worktree] ([force] to discard uncommitted changes). A **main** worktree is blocked here. */
|
||||||
|
public suspend fun remove(worktree: WorktreeInfo, force: Boolean) {
|
||||||
|
if (worktree.isMain) {
|
||||||
|
_phase.value = Phase.Failed(WorktreeCopy.CANNOT_REMOVE_MAIN)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_phase.value == Phase.Working) return
|
||||||
|
_phase.value = Phase.Working
|
||||||
|
_phase.value = runOp { gateway.removeWorktree(repoPath, worktree.path, force) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reclaim stale worktree admin dirs (idempotent). */
|
||||||
|
public suspend fun prune() {
|
||||||
|
if (_phase.value == Phase.Working) return
|
||||||
|
_phase.value = Phase.Working
|
||||||
|
_phase.value = runOp { gateway.pruneWorktrees(repoPath) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear a settled banner (Done/Failed) back to Idle after the user dismisses it. */
|
||||||
|
public fun reset() {
|
||||||
|
_phase.value = Phase.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one guarded write, mapping its [GitWriteOutcome] to a phase. On success it re-fetches the
|
||||||
|
* detail (via [onChanged]) BEFORE settling to [Phase.Done] so the list is fresh when the banner shows.
|
||||||
|
*/
|
||||||
|
private suspend fun <T> runOp(op: suspend () -> GitWriteOutcome<T>): Phase {
|
||||||
|
val outcome = try {
|
||||||
|
op()
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
return Phase.Failed(WorktreeCopy.failed(error.message ?: error.toString()))
|
||||||
|
}
|
||||||
|
return when (outcome) {
|
||||||
|
is GitWriteOutcome.Ok -> {
|
||||||
|
runCatching { onChanged() } // a refresh failure must not turn a successful write into a failure
|
||||||
|
Phase.Done(WorktreeCopy.okMessage(outcome.payload))
|
||||||
|
}
|
||||||
|
is GitWriteOutcome.Rejected -> Phase.Failed(outcome.message ?: WorktreeCopy.REJECTED)
|
||||||
|
GitWriteOutcome.RateLimited -> Phase.Failed(WorktreeCopy.RATE_LIMITED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/** Longest branch name the server accepts (`src/http/worktrees.ts` `MAX_BRANCH_LEN`). */
|
||||||
|
private const val MAX_BRANCH_LEN = 250
|
||||||
|
|
||||||
|
/** Mirror of the server's `FORBIDDEN_BRANCH_CHARS`: control/DEL, whitespace, `~^:?*[\`. */
|
||||||
|
private val FORBIDDEN_BRANCH_CHARS = Regex("[\\u0000-\\u001f\\u007f\\s~^:?*\\[\\\\]")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side mirror of `validateBranchName` (worktrees.ts:95) — a fast UX pre-check ONLY; the
|
||||||
|
* server re-validates. Rejects empty/overlong, leading `-`, bad slashes, `..`, `.lock`/trailing
|
||||||
|
* `.`, `@{`, and any forbidden char.
|
||||||
|
*/
|
||||||
|
public fun isValidBranchName(branch: String): Boolean {
|
||||||
|
if (branch.isEmpty() || branch.length > MAX_BRANCH_LEN) return false
|
||||||
|
if (branch.startsWith("-")) return false
|
||||||
|
if (branch.startsWith("/") || branch.endsWith("/") || branch.contains("//")) return false
|
||||||
|
if (branch.contains("..")) return false
|
||||||
|
if (branch.endsWith(".lock") || branch.endsWith(".")) return false
|
||||||
|
if (branch.contains("@{")) return false
|
||||||
|
if (FORBIDDEN_BRANCH_CHARS.containsMatchIn(branch)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-visible worktree-action copy (Chinese named constants; server strings surfaced inert). */
|
||||||
|
public object WorktreeCopy {
|
||||||
|
public const val INVALID_BRANCH: String = "分支名不合法(含非法字符或格式)。"
|
||||||
|
public const val CANNOT_REMOVE_MAIN: String = "不能删除主工作树。"
|
||||||
|
public const val REJECTED: String = "操作被服务器拒绝。"
|
||||||
|
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
|
||||||
|
|
||||||
|
public fun failed(detail: String): String = "工作树操作失败:$detail"
|
||||||
|
|
||||||
|
public fun okMessage(payload: Any?): String = when (payload) {
|
||||||
|
is CreateWorktreeResult -> "已创建工作树 ${payload.branch ?: ""}".trim()
|
||||||
|
is RemoveWorktreeResult -> "已删除工作树"
|
||||||
|
is PruneWorktreesResult ->
|
||||||
|
if (payload.pruned.isEmpty()) "没有可清理的工作树" else "已清理 ${payload.pruned.size} 个工作树"
|
||||||
|
else -> "操作完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,14 @@ import kotlinx.coroutines.test.StandardTestDispatcher
|
|||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
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
|
* 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 ───────────────────────────────────────
|
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
|
||||||
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
|
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
|
||||||
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
|
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
|
||||||
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
|
val bases = mutableListOf<String?>() // records the base arg of each fetch
|
||||||
|
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||||
calls += staged
|
calls += staged
|
||||||
|
bases += base
|
||||||
error?.let { throw it }
|
error?.let { throw it }
|
||||||
return result!!
|
return result!!
|
||||||
}
|
}
|
||||||
@@ -154,4 +162,133 @@ class DiffViewModelTest {
|
|||||||
assertTrue(vm.uiState.value.staged)
|
assertTrue(vm.uiState.value.staged)
|
||||||
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
|
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── diffUrl base mode (Phase B) ───────────────────────────────────────────────────────────────
|
||||||
|
@Test
|
||||||
|
fun `diffUrl appends staged in working mode and base (omitting staged) in base mode`() {
|
||||||
|
assertEquals(
|
||||||
|
"http://h:3000/projects/diff?path=%2Frepo&staged=1",
|
||||||
|
diffUrl("http://h:3000", "/repo", staged = true, base = null),
|
||||||
|
)
|
||||||
|
// base mode: no staged param, base percent-encoded.
|
||||||
|
assertEquals(
|
||||||
|
"http://h:3000/projects/diff?path=%2Frepo&base=feature%2Fx",
|
||||||
|
diffUrl("http://h:3000", "/repo", staged = true, base = "feature/x"),
|
||||||
|
)
|
||||||
|
// a blank base is treated as working mode.
|
||||||
|
assertEquals(
|
||||||
|
"http://h:3000/projects/diff?path=%2Frepo&staged=0",
|
||||||
|
diffUrl("http://h:3000", "/repo", staged = false, base = " "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DiffViewModel base mode (Phase B) ─────────────────────────────────────────────────────────
|
||||||
|
@Test
|
||||||
|
fun `setBase enters base mode, threads base to the fetcher, and suppresses the staged toggle`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo")
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
vm.setBase("main"); advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals("main", vm.uiState.value.base)
|
||||||
|
assertEquals(listOf(null, "main"), fetcher.bases) // base threaded on the re-fetch
|
||||||
|
|
||||||
|
// In base mode the staged toggle is a no-op (server ignores staged when base is set).
|
||||||
|
vm.selectStaged(true); advanceUntilIdle()
|
||||||
|
assertFalse(vm.uiState.value.staged)
|
||||||
|
assertEquals(2, fetcher.calls.size, "selectStaged must not re-fetch in base mode")
|
||||||
|
|
||||||
|
// Leaving base mode returns to the working/staged view.
|
||||||
|
vm.setBase(null); advanceUntilIdle()
|
||||||
|
assertNull(vm.uiState.value.base)
|
||||||
|
assertEquals(listOf(null, "main", null), fetcher.bases)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DiffViewModel git-write (Phase C) ─────────────────────────────────────────────────────────
|
||||||
|
private class FakeWriter(
|
||||||
|
var stage: GitWriteOutcome<StageResult> = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)),
|
||||||
|
var commit: GitWriteOutcome<CommitResult> = GitWriteOutcome.Ok(CommitResult(commit = "abc123")),
|
||||||
|
var push: GitWriteOutcome<PushResult> = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")),
|
||||||
|
) : GitWriteGateway {
|
||||||
|
val stageCalls = mutableListOf<Triple<String, List<String>, Boolean>>()
|
||||||
|
var commitCalls = 0; var pushCalls = 0
|
||||||
|
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> {
|
||||||
|
stageCalls += Triple(path, files, stage); return this.stage
|
||||||
|
}
|
||||||
|
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> { commitCalls++; return commit }
|
||||||
|
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> { pushCalls++; return push }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleStage posts the file and refreshes the diff`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val writer = FakeWriter()
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
vm.toggleStage("src/A.kt", staged = true); advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(Triple("/repo", listOf("src/A.kt"), true), writer.stageCalls.single())
|
||||||
|
assertEquals(2, fetcher.calls.size, "a successful stage must refresh the diff")
|
||||||
|
assertEquals(false, vm.uiState.value.writeBanner?.isError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `commit surfaces an Ok banner and an empty message is rejected client-side with no I O`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val writer = FakeWriter()
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.commit(" "); advanceUntilIdle() // blank → client-side reject
|
||||||
|
assertEquals(0, writer.commitCalls, "a blank commit message must not hit the network")
|
||||||
|
assertEquals(true, vm.uiState.value.writeBanner?.isError)
|
||||||
|
|
||||||
|
vm.commit("real message"); advanceUntilIdle()
|
||||||
|
assertEquals(1, writer.commitCalls)
|
||||||
|
assertEquals(false, vm.uiState.value.writeBanner?.isError)
|
||||||
|
assertTrue(vm.uiState.value.writeBanner!!.message.contains("abc123"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `push maps a 409 rejection to the inert server message and does not refresh`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val writer = FakeWriter(push = GitWriteOutcome.Rejected(409, "Push rejected: remote has diverged."))
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.push(); advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(1, writer.pushCalls)
|
||||||
|
assertEquals(true, vm.uiState.value.writeBanner?.isError)
|
||||||
|
assertEquals("Push rejected: remote has diverged.", vm.uiState.value.writeBanner?.message)
|
||||||
|
assertEquals(1, fetcher.calls.size, "a failed push must NOT refresh the diff")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `git-write is disabled in base mode`() = runTest {
|
||||||
|
val fetcher = FakeFetcher(oneFileResult(false))
|
||||||
|
val writer = FakeWriter()
|
||||||
|
val vm = DiffViewModel(fetcher, "/repo", writer)
|
||||||
|
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||||
|
vm.bind(scope); advanceUntilIdle()
|
||||||
|
vm.setBase("main"); advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.toggleStage("a.kt", true); vm.commit("m"); vm.push(); advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(writer.stageCalls.isEmpty() && writer.commitCalls == 0 && writer.pushCalls == 0)
|
||||||
|
assertFalse(vm.uiState.value.writeEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `writeEnabled is false without a writer and true with one in working mode`() {
|
||||||
|
assertFalse(DiffUiState(canWrite = false).writeEnabled)
|
||||||
|
assertTrue(DiffUiState(canWrite = true).writeEnabled)
|
||||||
|
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitLogResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
|
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||||
|
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||||
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
|
||||||
|
* ProjectDetailViewModel PR/log). Records the guarded-write call args and returns canned outcomes;
|
||||||
|
* PR/log return canned values or throw to exercise failure-isolation. The list-page methods
|
||||||
|
* (projects/prefs) are unused here and throw if called.
|
||||||
|
*/
|
||||||
|
class FakeWorktreeGateway(
|
||||||
|
private val detail: ProjectDetail? = null,
|
||||||
|
private val prResult: PrStatus? = null,
|
||||||
|
private val prThrows: Boolean = false,
|
||||||
|
private val logResult: GitLogResult? = null,
|
||||||
|
private val logThrows: Boolean = false,
|
||||||
|
private val createOutcome: GitWriteOutcome<CreateWorktreeResult> = GitWriteOutcome.Ok(CreateWorktreeResult()),
|
||||||
|
private val removeOutcome: GitWriteOutcome<RemoveWorktreeResult> = GitWriteOutcome.Ok(RemoveWorktreeResult()),
|
||||||
|
private val pruneOutcome: GitWriteOutcome<PruneWorktreesResult> = GitWriteOutcome.Ok(PruneWorktreesResult()),
|
||||||
|
) : ProjectsGateway {
|
||||||
|
val createCalls = mutableListOf<Triple<String, String, String?>>()
|
||||||
|
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
|
||||||
|
val pruneCalls = mutableListOf<String>()
|
||||||
|
var detailCalls = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override suspend fun projects(): List<ProjectInfo> = throw NotImplementedError()
|
||||||
|
override suspend fun prefs(): UiPrefs = throw NotImplementedError()
|
||||||
|
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = throw NotImplementedError()
|
||||||
|
|
||||||
|
override suspend fun projectDetail(path: String): ProjectDetail {
|
||||||
|
detailCalls++
|
||||||
|
return detail ?: throw NotImplementedError("no detail configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun projectPr(path: String): PrStatus {
|
||||||
|
if (prThrows) throw RuntimeException("pr unavailable")
|
||||||
|
return prResult ?: throw NotImplementedError("no pr configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun projectLog(path: String, n: Int?): GitLogResult {
|
||||||
|
if (logThrows) throw RuntimeException("log unavailable")
|
||||||
|
return logResult ?: throw NotImplementedError("no log configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
|
||||||
|
createCalls += Triple(path, branch, base)
|
||||||
|
return createOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> {
|
||||||
|
removeCalls += Triple(path, worktreePath, force)
|
||||||
|
return removeOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> {
|
||||||
|
pruneCalls += path
|
||||||
|
return pruneOutcome
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,6 +202,11 @@ class ProjectsViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
|
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(
|
private fun proj(
|
||||||
|
|||||||
@@ -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("没有"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user