feat(android): wire the dead code, render the git panel, make the token gate real

Four agents' worth of :app wiring. Everything here existed as tested code with
zero production call sites, or as a screen rendering a shape the server stopped
sending months ago.

Dead code that shipped as features. All three were fully implemented and
unit-tested while being reachable from nothing:

  - Session-list preview thumbnails. SessionsHome called SessionListScreen
    without the `thumbnails` argument, so the `= null` default won and every
    row rendered a placeholder forever — on the home session chooser, whose
    entire purpose is seeing what each session is doing.
  - Quick-reply chips and their palette editor.
  - PointerContextMenu (large-screen secondary click).

ThumbnailPipeline's formal cross-review had been explicitly skipped when it was
written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency
code, so nothing had ever reviewed OR executed it. It has now been reviewed and
kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight
dedup and the NonCancellable in-flight release are all sound, and a retained
map entry would have poisoned a key forever.

The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare
dirty dot, a bare hash+subject commit row, and a worktree heading that renamed
itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band,
unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule
that drives it is enforced, not documented: exactly one state may render green,
and no-upstream is not that state.

Remote approvals are no longer blind. status.preview reaches GateBanner and
PlanGateSheet, so the user can see the command or diff they are approving
instead of a tool name. Absent preview stays an ordinary gate, not an error.

The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling
OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared
client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did
anything. It now installs the real AuthCookieJar — one client, so the cookie
rides the WS upgrade too — backed by the Tink-sealed store, and binds
InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever
persisting a shell credential in the clear. The persister runs on an IO scope
because OkHttp calls a CookieJar synchronously and must never block on
DataStore or Tink.

And the user-facing half that made the gate unusable: a token-gated host
answers the pairing probe's unauthenticated GET with 401, which the frozen
taxonomy could only report as "this port is running something else" — so such
a host was impossible to pair AND the copy was misleading. Pairing now routes
to a token prompt and resumes through the same cert-gate choke point. The
token is a parameter, never a field and never part of any UI state.

Docs corrected rather than left aspirational: the plan asserted a cleartext
allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform
cannot express at all — the network-security-config format has no netmask or
prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually
meant, and PROGRESS_LOG.md records the whole pass including what is still
undone: A34/A35 have no code, and device QA remains ~0%.

Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify
-> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app
run was re-measured with outputs deleted and --no-build-cache, and the APK
re-packaged, so Hilt graph validity is an observation rather than an
up-to-date claim.
This commit is contained in:
Yaojia Wang
2026-07-30 11:23:23 +02:00
parent 35d32f4670
commit 980dbaa928
36 changed files with 4249 additions and 168 deletions

View File

@@ -16,10 +16,15 @@ import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.StatusBadge
import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.viewmodels.inertText
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.PreviewDiffFile
/**
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
@@ -44,6 +49,7 @@ public fun GateBanner(
gate: GateState,
onDecide: (GateDecision, Int) -> Unit,
modifier: Modifier = Modifier,
preview: ApprovalPreview? = null,
) {
WebTermCard(modifier = modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
@@ -63,6 +69,9 @@ public fun GateBanner(
)
}
}
// WHAT is being approved sits between the label and the buttons, so the decision and the
// evidence are never on different screens. Absent → the name-only bar, unchanged.
ApprovalPreviewBlock(preview)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
Text(text = "拒绝")
@@ -75,6 +84,124 @@ public fun GateBanner(
}
}
// ── W1: the approval preview (shared by GateBanner and PlanGateSheet) ───────────
/** How many diff/command lines a gate surface paints before it says "truncated". The server already
* caps at 40 lines / 4 KB; this is the on-phone reading limit, so the buttons stay reachable. */
private const val PREVIEW_MAX_VISIBLE_LINES = 20
/**
* W1 — render WHAT a held approval would do: the shell command, or the one-file diff.
*
* Everything here is **untrusted** (server-derived from the hook's `tool_input`, which Claude and the
* repo contents influence) and therefore INERT: plain [Text] only — no Markdown, no autolink, no
* `AnnotatedString` link detection (plan §8). A `null` preview renders NOTHING: absent is a normal
* state (plan gates and unknown tools have nothing reviewable), never an error.
*/
@Composable
internal fun ApprovalPreviewBlock(preview: ApprovalPreview?, modifier: Modifier = Modifier) {
if (preview == null) return
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
) {
when (preview) {
is ApprovalPreview.Command -> CommandPreview(preview)
is ApprovalPreview.Diff -> DiffPreview(preview.file)
}
if (preview.truncated) {
Text(
text = GatePreviewCopy.TRUNCATED,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun CommandPreview(preview: ApprovalPreview.Command) {
// Newlines are content in a shell command, so the lines are kept and rendered as lines. The
// sanitizer drops every other control byte (defence in depth over the server's own stripping).
val lines = (preview.inertText() ?: return).lines()
for (line in lines.take(PREVIEW_MAX_VISIBLE_LINES)) {
Text(
text = line,
style = WebTermType.monoTabular(12),
color = MaterialTheme.colorScheme.onSurface,
)
}
if (lines.size > PREVIEW_MAX_VISIBLE_LINES) ClippedNote()
}
@Composable
private fun DiffPreview(file: PreviewDiffFile) {
Text(
text = GatePreviewCopy.diffHeader(file),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (file.binary) {
Text(text = GatePreviewCopy.BINARY, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
return
}
var painted = 0
for (hunk in file.hunks) {
if (painted >= PREVIEW_MAX_VISIBLE_LINES) break
DiffLineText(hunk.header, DIFF_KIND_HUNK)
painted++
for (line in hunk.lines) {
if (painted >= PREVIEW_MAX_VISIBLE_LINES) break
DiffLineText(line.text, line.kind)
painted++
}
}
val total = file.hunks.sumOf { it.lines.size + 1 }
if (total > painted) ClippedNote()
}
@Composable
private fun DiffLineText(text: String, kind: String) {
// An UNKNOWN kind still renders (just uncoloured): dropping a line the client cannot classify
// would hide part of what the user is approving.
val color = when (kind) {
DIFF_KIND_ADDED -> WebTermColors.statusWorking
DIFF_KIND_REMOVED -> WebTermColors.statusStuck
DIFF_KIND_HUNK, DIFF_KIND_META -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
}
Text(text = text, style = WebTermType.monoTabular(12), color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@Composable
private fun ClippedNote() {
Text(
text = GatePreviewCopy.CLIPPED,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Wire `DiffLineKind` values (`src/types.ts`) — raw strings, so an unknown kind stays itself. */
private const val DIFF_KIND_ADDED = "added"
private const val DIFF_KIND_REMOVED = "removed"
private const val DIFF_KIND_HUNK = "hunk"
private const val DIFF_KIND_META = "meta"
/** Preview copy (local UI text). */
internal object GatePreviewCopy {
const val TRUNCATED: String = "… 服务端已截断"
const val CLIPPED: String = "… 仅显示前 $PREVIEW_MAX_VISIBLE_LINES 行"
const val BINARY: String = "二进制文件"
fun diffHeader(file: PreviewDiffFile): String {
val path = if (file.oldPath != file.newPath) "${file.oldPath}${file.newPath}" else file.newPath
return "$path +${file.added}/-${file.removed}"
}
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "GateBanner")
@@ -84,6 +211,7 @@ private fun GateBannerPreview() {
GateBanner(
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
onDecide = { _, _ -> },
preview = ApprovalPreview.Command("rm -rf build/\nnpm run build", truncated = true),
)
}
}

View File

@@ -20,6 +20,7 @@ import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.GateKind
/**
@@ -49,6 +50,7 @@ public fun PlanGateSheet(
onDecide: (GateDecision, Int) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
preview: ApprovalPreview? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
@@ -69,6 +71,9 @@ public fun PlanGateSheet(
overflow = TextOverflow.Ellipsis,
)
}
// W1: a plan gate usually has nothing reviewable, but when the server DOES send a preview
// (a plan that resolves straight into an Edit/Bash) it belongs above the buttons.
ApprovalPreviewBlock(preview)
Button(
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
modifier = Modifier.fillMaxWidth(),

View File

@@ -17,6 +17,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.nav.PointerMenuPolicy
import wang.yaojia.webterm.viewmodels.SessionRow
import java.util.UUID
/**
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
@@ -59,7 +61,12 @@ public fun PointerContextMenu(
val density = LocalDensity.current
Box(
modifier = modifier.pointerInput(actions) {
// Keyed on Unit, NOT on [actions]: the gesture body only records the anchor and opens the menu —
// it never reads [actions] (the DropdownMenu below reads the current list on every composition).
// Keying on the list would restart the pointer handler on every recomposition, because the
// per-entry lambdas in a freshly-built List<ContextMenuAction> are never equal to the previous
// ones — a right-click landing during that restart window would be dropped.
modifier = modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
@@ -95,3 +102,41 @@ public fun PointerContextMenu(
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)
// ── The session-row action set (A26 — the one surface the pointer menu is attached to) ───────────────
/** 打开会话 — the same target a row tap has. */
public const val MENU_LABEL_OPEN: String = "打开会话"
/** 在当前目录开新会话 — `attach(null, cwd)` in the row's own cwd (plan §1 new-session-in-cwd). */
public const val MENU_LABEL_NEW_IN_CWD: String = "在当前目录开新会话"
/** 终止会话 — `DELETE /live-sessions/:id`, the pointer equivalent of swipe-to-kill. */
public const val MENU_LABEL_KILL: String = "终止会话"
/**
* The [PointerContextMenu] entries for one session row — the pure half of the A26 pointer menu (the
* gesture and the placement are device-QA; [PointerMenuPolicy] gates whether the menu exists at all).
*
* Mirrors iOS's pointer menu with ONE deliberate omission: **copy is absent**. Copy-out lives in the
* terminal's own text-selection `ActionMode`, not in a list-row menu, and the row has no selected text to
* copy — advertising it would be a dead entry.
*
* The new-session entry appears only when BOTH a usable cwd is known AND a spawn handler is wired: the
* server may omit `cwd` (or send blank), and `attach(null, cwd)` with no directory is not the same action.
* Every label here is a fixed app string — the server-provided cwd is carried in the CALLBACK only and is
* never rendered into a menu entry (plan §8: untrusted server strings stay out of chrome).
*/
public fun sessionRowMenuActions(
row: SessionRow,
onOpen: (UUID) -> Unit,
onNewSessionInCwd: ((String) -> Unit)?,
onKill: (UUID) -> Unit,
): List<ContextMenuAction> = buildList {
add(ContextMenuAction(MENU_LABEL_OPEN) { onOpen(row.id) })
val cwd = row.info.cwd?.takeIf { it.isNotBlank() }
if (cwd != null && onNewSessionInCwd != null) {
add(ContextMenuAction(MENU_LABEL_NEW_IN_CWD) { onNewSessionInCwd(cwd) })
}
add(ContextMenuAction(MENU_LABEL_KILL) { onKill(row.id) })
}

View File

@@ -2,20 +2,35 @@ package wang.yaojia.webterm.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
@@ -96,6 +111,241 @@ internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
gateHeld && hasChips
// ── The palette presenter (production state holder) ─────────────────────────────
/**
* The state holder the terminal screen renders the chips from: one published, immutable
* [chips] snapshot over a [QuickReplyStore].
*
* Every mutation delegates to the store — which is the CRUD authority and returns the new
* list — and republishes exactly what came back, so the UI can never drift from what was
* persisted (no optimistic local copy to reconcile, and a guarded no-op like a blank `add`
* simply republishes the unchanged list). Nothing is published before [load]: an unloaded
* palette shows no chips rather than flashing the defaults over the user's real list.
*
* Plain (not an `androidx.lifecycle.ViewModel`) so it drives under `runTest` virtual time;
* the screen `remember`s one and loads it in a `LaunchedEffect`. Cross-device sync is out of
* scope (plan §1) — this is one device's palette.
*/
public class QuickReplyPalette(private val store: QuickReplyStore) {
private val _chips = MutableStateFlow<List<QuickReplyChip>>(emptyList())
/** The palette in display order; empty until [load] (and after a user "delete all"). */
public val chips: StateFlow<List<QuickReplyChip>> = _chips.asStateFlow()
/** Read the persisted palette (defaults on first ever read) and publish it. */
public suspend fun load() {
_chips.value = store.loadAll()
}
/** Append [text] (blank is a store-level no-op) and republish. */
public suspend fun add(text: String) {
_chips.value = store.add(text)
}
/** Replace the text of [id] and republish (unknown id / blank text → no-op). */
public suspend fun edit(id: String, text: String) {
_chips.value = store.edit(id, text)
}
/** Remove [id] and republish (unknown id → no-op). */
public suspend fun remove(id: String) {
_chips.value = store.remove(id)
}
/** Reorder [from]→[to] and republish (out-of-range → no-op). */
public suspend fun move(from: Int, to: Int) {
_chips.value = store.move(from, to)
}
}
// ── The editor's select / commit reducer (pure, JVM-tested) ─────────────────────
/**
* The editor's whole mutable state: ONE text field that either appends a new chip
* ([selectedId] null) or rewrites the selected one. Immutable — every transition returns a
* fresh snapshot.
*
* A single field (rather than an inline field per row) is deliberate: a per-row bound field
* would write to the store on every keystroke, i.e. one DataStore round-trip per character.
*/
internal data class QuickReplyEditorState(
/** The chip being rewritten, or null while composing a new one. */
val selectedId: String? = null,
/** The field's current text, carried VERBATIM into the commit (whitespace preserved). */
val draft: String = "",
)
/** What committing the current [QuickReplyEditorState] does. */
internal enum class QuickReplyCommit { ADD, EDIT, NONE }
/** Load [chip] into the field for rewriting (its id becomes the commit target). */
internal fun QuickReplyEditorState.selecting(chip: QuickReplyChip): QuickReplyEditorState =
copy(selectedId = chip.id, draft = chip.text)
/** Back to "composing a new chip" with an empty field. */
internal fun QuickReplyEditorState.cleared(): QuickReplyEditorState =
QuickReplyEditorState()
/**
* A blank draft commits NOTHING (mirrors the store's blank guard, so the button can be
* disabled instead of silently no-op-ing); otherwise ADD when nothing is selected, EDIT when
* a chip is.
*/
internal fun QuickReplyEditorState.commitKind(): QuickReplyCommit = when {
draft.isBlank() -> QuickReplyCommit.NONE
selectedId == null -> QuickReplyCommit.ADD
else -> QuickReplyCommit.EDIT
}
// ── The palette editor ──────────────────────────────────────────────────────────
/**
* The editable-palette surface (plan §1 "Quick-reply chips + editable palette"): an
* [AlertDialog] over the store's CRUD. One field composes/rewrites; each row can be selected
* (tap 编辑), reordered (▲/▼) or deleted (✕).
*
* The chip texts are the user's OWN strings — never server data — but they are still rendered
* as plain inert [Text] (no Markdown/autolink), consistent with §8.
*
* Layout/scroll behaviour is device-QA (§7); the select/commit decision is the JVM-tested
* [QuickReplyEditorState] reducer.
*
* @param chips the current palette snapshot (from [QuickReplyPalette.chips]).
* @param onAdd / [onEdit] / [onRemove] / [onMove] the store-backed CRUD callbacks.
* @param onDismiss close the editor.
*/
@Composable
public fun QuickReplyPaletteEditor(
chips: List<QuickReplyChip>,
onAdd: (String) -> Unit,
onEdit: (id: String, text: String) -> Unit,
onRemove: (id: String) -> Unit,
onMove: (from: Int, to: Int) -> Unit,
onDismiss: () -> Unit,
) {
var state by remember { mutableStateOf(QuickReplyEditorState()) }
// A chip removed while selected must not leave a dangling edit target pointing at a gone id.
// DERIVED, not written back during composition (a composition-time state write is a recomposition
// hazard): the field simply falls back to "adding" and keeps whatever was typed.
val effective = if (state.selectedId != null && chips.none { it.id == state.selectedId }) {
state.copy(selectedId = null)
} else {
state
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("快捷回复") },
text = {
Column(
modifier = Modifier.heightIn(max = EDITOR_MAX_HEIGHT_DP.dp),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
QuickReplyEditorField(
state = effective,
onDraftChange = { state = effective.copy(draft = it) },
onCommit = {
when (effective.commitKind()) {
QuickReplyCommit.ADD -> onAdd(effective.draft)
QuickReplyCommit.EDIT -> onEdit(requireNotNull(effective.selectedId), effective.draft)
QuickReplyCommit.NONE -> Unit
}
state = effective.cleared()
},
onCancelSelection = { state = effective.cleared() },
)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
if (chips.isEmpty()) {
Text("还没有快捷回复。", style = MaterialTheme.typography.bodySmall)
}
chips.forEachIndexed { index, chip ->
QuickReplyEditorRow(
chip = chip,
isSelected = chip.id == effective.selectedId,
canMoveUp = index > 0,
canMoveDown = index < chips.lastIndex,
onSelect = { state = effective.selecting(chip) },
onMoveUp = { onMove(index, index - 1) },
onMoveDown = { onMove(index, index + 1) },
onRemove = { onRemove(chip.id) },
)
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("完成") } },
)
}
/** The single compose/rewrite field + its commit action. */
@Composable
private fun QuickReplyEditorField(
state: QuickReplyEditorState,
onDraftChange: (String) -> Unit,
onCommit: () -> Unit,
onCancelSelection: () -> Unit,
) {
OutlinedTextField(
value = state.draft,
onValueChange = onDraftChange,
label = { Text(if (state.selectedId == null) "新增内容" else "修改内容") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TextButton(
onClick = onCommit,
enabled = state.commitKind() != QuickReplyCommit.NONE,
) { Text(if (state.selectedId == null) "添加" else "保存") }
if (state.selectedId != null) {
TextButton(onClick = onCancelSelection) { Text("取消") }
}
}
}
/** One palette row: the (inert) text plus select / reorder / delete affordances. */
@Composable
private fun QuickReplyEditorRow(
chip: QuickReplyChip,
isSelected: Boolean,
canMoveUp: Boolean,
canMoveDown: Boolean,
onSelect: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onRemove: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = chip.text,
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onSelect) { Text("编辑") }
TextButton(onClick = onMoveUp, enabled = canMoveUp) { Text("") }
TextButton(onClick = onMoveDown, enabled = canMoveDown) { Text("") }
TextButton(onClick = onRemove) { Text("") }
}
}
/** Cap on the editor body height so a long palette scrolls inside the dialog. */
private const val EDITOR_MAX_HEIGHT_DP: Int = 320
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "QuickReply (gate held)")
@@ -109,3 +359,18 @@ private fun QuickReplyPreview() {
)
}
}
@Preview(name = "QuickReply palette editor")
@Composable
private fun QuickReplyPaletteEditorPreview() {
WebTermTheme {
QuickReplyPaletteEditor(
chips = QuickReplyDefaults.chips,
onAdd = {},
onEdit = { _, _ -> },
onRemove = {},
onMove = { _, _ -> },
onDismiss = {},
)
}
}

View File

@@ -0,0 +1,136 @@
package wang.yaojia.webterm.di
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.CookieJar
import wang.yaojia.webterm.hostregistry.AuthCookieCipher
import wang.yaojia.webterm.hostregistry.AuthCookieStore
import wang.yaojia.webterm.hostregistry.DataStoreAuthCookieStore
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
import wang.yaojia.webterm.tlsandroid.TinkAuthCookieCipher
import wang.yaojia.webterm.transport.AuthCookieJar
import wang.yaojia.webterm.wiring.toRecord
import javax.inject.Singleton
/**
* The optional `WEBTERM_TOKEN` gate's client boundary (server `src/http/auth.ts`).
*
* Three things are wired here, and all three are required for the gate to work at all:
* 1. **[AuthCookieJar] on the ONE shared `OkHttpClient`** (via the [CookieJar] binding [NetworkModule]
* consumes). Both transports share that client, so the `webterm_auth` cookie rides every REST call
* AND the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls too). Without this the
* client runs on `CookieJar.NO_COOKIES` and a token-gated host 401s everything.
* 2. **[AuthCookieStore]** so the credential survives a process restart — encrypted at rest.
* 3. **The [AuthCookieCipher] seam** bridging `:host-registry` (which must not depend on Tink) to
* `:client-tls-android`'s [TinkAuthCookieCipher] (which must not depend on `:host-registry`) — the
* same sibling-bridge pattern [NetworkModule] uses for the mTLS `ClientIdentityProvider`.
*
* ### Fail CLOSED
* [DataStoreAuthCookieStore] takes the cipher as a REQUIRED constructor argument precisely so no wiring
* can persist plaintext by omission. If a real cipher cannot be constructed, this module binds
* [InMemoryAuthCookieStore] instead: memory-only persistence (the user re-authenticates after a restart)
* is the correct answer, and writing a full-shell credential in the clear is not an option. A cipher that
* constructs but later fails to seal is handled the same way one level down — the store persists NOTHING
* and drops any previous blob.
*
* ### Threading
* The graph builds nothing expensive: [TinkAuthCookieCipher] defers all AEAD/keystore work to first use,
* and `AppEnvironment.warmUp()` does the first read off `Main`. The persister side must be non-blocking
* because OkHttp calls a `CookieJar` synchronously on the call thread, so the durable write is handed to
* an app-scoped IO scope.
*/
@Module
@InstallIn(SingletonComponent::class)
public object AuthCookieModule {
/**
* The durable home of the `webterm_auth` cookie. Shares the ONE Preferences DataStore with the host
* list / last-session pointer ([StorageModule]) — disjoint keys (`authCookiesSealed` vs `hosts` /
* `lastSessionId.<hostId>`) — but the VALUE here is Tink-AEAD ciphertext, never plaintext.
*/
@Provides
@Singleton
public fun provideAuthCookieStore(
dataStore: DataStore<Preferences>,
@ApplicationContext context: Context,
): AuthCookieStore {
val cipher = buildAuthCookieCipher(context)
if (cipher == null) {
// Fail closed: memory-only rather than a plaintext credential at rest.
Log.w(TAG, "auth-cookie cipher unavailable — the session cookie will not be persisted")
return InMemoryAuthCookieStore()
}
return DataStoreAuthCookieStore(
dataStore = dataStore,
cipher = cipher,
onCipherFailure = ::reportCipherFailure,
)
}
/**
* The ONE jar. Its persister hands each host's changed cookie set to [AuthCookieStore] on an
* app-scoped IO scope — never blocking OkHttp's call thread on DataStore/Tink I/O. The scope lives for
* the process (there is no later moment at which a pending credential write should be abandoned).
*/
@Provides
@Singleton
public fun provideAuthCookieJar(store: AuthCookieStore): AuthCookieJar {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
return AuthCookieJar(
persister = { hostKey, cookies ->
scope.launch {
runCatching { store.replaceHost(hostKey, cookies.map { it.toRecord(hostKey) }) }
.onFailure(::reportPersistFailure)
}
},
)
}
/** What [NetworkModule] installs on the shared client — the same instance as [provideAuthCookieJar]. */
@Provides
@Singleton
public fun provideCookieJar(jar: AuthCookieJar): CookieJar = jar
/**
* The 4-line adapter [TinkAuthCookieCipher]'s KDoc prescribes: it matches [AuthCookieCipher]'s shape
* exactly but cannot declare it (sibling modules), so `:app` bridges the two.
*
* Returns null if the cipher cannot even be CONSTRUCTED. Today's implementation defers all keystore
* work to first use and so does not throw here; the guard exists because the alternative to "no
* cipher" must always be "no persistence", never "plaintext persistence".
*/
private fun buildAuthCookieCipher(context: Context): AuthCookieCipher? =
runCatching {
val tink = TinkAuthCookieCipher(context)
object : AuthCookieCipher {
override fun seal(plaintext: String): String = tink.seal(plaintext)
override fun open(sealed: String): String = tink.open(sealed)
}
}.getOrNull()
/**
* A seal/open failure means a credential was dropped instead of stored (or a stored one could not be
* read). Reported by TYPE only: neither the plaintext nor the ciphertext may reach a log.
*/
private fun reportCipherFailure(error: Throwable) {
Log.w(TAG, "auth-cookie at-rest crypto failed (${error::class.simpleName}) — nothing was persisted")
}
/** A durable-write failure leaves the cookie in memory only; same logging discipline. */
private fun reportPersistFailure(error: Throwable) {
Log.w(TAG, "auth-cookie persist failed (${error::class.simpleName}) — cookie kept in memory only")
}
private const val TAG: String = "WebTermAuthCookie"
}

View File

@@ -5,6 +5,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.ConnectionPool
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import wang.yaojia.webterm.transport.ClientIdentity
import wang.yaojia.webterm.transport.ClientIdentityProvider
@@ -27,6 +28,11 @@ import javax.inject.Singleton
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
* client rebuild (R4). The provider is read ONCE, when the client is built.
*
* ### The WEBTERM_TOKEN cookie jar
* The shared client also carries the ONE [CookieJar] provided by [AuthCookieModule], so the optional
* access-token gate applies uniformly to REST and to the WS upgrade. See that module for the at-rest
* (Tink AEAD) custody of the cookie.
*
* ### Breaking the construction cycle (A11's frozen constructor)
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
@@ -53,14 +59,24 @@ public object NetworkModule {
ClientIdentity(material.sslSocketFactory, material.trustManager)
}
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */
/**
* The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool] and
* carrying the ONE [CookieJar] ([AuthCookieModule]).
*
* The cookie jar is what makes the optional `WEBTERM_TOKEN` gate work: a gated host answers pairing
* with `Set-Cookie: webterm_auth=…` and then requires that cookie on every remote route AND on the WS
* upgrade. Because both transports share this client, installing the jar HERE covers both at once
* (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Leaving it at `CookieJar.NO_COOKIES` — as
* this provider did before — makes the whole token feature inert.
*/
@Provides
@Singleton
public fun provideOkHttpClient(
identityProvider: ClientIdentityProvider,
connectionPool: ConnectionPool,
cookieJar: CookieJar,
): OkHttpClient =
OkHttpClientFactory.create(identityProvider)
OkHttpClientFactory.create(identityProvider, cookieJar)
.newBuilder()
.connectionPool(connectionPool)
.build()

View File

@@ -0,0 +1,31 @@
package wang.yaojia.webterm.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.wiring.CanvasThumbnailRasterizer
import wang.yaojia.webterm.wiring.ThumbnailRasterizer
import javax.inject.Singleton
/**
* Session-preview boundary (plan §6.7): binds the off-screen rasterizer the thumbnail pipeline paints
* with, so `AppEnvironment.buildThumbnailPipeline(endpoint)` can mint a per-host pipeline and the session
* list finally renders real previews instead of placeholder tiles.
*
* ONE rasterizer for the whole app: its cell metrics are measured once, and it is explicitly safe to
* share across the pipeline's concurrent renders (each `rasterize` copies the metrics `Paint` into a
* local draw `Paint` — see [CanvasThumbnailRasterizer]).
*
* Consumers inject it as `dagger.Lazy` (`AppEnvironment`): constructing it measures a `Paint`, which is an
* `android.graphics` touch that must not happen while the Hilt graph is assembled (android.graphics is
* stubbed under JVM unit tests, and graph assembly happens on `Main`).
*/
@Module
@InstallIn(SingletonComponent::class)
public object ThumbnailModule {
@Provides
@Singleton
public fun provideThumbnailRasterizer(): ThumbnailRasterizer = CanvasThumbnailRasterizer()
}

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -18,12 +20,16 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import wang.yaojia.webterm.components.ContinueLastBanner
import wang.yaojia.webterm.components.SessionThumbnails
import wang.yaojia.webterm.components.continueLastModel
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.AdaptiveHome
import wang.yaojia.webterm.screens.SessionListScreen
import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway
import wang.yaojia.webterm.viewmodels.SessionListViewModel
import wang.yaojia.webterm.wiring.AppEnvironment
import wang.yaojia.webterm.wiring.ThumbnailPipeline
import wang.yaojia.webterm.wiring.sessionThumbnailPipeline
import java.util.UUID
/**
@@ -39,6 +45,21 @@ import java.util.UUID
*
* The [SessionListViewModel] host menu drives its own active-host selection; this pane reads the resolved
* active host id from its state to build the terminal endpoint / continue-last pointer.
*
* ### Live preview thumbnails (plan §6.7)
* This is the ONE production construction point of the [ThumbnailPipeline]: the active host's endpoint is
* resolved to an [ApiClient][wang.yaojia.webterm.api.routes.ApiClient] and handed to
* [sessionThumbnailPipeline], whose [SessionThumbnails] adapter is threaded into [SessionListScreen] so
* every row shows the session's actual screen instead of a placeholder tile. The fetch is the READ-ONLY
* `GET /live-sessions/:id/preview` — it never attaches, so merely looking at the chooser cannot inflate a
* session's client count or reset its idle clock. The pipeline is pruned to the current row set on every
* poll emission (dead sessions release their bitmaps) and closed when this pane leaves the composition.
*
* ### Pointer context menu (A26)
* The resolved [LayoutMode] is passed down so [SessionListScreen] can wrap each row in a
* [PointerContextMenu][wang.yaojia.webterm.components.PointerContextMenu]; the gate itself is
* [PointerMenuPolicy], evaluated inside that wrapper. The menu's「在当前目录开新会话」routes exactly like
* the terminal toolbar's does.
*/
@Composable
public fun SessionsHome(
@@ -70,6 +91,38 @@ public fun SessionsHome(
val openNewSession: () -> Unit = {
activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) }
}
// A26 pointer-menu「在当前目录开新会话」— same `attach(null, cwd)` route the terminal toolbar uses.
val openNewSessionInCwd: (String) -> Unit = { cwd ->
activeHostId?.let { navController.navigate(newTerminalRoute(it, cwd)) }
}
// ── §6.7 live preview thumbnails, for the ACTIVE host ────────────────────────────────────────────
// The VM exposes only host ids, so resolve the endpoint here (the pipeline needs a per-host ApiClient).
val activeHost by produceState<Host?>(initialValue = null, key1 = activeHostId) {
// Clear FIRST: produceState keeps the previous value across a key change, so without this a host
// switch would leave the OLD host's endpoint in place and the pipeline would fetch host A's
// session ids against host B until the resolve lands.
value = null
value = activeHostId?.let { id ->
runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == id } }.getOrNull()
}
}
// Constructing the pipeline is cheap and Main-safe: the ApiClient (→ shared OkHttpClient → mTLS
// material) resolves lazily on the first fetch, which runs on the pipeline's own dispatcher.
val pipeline: ThumbnailPipeline? = remember(activeHost) {
activeHost?.let { host -> sessionThumbnailPipeline { env.apiClientFactory.create(host.endpoint) } }
}
DisposableEffect(pipeline) {
onDispose { pipeline?.close() }
}
val thumbnails: SessionThumbnails? = remember(pipeline) {
pipeline?.let { live -> SessionThumbnails { session -> live.thumbnail(session) } }
}
// Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt
// keys — a thumbnail must never outlive the session it depicts.
LaunchedEffect(pipeline, state.rows) {
pipeline?.retainOnly(state.rows.map { it.info })
}
AdaptiveHome(
listPane = {
@@ -89,6 +142,9 @@ public fun SessionsHome(
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
onEnroll = { navController.navigate(NavRoutes.ENROLL) },
onImportCert = { navController.navigate(NavRoutes.CERT) },
thumbnails = thumbnails,
layoutMode = mode,
onNewSessionInCwd = openNewSessionInCwd,
)
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -38,6 +39,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
@@ -50,8 +53,10 @@ import com.google.mlkit.vision.common.InputImage
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.viewmodels.EntryError
import wang.yaojia.webterm.viewmodels.PairingCopy
import wang.yaojia.webterm.viewmodels.PairingUiState
import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.TokenError
import wang.yaojia.webterm.viewmodels.WarningTier
import wang.yaojia.webterm.viewmodels.needsExplicitAck
@@ -71,10 +76,14 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck
* - **POST_NOTIFICATIONS** is NOT requested here — push registration owns that prompt (A31); pairing
* only establishes the host.
*
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
* JVM-tested in `PairingViewModelTest`.
* A host behind the optional `WEBTERM_TOKEN` gate cannot answer the probe at all until it is
* authenticated, so the flow has one more step: the masked access-token prompt (see [TokenRequiredStep]).
*
* @param viewModel the pairing state machine (nav layer builds it with the real [pairingProber]).
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
* JVM-tested in `PairingViewModelTest` + `PairingTokenFlowTest`.
*
* @param viewModel the pairing state machine (the nav layer builds it with the real prober from
* `AppEnvironment.buildPairingProber()`).
* @param onPaired invoked once with the persisted [Host] after a successful pair.
* @param onImportCert opens the device-cert screen (A27) when a tunnel host is cert-gated.
*/
@@ -114,6 +123,13 @@ public fun PairingScreen(
onRetry = { viewModel.retry() },
onCancel = viewModel::reset,
)
is PairingUiState.TokenRequired -> TokenRequiredStep(
host = s.name,
error = s.error,
onSubmit = viewModel::submitAccessToken,
onCancel = viewModel::reset,
)
is PairingUiState.TokenSubmitting -> BusyStep(message = "正在验证访问令牌 …")
is PairingUiState.Failed -> FailedStep(
message = s.message,
needsAck = s.tier.needsExplicitAck,
@@ -295,14 +311,82 @@ private fun tierCopy(tier: WarningTier): Pair<String, String> = when (tier) {
@Composable
private fun ProbingStep(host: String) {
BusyStep(message = "正在验证 $host")
}
@Composable
private fun BusyStep(message: String) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
CircularProgressIndicator()
Text("正在验证 $host", style = MaterialTheme.typography.bodyMedium)
Text(message, style = MaterialTheme.typography.bodyMedium)
}
}
}
// ── Access token (the optional WEBTERM_TOKEN gate) ───────────────────────────────────────────────────
/**
* The access-token prompt for a `WEBTERM_TOKEN`-gated host.
*
* The token is a **shell credential**, so the field is masked ([PasswordVisualTransformation]) and typed
* as [KeyboardType.Password] (which also keeps it out of the keyboard's suggestion/learning store), and
* the composition drops it the instant it is handed to the ViewModel — nothing here, and nothing in the
* resulting UI state, retains it. All copy is app-authored and inert (plan §8).
*/
@Composable
private fun TokenRequiredStep(
host: String,
error: TokenError?,
onSubmit: (String) -> Unit,
onCancel: () -> Unit,
) {
var token by remember(host) { mutableStateOf("") }
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(Spacing.md12),
) {
Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text("需要访问令牌", style = MaterialTheme.typography.titleSmall)
Text(
text = "$host 启用了访问令牌WEBTERM_TOKEN。输入令牌后即可继续配对令牌只用于本次验证不会显示或记录。",
style = MaterialTheme.typography.bodySmall,
)
}
}
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text("访问令牌") },
singleLine = true,
isError = error != null,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Text(
text = PairingCopy.describeToken(error),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
Button(
onClick = {
// Hand it over and forget it in the same breath — never leave the credential in state.
val submitted = token
token = ""
onSubmit(submitted)
},
enabled = token.isNotBlank(),
modifier = Modifier.weight(1f),
) { Text("提交") }
}
}
@Composable
private fun CertRequiredStep(
host: String,

View File

@@ -37,10 +37,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.SyncState
import wang.yaojia.webterm.api.models.WorktreeInfo
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.Stroke
@@ -48,8 +50,18 @@ import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.GitChipTone
import wang.yaojia.webterm.viewmodels.GitPanelCopy
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.SyncBand
import wang.yaojia.webterm.viewmodels.boundaryIndex
import wang.yaojia.webterm.viewmodels.boundaryLabel
import wang.yaojia.webterm.viewmodels.isUnpushed
import wang.yaojia.webterm.viewmodels.WorktreeRowState
import wang.yaojia.webterm.viewmodels.WorktreeViewModel
import wang.yaojia.webterm.viewmodels.headerDirtyChip
import wang.yaojia.webterm.viewmodels.syncBandOf
import wang.yaojia.webterm.viewmodels.worktreeChips
import java.net.URI
/**
@@ -74,8 +86,15 @@ public fun ProjectDetailScreen(
val phase by viewModel.phase.collectAsStateWithLifecycle()
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
val fetch by viewModel.fetchPhase.collectAsStateWithLifecycle()
val worktreeStates by viewModel.worktreeStates.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LaunchedEffect(viewModel) { viewModel.load() }
LaunchedEffect(viewModel) {
viewModel.load()
// w6/G7: probe the other worktrees once per opened project (never per tick) — each row's
// failure is isolated, so a bad probe costs one chip, not the panel.
viewModel.probeWorktrees()
}
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
@@ -91,7 +110,12 @@ public fun ProjectDetailScreen(
detail = current.detail,
prChip = prChip,
recent = recent,
fetch = fetch,
canFetch = viewModel.canFetch,
onFetch = { scope.launch { viewModel.fetchUpstream() } },
onDismissFetchBanner = viewModel::clearFetchBanner,
worktree = viewModel.worktree,
worktreeStates = worktreeStates,
onOpenClaude = onOpenClaude,
onViewDiff = onViewDiff,
)
@@ -120,6 +144,11 @@ private fun DetailBody(
worktree: WorktreeViewModel?,
onOpenClaude: (String) -> Unit,
onViewDiff: (String) -> Unit = {},
fetch: ProjectDetailViewModel.FetchPhase = ProjectDetailViewModel.FetchPhase.Idle,
canFetch: Boolean = false,
onFetch: () -> Unit = {},
onDismissFetchBanner: () -> Unit = {},
worktreeStates: Map<String, WorktreeRowState> = emptyMap(),
) {
Column(
modifier = Modifier
@@ -139,19 +168,34 @@ private fun DetailBody(
modifier = Modifier.weight(1f),
)
detail.branch?.let { Text(text = it, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) }
if (detail.dirty == true) Text(text = "", color = WebTermColors.statusWaiting)
// w6/G3: a count beats a bare dot — "how many uncommitted" is the question the dot dodged.
headerDirtyChip(detail.dirtyCount, detail.dirty)?.let {
Text(text = it, style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
}
}
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
SyncBandCard(
// On a detached HEAD there is no branch to show, so the short sha stands in for it; the
// current worktree row is where the server reports it.
band = syncBandOf(detail.sync, head = detail.worktrees.firstOrNull { it.isCurrent }?.head),
fetch = fetch,
canFetch = canFetch,
onFetch = onFetch,
onDismissFetchBanner = onDismissFetchBanner,
)
PrChipRow(prChip)
if (detail.isGit && worktree != null) {
WorktreeSection(detail = detail, worktree = worktree)
WorktreeSection(detail = detail, worktree = worktree, worktreeStates = worktreeStates)
} else {
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size))
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)
else for (w in detail.worktrees) {
WorktreeRow(w, onRemove = null, state = worktreeStateOf(w, detail, worktreeStates))
}
}
val running = detail.sessions.filter { !it.exited }
@@ -173,6 +217,134 @@ private fun DetailBody(
}
}
// ── w6/G3: the sync band ─────────────────────────────────────────────────────────────────────────
/**
* The header sync band: upstream · ↑ahead · ↓behind · Fetch. Stacked (not a 4-column row) because a
* phone is narrow and the mock's desktop row collapses to a column at this width anyway.
*
* **The one design rule:** only [SyncBand.isAllClear] paints green. A stale `↓0` carries the
* "存疑" chip on the NUMBER (↑ never does — it needs only local refs), and a branch with no upstream
* says so in words. Absent facts render nothing at all.
*/
@Composable
private fun SyncBandCard(
band: SyncBand?,
fetch: ProjectDetailViewModel.FetchPhase,
canFetch: Boolean,
onFetch: () -> Unit,
onDismissFetchBanner: () -> Unit,
) {
if (band == null) return
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
when (band) {
is SyncBand.Detached -> SyncCell(GitPanelCopy.HEAD_CAPTION) {
Text(
text = band.head?.let { "${GitPanelCopy.DETACHED_CHIP} @ $it" } ?: GitPanelCopy.DETACHED_CHIP,
style = WebTermType.metaMono,
color = WebTermColors.statusStuck,
)
}
SyncBand.NoUpstream -> {
SyncCell(GitPanelCopy.UPSTREAM_CAPTION) {
Text(text = GitPanelCopy.NO_UPSTREAM_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck)
}
EmptyLine(GitPanelCopy.NO_UPSTREAM_NOTE)
}
is SyncBand.Tracking -> TrackingCells(band)
}
FetchRow(
band = band,
fetch = fetch,
canFetch = canFetch,
onFetch = onFetch,
onDismissFetchBanner = onDismissFetchBanner,
)
}
}
}
@Composable
private fun TrackingCells(band: SyncBand.Tracking) {
SyncCell(GitPanelCopy.UPSTREAM_CAPTION) {
// Upstream is a server string (a remote-tracking ref name) → inert text.
Text(text = band.upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SyncCell(GitPanelCopy.TO_PUSH_CAPTION) {
Text(
text = GitPanelCopy.aheadChip(band.ahead),
style = WebTermType.metaMono,
color = if (band.ahead > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (band.isAllClear) {
Text(text = GitPanelCopy.IN_SYNC_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusWorking)
}
}
EmptyLine(if (band.ahead == 0) GitPanelCopy.NOTHING_TO_PUSH_NOTE else GitPanelCopy.aheadNote(band.ahead))
SyncCell(GitPanelCopy.TO_PULL_CAPTION) {
Text(
text = GitPanelCopy.behindChip(band.behind),
style = WebTermType.metaMono,
// Behind is read off a CACHED remote ref: dim the digit itself when it cannot be trusted.
color = if (band.isStale) WebTermColors.statusStuck else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (band.isStale) {
Text(text = GitPanelCopy.STALE_CHIP, style = WebTermType.metaMono, color = WebTermColors.statusStuck)
}
}
EmptyLine(
when {
band.isStale && band.lastFetchMs == null -> GitPanelCopy.NEVER_FETCHED_NOTE
band.isStale -> GitPanelCopy.STALE_NOTE
band.behind == 0 -> GitPanelCopy.UP_TO_DATE_NOTE
else -> GitPanelCopy.WAITING_ON_REMOTE_NOTE
},
)
}
@Composable
private fun SyncCell(caption: String, value: @Composable () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(
text = caption,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
value()
}
}
@Composable
private fun FetchRow(
band: SyncBand,
fetch: ProjectDetailViewModel.FetchPhase,
canFetch: Boolean,
onFetch: () -> Unit,
onDismissFetchBanner: () -> Unit,
) {
val working = fetch == ProjectDetailViewModel.FetchPhase.Working
// Fetch only moves remote-tracking refs — never a pull, never a merge. Disabled on a detached
// HEAD (nothing to compare) and while one is in flight (a second tap is dropped, not queued).
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(enabled = canFetch && band.canFetch && !working, onClick = onFetch) {
Text(GitPanelCopy.FETCH)
}
when (fetch) {
ProjectDetailViewModel.FetchPhase.Idle -> if (!band.canFetch) {
EmptyLine(GitPanelCopy.FETCH_DISABLED_DETACHED)
}
ProjectDetailViewModel.FetchPhase.Working -> EmptyLine(GitPanelCopy.FETCH_WORKING)
is ProjectDetailViewModel.FetchPhase.Done ->
BannerLine(GitPanelCopy.FETCH_DONE, WebTermColors.statusWorking, onDismissFetchBanner)
is ProjectDetailViewModel.FetchPhase.Failed ->
BannerLine(fetch.message, WebTermColors.statusStuck, onDismissFetchBanner)
}
}
}
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
@Composable
@@ -230,18 +402,30 @@ private fun isHttpsUrl(url: String): Boolean =
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
@Composable
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
private fun WorktreeSection(
detail: ProjectDetail,
worktree: WorktreeViewModel,
worktreeStates: Map<String, WorktreeRowState>,
) {
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 "分支")
// w6/G5: ALWAYS "Worktrees (n)" — one worktree per session makes the count the point, and a
// section that renames itself to "Branch" at n=1 hides the whole model.
SectionTitle(GitPanelCopy.worktreesTitle(detail.worktrees.size))
if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
} else {
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
for (w in detail.worktrees) {
WorktreeRow(
worktree = w,
state = worktreeStateOf(w, detail, worktreeStates),
onRemove = { if (!w.isMain) removeTarget = w },
)
}
}
// New-worktree inline form.
@@ -337,31 +521,77 @@ private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
when (recent) {
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
ProjectDetailViewModel.RecentCommits.Loading -> {
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("正在读取提交记录…")
}
ProjectDetailViewModel.RecentCommits.Unavailable -> {
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
SectionTitle(GitPanelCopy.RECENT_COMMITS); EmptyLine("提交记录不可用。")
}
is ProjectDetailViewModel.RecentCommits.Loaded -> {
SectionTitle("最近提交")
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
else for (commit in recent.result.commits) CommitRow(commit)
is ProjectDetailViewModel.RecentCommits.Loaded -> CommitList(recent.result)
}
}
/**
* The commit list with w6/G4's unpushed marking: a marked row carries `↑` and the accent, and the
* upstream boundary is drawn EXACTLY ONCE, right after the last marked row — it is the real position
* of `origin/<branch>` on this timeline, so it is only drawn when there is an upstream to name.
*/
@Composable
private fun CommitList(log: GitLogResult) {
val boundaryLabel = log.boundaryLabel()
val boundaryIndex = log.boundaryIndex()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
SectionTitle(GitPanelCopy.RECENT_COMMITS)
// The count belongs ON the heading: it qualifies "recent commits", it is not its own statement.
if (log.unpushedCount > 0 && boundaryLabel != null) {
Text(
text = GitPanelCopy.unpushedBadge(log.unpushedCount),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (log.commits.isEmpty()) {
EmptyLine("暂无提交。")
return
}
log.commits.forEachIndexed { index, commit ->
CommitRow(commit)
if (index == boundaryIndex && boundaryLabel != null) UpstreamBoundary(boundaryLabel)
}
if (log.truncated) EmptyLine(GitPanelCopy.truncatedNote(log.commits.size))
}
@Composable
private fun UpstreamBoundary(upstream: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
// Inert: `upstream` is a server-supplied ref name.
Text(text = upstream, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outline,
thickness = Stroke.hairline,
)
}
}
@Composable
private fun CommitRow(commit: CommitLogEntry) {
val unpushed = commit.isUnpushed()
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
Text(
text = commit.hash.take(7),
text = if (unpushed) GitPanelCopy.UNPUSHED_MARK else " ",
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = commit.hash.take(SHORT_HASH_LENGTH),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = commit.subject,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
color = if (unpushed) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
@@ -369,25 +599,59 @@ private fun CommitRow(commit: CommitLogEntry) {
}
}
/** `git log --format=%h` width — the same 7 the rest of the cockpit shows. */
private const val SHORT_HASH_LENGTH = 7
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
/**
* One worktree row: identity + state chips on top, path underneath (on one line the path and the
* chips competed for width and whichever lost got truncated).
*
* The chips come from [worktreeChips], which deliberately has no green "clean" chip: only a fresh
* `↑0 ↓0` earns green, and "no upstream" — the normal state of a fresh worktree branch — is a warning.
*/
@Composable
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
private fun WorktreeRow(
worktree: WorktreeInfo,
onRemove: (() -> Unit)?,
state: WorktreeRowState? = null,
) {
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) {
Text(text = label, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
if (worktree.isMain) Tag("main")
if (worktree.isCurrent) Tag("current")
if (worktree.locked == true) Tag("locked")
for (chip in worktreeChips(worktree, state)) Tag(chip.label, chip.tone)
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)
if (state != null && state.sessionCount > 0) {
Text(
text = GitPanelCopy.sessionCount(state.sessionCount),
style = WebTermType.metaMono,
color = WebTermColors.statusWorking,
)
}
}
}
}
/**
* Per-row git state. The CURRENT worktree's state is the project's own — free, already loaded. Other
* rows need the w6/G7 per-worktree probe (`GET /projects/worktree/state`), which this client does not
* have a route for yet, so they stay unprobed and show no sync chip rather than a guess.
*/
private fun worktreeStateOf(
worktree: WorktreeInfo,
detail: ProjectDetail,
probed: Map<String, WorktreeRowState>,
): WorktreeRowState? {
if (!worktree.isCurrent) return probed[worktree.path]
if (detail.sync == null && detail.dirtyCount == null) return null
return WorktreeRowState(sync = detail.sync, dirtyCount = detail.dirtyCount)
}
@Composable
private fun SessionRow(session: ProjectSessionRef) {
WebTermCard(modifier = Modifier.fillMaxWidth()) {
@@ -413,8 +677,15 @@ private fun ClaudeMdBlock(text: String) {
}
@Composable
private fun Tag(text: String) {
Text(text = text, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
private fun Tag(text: String, tone: GitChipTone = GitChipTone.ACCENT) {
val color = when (tone) {
GitChipTone.NEUTRAL -> MaterialTheme.colorScheme.onSurfaceVariant
GitChipTone.ACCENT -> MaterialTheme.colorScheme.primary
GitChipTone.WARN -> WebTermColors.statusStuck
GitChipTone.DIRTY -> WebTermColors.statusWaiting
GitChipTone.OK -> WebTermColors.statusWorking
}
Text(text = text, style = WebTermType.metaMono, color = color)
}
@Composable
@@ -452,16 +723,27 @@ private fun Centered(content: @Composable () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() }
}
@Preview(name = "ProjectDetailScreen — loaded")
@Preview(name = "ProjectDetailScreen — git panel (stale ↓, 9 to push)")
@Composable
private fun ProjectDetailScreenPreview() {
val detail = ProjectDetail(
name = "Acme.Web.frontend",
path = "/home/dev/acme-web",
isGit = true,
branch = "main",
branch = "develop",
dirty = true,
worktrees = emptyList(),
dirtyCount = 3,
// The panel's headline case: 9 to push, and a ↓0 that is 19 days old — so NOT green.
sync = SyncState(
upstream = "origin/develop",
ahead = 9,
behind = 0,
lastFetchMs = 1L,
),
worktrees = listOf(
WorktreeInfo(path = "/home/dev/acme-web", branch = "develop", isMain = true, isCurrent = true),
WorktreeInfo(path = "/home/dev/acme-web/.claude/worktrees/x", branch = "worktree-x"),
),
sessions = emptyList(),
hasClaudeMd = true,
claudeMd = "# CLAUDE.md\n\nProject instructions…",
@@ -471,13 +753,21 @@ private fun ProjectDetailScreenPreview() {
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")),
GitLogResult(
commits = listOf(
CommitLogEntry("553a00c1", 1, "docs(progress): log the leftover fixes", unpushed = true),
CommitLogEntry("1dbed541", 1, "feat(control-panel): web admin UI"),
),
truncated = false,
upstream = "origin/develop",
),
),
worktree = null,
onOpenClaude = {},
canFetch = true,
worktreeStates = mapOf(
"/home/dev/acme-web/.claude/worktrees/x" to WorktreeRowState(sync = SyncState(), sessionCount = 1),
),
)
}
}

View File

@@ -43,10 +43,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.components.PointerContextMenu
import wang.yaojia.webterm.components.SessionListRow
import wang.yaojia.webterm.components.SessionThumbnails
import wang.yaojia.webterm.components.sessionRowMenuActions
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.viewmodels.HostOption
import wang.yaojia.webterm.viewmodels.SessionListUiState
import wang.yaojia.webterm.viewmodels.SessionListViewModel
@@ -81,6 +84,12 @@ import java.util.UUID
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
* @param thumbnails off-screen preview seam; production wires it to the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
* @param layoutMode the adaptive layout from [wang.yaojia.webterm.nav.LayoutPolicy]; it gates the
* per-row pointer context menu (A26) via
* [PointerMenuPolicy][wang.yaojia.webterm.nav.PointerMenuPolicy] inside [PointerContextMenu]. The
* [LayoutMode.STACK] default means a caller that does not pass it gets today's phone behaviour.
* @param onNewSessionInCwd pointer-menu「在当前目录开新会话」→ `attach(null, cwd)` in the row's cwd.
* `null` (the default) drops that entry.
*/
@Composable
public fun SessionListScreen(
@@ -92,6 +101,8 @@ public fun SessionListScreen(
onImportCert: () -> Unit,
modifier: Modifier = Modifier,
thumbnails: SessionThumbnails? = null,
layoutMode: LayoutMode = LayoutMode.STACK,
onNewSessionInCwd: ((String) -> Unit)? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
@@ -129,6 +140,8 @@ public fun SessionListScreen(
onNewSession = onNewSession,
onPairHost = onPairHost,
thumbnails = thumbnails,
layoutMode = layoutMode,
onNewSessionInCwd = onNewSessionInCwd,
)
}
}
@@ -208,6 +221,8 @@ private fun SessionListBody(
onNewSession: () -> Unit,
onPairHost: () -> Unit,
thumbnails: SessionThumbnails?,
layoutMode: LayoutMode,
onNewSessionInCwd: ((String) -> Unit)?,
) {
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
isRefreshing = state.isRefreshing,
@@ -223,7 +238,14 @@ private fun SessionListBody(
actionLabel = "开新会话",
onAction = onNewSession,
)
else -> SessionList(state = state, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
else -> SessionList(
state = state,
onOpen = onOpen,
onKill = onKill,
thumbnails = thumbnails,
layoutMode = layoutMode,
onNewSessionInCwd = onNewSessionInCwd,
)
}
}
}
@@ -234,6 +256,8 @@ private fun SessionList(
onOpen: (UUID) -> Unit,
onKill: (UUID) -> Unit,
thumbnails: SessionThumbnails?,
layoutMode: LayoutMode,
onNewSessionInCwd: ((String) -> Unit)?,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -244,7 +268,14 @@ private fun SessionList(
item(key = "load-error") { LoadErrorBanner() }
}
items(items = state.rows, key = { it.id.toString() }) { row ->
SwipeToKillRow(row = row, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
SwipeToKillRow(
row = row,
onOpen = onOpen,
onKill = onKill,
thumbnails = thumbnails,
layoutMode = layoutMode,
onNewSessionInCwd = onNewSessionInCwd,
)
}
}
}
@@ -253,6 +284,11 @@ private fun SessionList(
* One row wrapped in a trailing (end→start) [SwipeToDismissBox]. Swiping left confirms the kill, which the
* ViewModel handles optimistically (the row leaves the list on the next [state] emission). A start→end
* swipe is disabled so a left-handed drag can't accidentally kill.
*
* On a large screen the row is ALSO wrapped in [PointerContextMenu] (A26), giving mouse/trackpad users the
* same three actions without a swipe: 打开会话 / 在当前目录开新会话 / 终止会话. The wrapper is a transparent
* pass-through whenever [PointerMenuPolicy][wang.yaojia.webterm.nav.PointerMenuPolicy] says no, so the
* phone path is byte-for-byte unchanged.
*/
@Composable
private fun SwipeToKillRow(
@@ -260,6 +296,8 @@ private fun SwipeToKillRow(
onOpen: (UUID) -> Unit,
onKill: (UUID) -> Unit,
thumbnails: SessionThumbnails?,
layoutMode: LayoutMode,
onNewSessionInCwd: ((String) -> Unit)?,
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
@@ -276,7 +314,17 @@ private fun SwipeToKillRow(
enableDismissFromStartToEnd = false,
backgroundContent = { KillBackground() },
) {
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
PointerContextMenu(
mode = layoutMode,
actions = sessionRowMenuActions(
row = row,
onOpen = onOpen,
onNewSessionInCwd = onNewSessionInCwd,
onKill = onKill,
),
) {
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
}
}
}

View File

@@ -3,8 +3,9 @@ package wang.yaojia.webterm.screens
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.View
import android.view.WindowManager
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -25,6 +26,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -39,13 +41,24 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import wang.yaojia.webterm.components.AwayDigestView
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.components.DataStoreQuickReplyStore
import wang.yaojia.webterm.components.GateBanner
import wang.yaojia.webterm.components.HardwareKeyRouter
import wang.yaojia.webterm.components.KeyBar
import wang.yaojia.webterm.components.PlanGateSheet
import wang.yaojia.webterm.components.QuickReply
import wang.yaojia.webterm.components.QuickReplyPalette
import wang.yaojia.webterm.components.QuickReplyPaletteEditor
import wang.yaojia.webterm.components.QuickReplyStore
import wang.yaojia.webterm.components.ReconnectBanner
import wang.yaojia.webterm.components.TelemetryChips
import wang.yaojia.webterm.designsystem.LocalReduceMotion
@@ -80,10 +93,11 @@ public object NewSessionInCwd {
*
* Hosts the forked Termux emulator ([RemoteTerminalView]) via an [AndroidView] and wires it to the
* holder-owned [TerminalSessionControllerImpl]. Structure top→bottom: a toolbar (sanitized title +
* "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) + telemetry chips
* ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with the privacy
* cover), the [KeyBar] pinned above the IME, and — for plan gates — the [PlanGateSheet]
* `ModalBottomSheet` overlay. Every server-derived string is rendered INERT by its component (§8).
* 「快捷回复」editor + "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) +
* telemetry chips ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with
* the privacy cover), the [QuickReply] chip row (A25 — floats only while a gate is held), the [KeyBar]
* pinned above the IME, and — for plan gates — the [PlanGateSheet] `ModalBottomSheet` overlay. Every
* server-derived string is rendered INERT by its component (§8).
*
* The Compose / AndroidView / lifecycle / privacy-shade / haptic behaviour here is DEVICE-QA (plan §7);
* the banner reduction, gate stale-guard and new-session-in-cwd routing are the JVM-tested cores.
@@ -106,8 +120,16 @@ public object NewSessionInCwd {
* cycle rebuilds a fresh emulator (ring replay), while a rotation re-binds the survivor.
* `holder.onStop(isChangingConfigurations)` drives that split.
* - `FLAG_SECURE` + a cover Composable on `ON_STOP` keep terminal bytes out of the recents snapshot (§8).
* - Latest-writer-wins resize: `ON_RESUME` and window-focus re-send the remembered dims via
* `notifyForegrounded` so the active device reclaims full-screen (plan §6.4).
*
* ### Latest-writer-wins resize (plan §6.4) — who pushes what
* The grid itself is measured and pushed by `:terminal-view` (`RemoteTerminalHostView.onSizeChanged` →
* `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` → `Resize` on the wire), so this screen does
* NOT compute cols×rows and must not re-assert a size the view just pushed. What the view cannot see is
* "this device became the active one again": `ON_RESUME` and a window-focus GAIN therefore call
* `notifyForegrounded`, which re-sends the engine's remembered dims unconditionally (no dedup) and so wins
* the shared PTY size back from whichever device wrote last. The size-changed outlet is only forwarded
* while the session is NOT connected, where the same call is the connect-now nudge — see
* [TerminalReclaimPolicy] for the decision table (JVM-tested).
*
* @param holder the nav-scoped, config-surviving session holder (`hiltViewModel()` at the destination).
* @param onNewSessionInCwd nav callback that opens a new session for the produced [NewSessionRequest].
@@ -116,6 +138,10 @@ public object NewSessionInCwd {
* the current session (default no-op keeps the screen usable standalone). This only WIRES the existing
* [AwayDigestView.onExpand][wang.yaojia.webterm.components.AwayDigestView] callback outward; no internal
* logic changes.
* @param quickReplyStore the A25 palette store. Defaults to the app-graph one
* ([rememberAppQuickReplyStore] — the SAME `DataStore<Preferences>` singleton every other store uses, so
* the palette really persists); a test/preview passes an
* [InMemoryQuickReplyStore][wang.yaojia.webterm.components.InMemoryQuickReplyStore].
*/
@Composable
public fun TerminalScreen(
@@ -127,6 +153,7 @@ public fun TerminalScreen(
modifier: Modifier = Modifier,
onWarmUp: suspend () -> Unit = {},
onDigestExpand: () -> Unit = {},
quickReplyStore: QuickReplyStore = rememberAppQuickReplyStore(),
) {
val context = LocalContext.current
val activity = remember(context) { context.findActivity() }
@@ -192,13 +219,15 @@ public fun TerminalScreen(
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, activity, controller) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> {
when {
event == Lifecycle.Event.ON_STOP -> {
covered = true
holder.onStop(activity?.isChangingConfigurations == true)
}
Lifecycle.Event.ON_START -> covered = false
Lifecycle.Event.ON_RESUME -> controller.notifyForegrounded(null, null)
event == Lifecycle.Event.ON_START -> covered = false
// §6.4 reclaim: the engine re-sends its remembered dims with NO dedup, so the returning
// device takes the shared PTY size back even though nothing about it changed.
TerminalReclaimPolicy.reclaimsOnLifecycle(event) -> controller.notifyForegrounded(null, null)
else -> Unit
}
}
@@ -210,10 +239,17 @@ public fun TerminalScreen(
val windowInfo = LocalWindowInfo.current
LaunchedEffect(windowInfo, controller) {
snapshotFlow { windowInfo.isWindowFocused }.collect { focused ->
if (focused) controller.notifyForegrounded(null, null)
if (TerminalReclaimPolicy.reclaimsOnWindowFocus(focused)) controller.notifyForegrounded(null, null)
}
}
// A25 quick-reply palette: one remembered presenter over the injected store, loaded once per store.
val palette = remember(quickReplyStore) { QuickReplyPalette(quickReplyStore) }
val quickReplyChips by palette.chips.collectAsStateWithLifecycle()
val paletteScope = rememberCoroutineScope()
var showPaletteEditor by remember { mutableStateOf(false) }
LaunchedEffect(palette) { palette.load() }
val requestNewSession: () -> Unit = { onNewSessionInCwd(NewSessionInCwd.requestFor(cwd)) }
// A slow wall-clock tick so the telemetry chips grey out as the last frame ages (§ staleness).
@@ -225,7 +261,11 @@ public fun TerminalScreen(
}
Column(modifier = modifier.fillMaxSize()) {
TerminalToolbar(title = title, onNewSessionInCwd = requestNewSession)
TerminalToolbar(
title = title,
onNewSessionInCwd = requestNewSession,
onEditQuickReplies = { showPaletteEditor = true },
)
ReconnectBanner(model = banner, onNewSession = requestNewSession)
// Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide
// themselves when their state is null/empty; server strings render inert (§8).
@@ -246,12 +286,23 @@ public fun TerminalScreen(
remoteView.onKeyCommand = { keyCode, keyEvent ->
HardwareKeyRouter.handle(keyCode, keyEvent, controller::sendInput)
}
// A layout/size change feeds the latest-writer-wins reclaim path (§6.4). The pixel→
// grid metric read (Termux's package-private mFontWidth) + emulator resize is
// completed in the view layer on-device; here we reclaim full-screen.
remoteView.onViewSizeChanged = { _, _ -> controller.notifyForegrounded(null, null) }
// The grid for this size has ALREADY been measured and pushed by the view layer
// (TerminalResizeDriver → RemoteTerminalSession.updateSize → `Resize`) by the time
// this fires, so re-asserting it here would only double the frame. Forward it to
// the engine solely while disconnected, where the call is the connect-now nudge
// (§6.4). bannerState is read at CALL time — a value captured in this factory
// lambda would be frozen at first composition.
remoteView.onViewSizeChanged = { _, _ ->
if (TerminalReclaimPolicy.nudgesOnViewSizeChange(holder.bannerState.value)) {
controller.notifyForegrounded(null, null)
}
}
controller.remoteSession.attachView(remoteView)
remoteView.hostView()
// The host view IS the View to compose: :terminal-view now exposes its own
// `RemoteTerminalHostView` (a FrameLayout that owns focus + the IME contract and
// keeps the final stock Termux view away from its session-dereferencing paths), so
// no reflection is needed to avoid naming an `implementation`-scoped Termux type.
remoteView.terminalView
},
// Config change / real background: unbind the view but the holder-owned emulator
// survives (FIX 3) — never close it here (that is the holder teardown's job).
@@ -260,10 +311,31 @@ public fun TerminalScreen(
}
if (covered) PrivacyCover()
}
// A25: the quick-reply chips float directly above the key-bar while a gate is held — the moment
// Claude is waiting is exactly when a one-tap canned reply is useful. Each tap sends the chip's
// text VERBATIM through the same ordered send pump as typing (invariant #1/#9).
QuickReply(
chips = quickReplyChips,
gateHeld = gateUi.gate != null,
onSend = controller::sendInput,
)
// The IME-bypass touch key-bar, pinned above the keyboard (hidden on wide screens / hardware kbd).
KeyBar(onSend = controller::sendInput)
}
// The editable palette (A25). Every mutation goes through the store (the CRUD authority) and the
// presenter republishes what came back, so the chips above can never drift from what was persisted.
if (showPaletteEditor) {
QuickReplyPaletteEditor(
chips = quickReplyChips,
onAdd = { text -> paletteScope.launch { palette.add(text) } },
onEdit = { id, text -> paletteScope.launch { palette.edit(id, text) } },
onRemove = { id -> paletteScope.launch { palette.remove(id) } },
onMove = { from, to -> paletteScope.launch { palette.move(from, to) } },
onDismiss = { showPaletteEditor = false },
)
}
// Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the
// gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in
// GateViewModel.decide.
@@ -275,9 +347,18 @@ public fun TerminalScreen(
/** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */
private const val TELEMETRY_TICK_MS: Long = 1_000L
/** Toolbar: the inert (already-sanitized) session title + the new-session-in-cwd action. */
/**
* Toolbar: the inert (already-sanitized) session title, the quick-reply palette editor and the
* new-session-in-cwd action. The editor lives HERE rather than on the chip row because the row only
* floats while a gate is held (and hides itself when the palette is empty) — an editor reachable only
* from the row could never be used to create the first chip.
*/
@Composable
private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) {
private fun TerminalToolbar(
title: String,
onNewSessionInCwd: () -> Unit,
onEditQuickReplies: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -292,6 +373,7 @@ private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) {
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onEditQuickReplies) { Text("快捷回复") }
TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") }
}
}
@@ -340,17 +422,65 @@ private tailrec fun Context.findActivity(): Activity? = when (this) {
}
/**
* The [android.view.View] to add to the Compose tree — the Termux `TerminalView` behind
* [RemoteTerminalView].
* WHICH app-side events must call `SessionEngine.notifyForegrounded` for the §6.4 latest-writer-wins
* reclaim — the pure, JVM-tested half of the device-switch resize path.
*
* BOUNDARY WORKAROUND (recorded): A16's `RemoteTerminalView.terminalView` is typed
* `com.termux.view.TerminalView`, but `:terminal-view` scopes the Termux `terminal-view` artifact as
* `implementation`, so that concrete type is NOT on `:app`'s compile classpath (and neither
* `app/build.gradle.kts` nor `:terminal-view`/`RemoteTerminalView` is in A21's editable lane). We
* therefore read the already-attached stock view through its public getter as a plain [View] — a
* `TerminalView` IS a `View`, so hosting it in an `AndroidView` is correct; only the compile-time type
* name is avoided. The clean fix (out of A21's lane) is to `api`-expose the Termux view from
* `:terminal-view` or add `libs.termux.terminal.view` to `:app`, then `AndroidView { remote.terminalView }`.
* A shared PTY has exactly ONE size, so the device you are actively using has to re-assert its dims to
* take that size back from whichever device wrote last. Two things make this subtler than "resend on every
* event":
* - **The view layer already owns the grid.** `RemoteTerminalHostView.onSizeChanged` →
* `TerminalResizeDriver` → `RemoteTerminalSession.updateSize` measures and pushes the real cols×rows,
* deduping sub-cell layout churn so a rotation costs exactly one `Resize` frame. Calling the engine on
* the SAME size-changed event would double every one of those frames (and re-SIGWINCH the shell).
* - **The engine call has no dedup.** `notifyForegrounded` re-sends the remembered dims unconditionally —
* which is precisely what the reclaim needs, and precisely why it must not be attached to layout churn.
*
* So the engine call is reserved for what the view cannot observe (becoming the active device again:
* `ON_RESUME`, a window-focus GAIN) plus the disconnected case, where the same call is the
* connect-now-without-resetting-the-ladder nudge.
*/
private fun RemoteTerminalView.hostView(): View =
RemoteTerminalView::class.java.getMethod("getTerminalView").invoke(this) as View
internal object TerminalReclaimPolicy {
/** `ON_RESUME` (pane-show / device-switch) is the only lifecycle event that reclaims. */
fun reclaimsOnLifecycle(event: Lifecycle.Event): Boolean = event == Lifecycle.Event.ON_RESUME
/** Focus GAIN reclaims; a blur must never resize (the v0.4 min→latest-writer pivot removed that). */
fun reclaimsOnWindowFocus(focused: Boolean): Boolean = focused
/**
* A view size change reaches the engine only while a connect is pending/retrying — there the call is
* the connect-now nudge. Connected ([BannerModel.Hidden]) the view driver has already pushed this
* exact grid; terminally failed/exited there is no reconnect owed.
*/
fun nudgesOnViewSizeChange(banner: BannerModel): Boolean =
banner is BannerModel.Connecting || banner is BannerModel.Reconnecting
}
/**
* The app-graph quick-reply store: a [DataStoreQuickReplyStore] over the SAME
* `DataStore<Preferences>` singleton the host/last-session stores use (disjoint keys, one file — two
* `DataStore` instances over one file would throw).
*
* `TerminalScreen` is a Composable, so it cannot take the store by constructor injection; the store is
* pulled off the Hilt `SingletonComponent` through an `@EntryPoint` instead (the standard Hilt escape
* hatch for exactly this case) and kept as a default parameter value so a test/preview can still pass an
* in-memory store. Nothing here widens the DI graph — the provider already exists in `di/StorageModule`.
*/
@Composable
internal fun rememberAppQuickReplyStore(): QuickReplyStore {
val application = LocalContext.current.applicationContext
return remember(application) {
DataStoreQuickReplyStore(
EntryPointAccessors
.fromApplication(application, QuickReplyStoreEntryPoint::class.java)
.preferencesDataStore(),
)
}
}
/** Reads the app-scoped Preferences `DataStore` provider for [rememberAppQuickReplyStore]. */
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface QuickReplyStoreEntryPoint {
public fun preferencesDataStore(): DataStore<Preferences>
}

View File

@@ -17,6 +17,7 @@ import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.session.Telemetry
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.StatusTelemetry
@@ -106,13 +107,15 @@ public class GateViewModel(
is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
is Digest -> onDigest(event.digest)
// The session ended: a held gate is no longer decidable — clear it so no stale card lingers.
is Exited -> _uiState.value = _uiState.value.copy(gate = null)
is Exited -> _uiState.value = _uiState.value.copy(gate = null, preview = null)
else -> Unit // Output / Connection / Adopted are not cockpit concerns.
}
}
private fun onGate(gate: GateState?) {
_uiState.value = _uiState.value.copy(gate = gate)
// The preview is bound to the gate it describes: it arrives with it and is cleared with it, so
// a resolved gate's command/diff can never sit under the NEXT gate's buttons.
_uiState.value = _uiState.value.copy(gate = gate, preview = gate?.preview)
if (gate == null) return // Falling edge: gate lifted; keep lastArrivalEpoch so it can't refire.
// Rising edge = a NEW epoch (epochs are monotonic; a refresh keeps the same epoch).
if (gate.epoch != lastArrivalEpoch) {
@@ -152,10 +155,29 @@ public enum class GateDecision {
REJECT,
}
/**
* W1 — strip everything that could make untrusted preview text behave like anything other than text:
* every C0 control char except `\n`/`\t`, DEL, and the C1 range. ESC in particular must never reach a
* renderer — and `\n` must survive, because a shell command's line structure IS the content.
*
* The server already sanitizes (`src/http/approval-preview.ts`); this is the client's defence in
* depth, because the gate surfaces are the one place untrusted bytes sit under an approve button.
*/
public fun inertPreviewText(raw: String): String =
raw.filterNot { ch ->
val code = ch.code
(code < 0x20 && ch != '\n' && ch != '\t') || code == 0x7F || (code in 0x80..0x9F)
}
/** The sanitized command text of a [ApprovalPreview.Command], or null when nothing legible remains. */
public fun ApprovalPreview.Command.inertText(): String? = inertPreviewText(text).takeIf { it.isNotBlank() }
/** The gate/cockpit snapshot rendered by the surfaces. All fields null = nothing to show. */
public data class GateUiState(
/** The held permission gate, or `null` when none (falling edge / never risen). */
val gate: GateState? = null,
/** What the held gate would run, or `null` when the server sent no preview (a NORMAL state). */
val preview: ApprovalPreview? = null,
/** Latest statusLine telemetry, or `null` before the first `telemetry` frame. */
val telemetry: StatusTelemetry? = null,
/** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */

View File

@@ -8,14 +8,11 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport
import java.util.UUID
/**
@@ -35,13 +32,23 @@ import java.util.UUID
* 3. **Public host needs an explicit acknowledge.** A [WarningTier.PUBLIC] host cannot be probed until
* the user ticks the risk acknowledge ([confirm]`(acknowledgedPublicRisk = true)`); otherwise the
* confirm is a no-op that re-arms the reminder.
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** Both [confirm] and [retry]
* funnel through the ONE private [runProbe]; for a [WarningTier.TUNNEL] host with no installed
* device cert, [runProbe] refuses BEFORE any network I/O and re-refuses on every retry (plan §8
* "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed").
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** [confirm], [retry] and the
* post-token resume all funnel through the ONE private `performProbe`; for a [WarningTier.TUNNEL]
* host with no installed device cert it refuses BEFORE any network I/O and re-refuses on every
* retry (plan §8 "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is
* installed").
*
* On probe success the validated host is persisted via [HostStore.upsert].
*
* ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`)
* A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the
* frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so
* without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous
* failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to
* [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared
* cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point.
* The token is a SHELL credential: it is a parameter, never a field and never part of any UI state.
*
* @param hostStore where a successfully-paired [Host] is saved.
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
@@ -126,38 +133,102 @@ public class PairingViewModel(
runProbe(candidate, acknowledgedPublicRisk)
}
/**
* Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from
* [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a
* credential to a host the user has moved on from.
*
* [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no
* log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same
* choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome
* re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included).
*/
public fun submitAccessToken(token: String) {
val state = _uiState.value
if (state !is PairingUiState.TokenRequired) return
if (token.isBlank()) return
val candidate = Candidate(state.endpoint, state.name, state.tier)
val scope = scope ?: return
probeJob?.cancel()
probeJob = scope.launch {
_uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier)
when (prober.submitAccessToken(candidate.endpoint, token)) {
// Straight into the probe body: RULE 3 is already satisfied by construction (reaching the
// prompt required a probe, which for a public host required the explicit ack — and an ack
// cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies.
TokenSubmission.Accepted -> performProbe(candidate)
TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID)
TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED)
is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE)
TokenSubmission.Unavailable -> promptForToken(candidate, TokenError.UNAVAILABLE)
}
}
}
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
val tier = candidate.tier
// RULE 3 — public host: no probe until the risk is explicitly acknowledged.
if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
// RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the
// pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe.
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
_uiState.value = PairingUiState.Confirming(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
tier = candidate.tier,
awaitingAck = true,
)
return
}
val scope = scope ?: return
probeJob?.cancel()
probeJob = scope.launch {
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard.
if (tier.isCertGated && !hasDeviceCert()) {
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
return@launch
}
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
when (val result = prober.probe(candidate.endpoint)) {
is PairingProbeResult.Success -> onProbeSuccess(candidate)
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
error = result.error,
message = PairingCopy.describe(result.error, tier),
)
}
probeJob = scope.launch { performProbe(candidate) }
}
/**
* The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access
* token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a
* pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns
* [probeJob], which would cancel the coroutine it is called from).
*/
private suspend fun performProbe(candidate: Candidate) {
val tier = candidate.tier
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
// re-hit this same guard.
if (tier.isCertGated && !hasDeviceCert()) {
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
return
}
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
when (val result = prober.probe(candidate.endpoint)) {
is PairingProbeResult.Success -> onProbeSuccess(candidate)
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
}
}
/**
* A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a
* `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token
* gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim.
*/
private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) {
if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) {
promptForToken(candidate, error = null)
return
}
_uiState.value = PairingUiState.Failed(
endpoint = candidate.endpoint,
name = candidate.name,
tier = candidate.tier,
error = error,
message = PairingCopy.describe(error, candidate.tier),
)
}
private fun promptForToken(candidate: Candidate, error: TokenError?) {
_uiState.value = PairingUiState.TokenRequired(
endpoint = candidate.endpoint,
name = candidate.name,
tier = candidate.tier,
error = error,
)
}
private suspend fun onProbeSuccess(candidate: Candidate) {
@@ -181,20 +252,80 @@ public class PairingViewModel(
HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl }
}
/** The two-step pairing probe seam ([wang.yaojia.webterm.api.pairing.runPairingProbe]); faked in tests. */
public fun interface PairingProber {
/**
* The pairing NETWORK seam: the two-step probe ([wang.yaojia.webterm.api.pairing.runPairingProbe]) plus
* the `WEBTERM_TOKEN` gate's two operations. Faked in tests; production is built by
* `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the
* submit obtains is the same jar every later request and the WS upgrade read from).
*
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their
* defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and
* [TokenSubmission.Unavailable] — never a silent success.
*/
public interface PairingProber {
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
/**
* Does [endpoint] answer an unauthenticated read with the server's `401 authentication required`
* (`src/server.ts` `authGate`)? Only consulted to disambiguate
* [PairingError.HttpOkButNotWebTerminal].
*
* Implementations MUST NOT throw for a network failure — an unreachable host is `false` ("not known
* to be gated"), so the app never prompts for a credential because a host is merely down.
*/
public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = false
/**
* `POST /auth` with [token]. On [TokenSubmission.Accepted] the shared cookie jar holds the host's
* `webterm_auth` cookie, so the very next probe/attach is authenticated.
*
* [token] is a SHELL CREDENTIAL: implementations must put it in the request BODY only — never a URL,
* never a log, never an exception message — and must not retain it.
*/
public suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission =
TokenSubmission.Unavailable
}
/**
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
* I/O on a UI thread.
* Outcome of `POST /auth` (server `src/http/auth.ts` + the route in `src/server.ts`). A wrong token
* ([Rejected]) and a host that did not answer ([Unreachable]) are DISTINCT cases on purpose — collapsing
* them would tell the user to re-type a token that was fine.
*/
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
public sealed interface TokenSubmission {
/** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */
public data object Accepted : TokenSubmission
/** `401` — the token does not match the host's `WEBTERM_TOKEN`. */
public data object Rejected : TokenSubmission
/** `429` — over the host's 10-per-minute auth limit. Surfaced as-is; NEVER auto-retried. */
public data object RateLimited : TokenSubmission
/**
* The submit never got an answer it could interpret: a transport failure or an unexpected status.
* [reason] is a short, credential-free diagnostic (an error TYPE or `"HTTP <status>"`) — it is
* shown to nobody and logged nowhere; the UI copy is app-authored.
*/
public data class Unreachable(val reason: String) : TokenSubmission
/** This prober has no way to submit a token (a probe-only seam). Reported, never silently ignored. */
public data object Unavailable : TokenSubmission
}
/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */
public enum class TokenError {
/** The submitted token was rejected by the host (`401`). */
INVALID,
/** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */
RATE_LIMITED,
/** The host did not answer the submit (or answered unintelligibly). */
UNREACHABLE,
/** The app cannot submit a token at all on this seam. */
UNAVAILABLE,
}
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
@@ -276,6 +407,27 @@ public sealed interface PairingUiState {
*/
public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState
/**
* The host is behind the optional `WEBTERM_TOKEN` gate and needs its shared access token before
* pairing can continue. [error] is null on the first prompt and set after a failed submit.
*
* This state deliberately carries NO token field — the credential is a
* [PairingViewModel.submitAccessToken] parameter and must not survive the call (plan §8).
*/
public data class TokenRequired(
val endpoint: HostEndpoint,
val name: String,
val tier: WarningTier,
val error: TokenError? = null,
) : PairingUiState
/** A submitted access token is in flight (`POST /auth`). Carries no token, for the same reason. */
public data class TokenSubmitting(
val endpoint: HostEndpoint,
val name: String,
val tier: WarningTier,
) : PairingUiState
/** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */
public data class Failed(
val endpoint: HostEndpoint,
@@ -302,6 +454,8 @@ private fun PairingUiState.candidate(): Candidate? = when (this) {
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
is PairingUiState.CertRequired -> Candidate(endpoint, name, tier)
is PairingUiState.TokenRequired -> Candidate(endpoint, name, tier)
is PairingUiState.TokenSubmitting -> Candidate(endpoint, name, tier)
is PairingUiState.Failed -> Candidate(endpoint, name, tier)
is PairingUiState.Entry, is PairingUiState.Paired -> null
}
@@ -315,11 +469,21 @@ internal object PairingCopy {
"这个端口在运行别的服务,不是 web-terminal。端口对吗"
is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived
is PairingError.CleartextBlocked ->
// Inverted by the A19 cleartext fix: LAN cleartext is now PERMITTED (the platform has no
// CIDR syntax, so it could not be scoped to RFC1918 — see network_security_config.xml).
// The only host that can still produce this error is the tunnel domain, which
// network_security_config pins to TLS because it always serves a real LE cert.
"${error.host} 必须使用 https / wss —— 该主机由设备证书保护,不允许降级为明文。"
// Two distinct refusals collapse into this one error case (both surface as
// UnknownServiceException, which is what `PairingError.classify` keys on):
// 1. the PLATFORM's network_security_config, which pins the tunnel apex to TLS because it
// always serves a real LE cert (LAN cleartext itself is permitted — the config format has
// no CIDR syntax, so it could not be scoped to RFC1918);
// 2. the app's OWN transport-level cleartext guard (:transport-okhttp
// CleartextNotPermittedException), which is where the CIDR scoping actually happens: a
// plaintext dial to a PUBLIC (or unclassifiable) host is refused before it leaves the
// device. The tier tells the two apart, so each gets copy the user can act on.
if (tier == WarningTier.TUNNEL) {
"${error.host} 必须使用 https / wss —— 该主机由设备证书保护,不允许降级为明文。"
} else {
"应用拒绝以明文连接 ${error.host}http:// 与 ws:// 只允许用于本机、局域网和 Tailscale 地址。" +
"公网主机请改用 https:// / wss://(隧道或 Tailscale"
}
PairingError.TlsFailure ->
// Tunnel hosts present a real LE server cert, so a TLS failure there means OUR client cert
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
@@ -328,4 +492,19 @@ internal object PairingCopy {
PairingError.Timeout ->
"连接超时。主机可能不可达,或响应过慢。"
}
/**
* Copy for the access-token prompt. A wrong token and an unreachable host read differently on
* purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself.
*/
fun describeToken(error: TokenError): String = when (error) {
TokenError.INVALID ->
"访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。"
TokenError.RATE_LIMITED ->
"尝试次数过多,主机已暂时限流(每分钟最多 10 次。请等待约一分钟再试——App 不会自动重试。"
TokenError.UNREACHABLE ->
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
TokenError.UNAVAILABLE ->
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
}
}

View File

@@ -4,9 +4,15 @@ 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.CommitLogEntry
import wang.yaojia.webterm.api.models.FetchResult
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.SyncState
import wang.yaojia.webterm.api.models.WorktreeInfo
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.routes.ApiClientError
/**
@@ -30,6 +36,16 @@ public class ProjectDetailViewModel(
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,
/**
* w6/G2 — `POST /projects/git/fetch` for THIS repo. `null` ⇒ the band hides the Fetch action
* rather than offering a button that cannot work.
*/
private val fetchRemote: (suspend () -> GitWriteOutcome<FetchResult>)? = null,
/**
* w6/G7 — `GET /projects/worktree/state?path=` for ONE worktree. `null` ⇒ rows stay unprobed and
* show no sync chip, which is the honest rendering of "not known".
*/
private val fetchWorktreeState: (suspend (String) -> WorktreeState)? = null,
) {
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
@@ -57,9 +73,23 @@ public class ProjectDetailViewModel(
public data object Unavailable : RecentCommits
}
/** w6/G2 — the Fetch button's own state. A failure NEVER touches [phase] (failure isolation). */
public sealed interface FetchPhase {
public data object Idle : FetchPhase
public data object Working : FetchPhase
/** Succeeded; [lastFetchMs] is the server's post-fetch `FETCH_HEAD` mtime (may be null). */
public data class Done(val lastFetchMs: Long?) : FetchPhase
/** Failed (offline / auth / ≥2 remotes). `lastFetchMs` is left untouched so `stale` stays on. */
public data class Failed(val message: String) : FetchPhase
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
private val _fetchPhase = MutableStateFlow<FetchPhase>(FetchPhase.Idle)
private val _worktreeStates = MutableStateFlow<Map<String, WorktreeRowState>>(emptyMap())
/** The main detail snapshot `ProjectDetailScreen` renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
@@ -70,6 +100,15 @@ public class ProjectDetailViewModel(
/** The recent-commits snapshot. */
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
/** The Fetch action's snapshot (spinner / result banner). */
public val fetchPhase: StateFlow<FetchPhase> = _fetchPhase.asStateFlow()
/** Per-worktree git state, keyed by worktree path. Absent ⇒ unprobed (renders no sync chip). */
public val worktreeStates: StateFlow<Map<String, WorktreeRowState>> = _worktreeStates.asStateFlow()
/** Whether a Fetch seam is wired at all; a detached HEAD is refused by [SyncBand.canFetch]. */
public val canFetch: Boolean get() = fetchRemote != null
/**
* 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
@@ -93,6 +132,72 @@ public class ProjectDetailViewModel(
}
}
/**
* w6/G2 — refresh the remote-tracking refs for this repo (`git fetch --no-tags`: no pull, no
* merge, no working-tree change). Rules, all pinned by test:
* - **one at a time** — a tap while [FetchPhase.Working] is DROPPED, never queued;
* - **success re-loads the detail**, because `behind`/`lastFetchMs` only move server-side;
* - **failure changes nothing** — no re-load, no invented `lastFetchMs`, so the band's `stale`
* flag stays on. Marking stale data fresh is the one lie this panel exists to prevent.
*/
public suspend fun fetchUpstream() {
val fetcher = fetchRemote ?: return
if (_fetchPhase.value == FetchPhase.Working) return
_fetchPhase.value = FetchPhase.Working
val outcome = try {
fetcher()
} catch (cancel: CancellationException) {
_fetchPhase.value = FetchPhase.Idle
throw cancel
} catch (_: Throwable) {
_fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_FAILED)
return
}
when (outcome) {
is GitWriteOutcome.Ok -> {
_fetchPhase.value = FetchPhase.Done(outcome.payload.lastFetchMs)
load() // the numbers this button exists for live server-side — re-read them.
}
// The server classifies and sanitizes its own failures (SEC-M10), so its message is shown
// verbatim; a missing one falls back to local copy. Nothing else changes: `lastFetchMs`
// stays where it was, so the band keeps reading "stale" instead of pretending it refreshed.
is GitWriteOutcome.Rejected ->
_fetchPhase.value = FetchPhase.Failed(outcome.message ?: GitPanelCopy.FETCH_FAILED)
GitWriteOutcome.RateLimited ->
_fetchPhase.value = FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED)
}
}
/**
* w6/G7 — probe the git state of every worktree OTHER than the current one (the current row reads
* the project's own `sync`/`dirtyCount`, already loaded and free).
*
* Each probe is isolated to its own row: a failure leaves that row unprobed (no chip) and never
* touches [phase] or another row. Probed once per opened project, never per refresh tick.
*/
public suspend fun probeWorktrees() {
val probe = fetchWorktreeState ?: return
val detail = (_phase.value as? Phase.Loaded)?.detail ?: return
for (worktree in detail.worktrees) {
if (worktree.isCurrent) continue
val state = try {
probe(worktree.path)
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
continue // isolated: this row stays unprobed, the panel still renders
}
_worktreeStates.value = _worktreeStates.value + (
worktree.path to WorktreeRowState(sync = state.sync, dirtyCount = state.dirtyCount)
)
}
}
/** Dismiss the fetch result banner. */
public fun clearFetchBanner() {
_fetchPhase.value = FetchPhase.Idle
}
private suspend fun loadPr() {
val fetcher = fetchPr ?: return
_prChip.value = PrChip.Loading
@@ -137,9 +242,218 @@ public class ProjectDetailViewModel(
fetchPr = { gateway.projectPr(path) },
fetchLog = { gateway.projectLog(path, null) },
worktree = worktree,
fetchRemote = { gateway.gitFetch(path) },
fetchWorktreeState = { gateway.worktreeState(it) },
)
self = vm
return vm
}
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// w6 — the git panel's pure presentation core (port of public/projects.ts `makeSyncBand` /
// `makeWorktreeRow` + public/git-log.ts `renderGitLog`; design: docs/mockups/project-detail-git.html).
// Pure and JVM-tested; the Compose layer only paints what these return.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** [GitLogResult.boundaryIndex] when there is no pushed/unpushed boundary to draw. */
public const val NO_COMMIT_BOUNDARY: Int = -1
/**
* The header sync band, as one of three mutually exclusive shapes.
*
* **The rule the type exists to enforce: exactly ONE shape may ever answer [isAllClear] true** —
* [Tracking] with `ahead == 0 && behind == 0` and a fetch inside [SyncState.FETCH_FRESH_WINDOW_MS].
* Green here means
* "I checked, ignore this", so [Detached] and [NoUpstream] cannot reach it by construction: absent
* numbers are not zeros.
*/
public sealed interface SyncBand {
/** True for the single green "all clear" state and nothing else. */
public val isAllClear: Boolean
/** `git fetch` is meaningless without a branch, so a detached HEAD disables the button. */
public val canFetch: Boolean get() = this !is Detached
/** HEAD is detached: show the short sha, no branch, no ↑↓. */
public data class Detached(val head: String?) : SyncBand {
override val isAllClear: Boolean get() = false
}
/** The branch tracks nothing — stated explicitly, in words as well as colour. */
public data object NoUpstream : SyncBand {
override val isAllClear: Boolean get() = false
}
/** Tracking [upstream]: ↑[ahead] is fact, ↓[behind] is only as fresh as [lastFetchMs]. */
public data class Tracking(
val upstream: String,
val ahead: Int,
val behind: Int,
/** `FETCH_HEAD` older than [SyncState.FETCH_FRESH_WINDOW_MS] (or never) ⇒ [behind] is a guess. */
val isStale: Boolean,
val lastFetchMs: Long?,
) : SyncBand {
override val isAllClear: Boolean get() = ahead == 0 && behind == 0 && !isStale
}
}
/**
* Derive the header band. `null` facts (a non-git project) ⇒ `null` — say nothing rather than guess.
* [head] is the short HEAD sha shown on a detached HEAD.
*/
public fun syncBandOf(
sync: SyncState?,
head: String? = null,
nowMs: Long = System.currentTimeMillis(),
): SyncBand? {
if (sync == null) return null
if (sync.detached) return SyncBand.Detached(head)
// A blank upstream is treated as none: the server should never send one, and "" would otherwise
// label the commit boundary with an empty string and unlock the green path.
val upstream = sync.upstream?.trim()?.takeIf { it.isNotEmpty() } ?: return SyncBand.NoUpstream
return SyncBand.Tracking(
upstream = upstream,
ahead = sync.ahead ?: 0,
behind = sync.behind ?: 0,
isStale = !sync.isFetchFresh(nowMs),
lastFetchMs = sync.lastFetchMs,
)
}
/**
* The header's uncommitted-changes chip, or null when clean/unknown. A count is strictly better than
* a bare dot (`● 3 未提交` answers "how much?"), so it wins whenever the server sent one; `dirty`
* alone falls back to the countless chip (mirror of public/projects.ts:1254-1258).
*/
public fun headerDirtyChip(dirtyCount: Int?, dirty: Boolean?): String? = when {
dirtyCount != null && dirtyCount > 0 -> GitPanelCopy.dirtyChip(dirtyCount)
dirtyCount == null && dirty == true -> GitPanelCopy.DIRTY_NO_COUNT
else -> null
}
// ── Commit list: unpushed marking + ONE upstream boundary ────────────────────────────
/**
* The label for the pushed/unpushed boundary, or null when NO boundary may be drawn.
*
* A blank upstream counts as absent: an empty label would be a boundary claiming a position on the
* timeline without naming what it is the position OF.
*/
public fun GitLogResult.boundaryLabel(): String? = upstream?.trim()?.takeIf { it.isNotEmpty() }
/**
* The row index the boundary is drawn AFTER — exactly once — or [NO_COMMIT_BOUNDARY].
*
* It is the LAST marked row, not "the first N rows": `git log` is date-ordered and a merge of an older
* branch interleaves unpushed commits BELOW pushed ones, so a row-count rule fails in the dangerous
* direction (calling an unpushed commit pushed). Marking itself is a SERVER decision
* ([GitLogResult.upstreamBoundaryIndex] reads `unpushed`, honoured only when strictly `true`) because
* `git log`'s `%h` is abbreviated while `rev-list` yields full SHAs.
*/
public fun GitLogResult.boundaryIndex(): Int =
if (boundaryLabel() == null) NO_COMMIT_BOUNDARY else upstreamBoundaryIndex ?: NO_COMMIT_BOUNDARY
/** Whether this row draws the "not pushed yet" rail: only an explicit `true` counts. */
public fun CommitLogEntry.isUnpushed(): Boolean = unpushed == true
// ── Worktree rows ────────────────────────────────────────────────────────────────────────────────
/** Chip colour role. [OK] is the green all-clear and is reserved for the header band's in-sync chip. */
public enum class GitChipTone { NEUTRAL, ACCENT, WARN, DIRTY, OK }
/** One inert chip on a worktree row. */
public data class WorktreeChip(val label: String, val tone: GitChipTone)
/**
* Per-row git state (w6/G7 — probed per worktree). Absent means UNPROBED, and an unprobed row shows
* no chip at all rather than a guess.
*/
public data class WorktreeRowState(
val sync: SyncState? = null,
val dirtyCount: Int? = null,
/** Live sessions whose cwd sits inside this worktree. */
val sessionCount: Int = 0,
)
/**
* The chips for one worktree row, in the shipped order: main · current · locked · prunable · sync ·
* dirty (mirror of public/projects.ts `makeWorktreeRow`).
*
* Deliberately NO green chip here: a worktree row cannot earn the all-clear, because the only state
* that may read green needs a fresh fetch, and "no upstream" — the normal state of a fresh worktree
* branch — must read as a warning instead.
*/
public fun worktreeChips(worktree: WorktreeInfo, state: WorktreeRowState? = null): List<WorktreeChip> {
val chips = ArrayList<WorktreeChip>(6)
if (worktree.isMain) chips += WorktreeChip(GitPanelCopy.MAIN_CHIP, GitChipTone.NEUTRAL)
if (worktree.isCurrent) chips += WorktreeChip(GitPanelCopy.CURRENT_CHIP, GitChipTone.ACCENT)
if (worktree.locked == true) chips += WorktreeChip(GitPanelCopy.LOCKED_CHIP, GitChipTone.NEUTRAL)
if (worktree.prunable == true) chips += WorktreeChip(GitPanelCopy.PRUNABLE_CHIP, GitChipTone.WARN)
val sync = state?.sync
if (sync != null) {
val ahead = sync.ahead ?: 0
when {
sync.detached -> chips += WorktreeChip(GitPanelCopy.DETACHED_CHIP_SHORT, GitChipTone.WARN)
sync.upstream.isNullOrBlank() ->
chips += WorktreeChip(GitPanelCopy.NO_UPSTREAM_CHIP, GitChipTone.WARN)
ahead > 0 -> chips += WorktreeChip(GitPanelCopy.aheadChip(ahead), GitChipTone.ACCENT)
}
}
val dirtyCount = state?.dirtyCount ?: 0
if (dirtyCount > 0) chips += WorktreeChip(GitPanelCopy.dirtyCountChip(dirtyCount), GitChipTone.DIRTY)
return chips
}
/** User-visible copy for the git panel. Local UI text — no wire contract depends on these strings. */
public object GitPanelCopy {
// Sync band
public const val UPSTREAM_CAPTION: String = "Upstream"
public const val TO_PUSH_CAPTION: String = "待推送"
public const val TO_PULL_CAPTION: String = "待拉取"
public const val HEAD_CAPTION: String = "HEAD"
public const val NO_UPSTREAM_CHIP: String = "无 upstream"
public const val NO_UPSTREAM_NOTE: String = "该分支没有跟踪上游 —— 没有可报告的 ↑↓"
public const val DETACHED_CHIP: String = "detached HEAD"
public const val DETACHED_CHIP_SHORT: String = "detached"
public const val IN_SYNC_CHIP: String = "✓ 已同步"
public const val STALE_CHIP: String = "存疑"
public const val NEVER_FETCHED_NOTE: String = "从未 fetch —— 这个数字不可信"
public const val STALE_NOTE: String = "上次 fetch 已超过一小时 —— 这个数字不可信"
public const val UP_TO_DATE_NOTE: String = "与远端一致"
public const val WAITING_ON_REMOTE_NOTE: String = "远端有新提交"
public const val NOTHING_TO_PUSH_NOTE: String = "没有待推送的提交"
public const val DIRTY_NO_COUNT: String = "● 未提交"
// Fetch
public const val FETCH: String = "Fetch"
public const val FETCH_WORKING: String = "正在 fetch…"
public const val FETCH_DONE: String = "已刷新远端引用(未 pull、未 merge"
public const val FETCH_FAILED: String = "fetch 失败,远端状态仍是上次的数据。"
public const val FETCH_RATE_LIMITED: String = "fetch 太频繁,请稍后再试。"
public const val FETCH_DISABLED_DETACHED: String = "HEAD 游离,无法 fetch"
// Commit list
public const val RECENT_COMMITS: String = "最近提交"
public const val UNPUSHED_MARK: String = ""
// Worktrees
public const val MAIN_CHIP: String = "main"
public const val CURRENT_CHIP: String = "当前"
public const val LOCKED_CHIP: String = "locked"
public const val PRUNABLE_CHIP: String = "prunable"
/** ALWAYS "Worktrees (n)" — one worktree per session means the count is the point, and a section
* that renames itself at n=1 hides the whole model (w6/G5). */
public fun worktreesTitle(count: Int): String = "Worktrees ($count)"
public fun dirtyChip(count: Int): String = "$count 未提交"
public fun dirtyCountChip(count: Int): String = "$count"
public fun aheadChip(count: Int): String = "$count"
public fun behindChip(count: Int): String = "$count"
public fun unpushedBadge(count: Int): String = "$count 未推送"
public fun aheadNote(count: Int): String = "$count 个提交只在本机"
public fun sessionCount(count: Int): String = "$count session"
public fun truncatedNote(count: Int): String = "只显示最近 $count 条提交。"
}

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
@@ -14,6 +15,7 @@ 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.WorktreeState
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.wire.Validation
@@ -418,6 +420,18 @@ public interface ProjectsGateway {
public suspend fun projectPr(path: String): PrStatus
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
/**
* w6/G7 — one worktree's git state. Its own call (not part of `projectDetail`) so N rows do not
* each pay for a worktree listing and a CLAUDE.md read.
*/
public suspend fun worktreeState(worktreePath: String): WorktreeState
/**
* w6/G2 — refresh remote-tracking refs (no pull, no merge). GUARDED like the other git writes:
* the server derives the remote itself and never accepts one from the client.
*/
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult>
// ── 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>
@@ -432,6 +446,8 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
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 worktreeState(worktreePath: String): WorktreeState = api.worktreeState(worktreePath)
override suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> = api.gitFetch(path)
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
api.createWorktree(path, branch, base)

View File

@@ -1,15 +1,27 @@
package wang.yaojia.webterm.wiring
import dagger.Lazy
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
import wang.yaojia.webterm.hostregistry.AuthCookieStore
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.transport.AuthCookieJar
import wang.yaojia.webterm.transport.AuthCookieSnapshot
import wang.yaojia.webterm.viewmodels.PairingProber
import wang.yaojia.webterm.viewmodels.TokenSubmission
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport
import java.net.URI
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
@@ -28,10 +40,11 @@ import javax.inject.Singleton
* Engine state is never touched off-confinement and emulator/scrollback state never off-`Main`.
*
* ### Off-`Main` warm-up (FIX 3)
* The mTLS transports and [identityRepository] are behind `dagger.Lazy` so constructing this root does
* NO AndroidKeyStore/Tink/`OkHttpClient` I/O on the injection (often `Main`) thread. [warmUp] resolves
* them on `Dispatchers.IO`, so the shared client is built and the device identity first-touched off the
* UI thread. A21/A27 call [warmUp] before the first `bind()` / cert-screen open.
* The mTLS transports, [identityRepository], the auth-cookie store and the thumbnail rasterizer are all
* behind `dagger.Lazy` so constructing this root does NO AndroidKeyStore/Tink/`OkHttpClient`/DataStore/
* `android.graphics` work on the injection (often `Main`) thread — `MainActivity` field-injects it in
* `onCreate`. [warmUp] resolves them on `Dispatchers.IO`, so the shared client is built, the device
* identity first-touched and the persisted session cookie rehydrated off the UI thread.
*
* What lives here (all app singletons):
* - [hostStore] / [lastSessionStore] — persisted, non-secret UI state (DataStore).
@@ -39,6 +52,7 @@ import javax.inject.Singleton
* - [apiClientFactory] / [sessionEngineFactory] — per-host / per-session builders over the shared
* transports (lazy).
* - [coldStartPolicy] — the cold-launch route seam (A29).
* - the `webterm_auth` cookie jar + its durable store — the optional `WEBTERM_TOKEN` gate.
* Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its
* [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder].
*/
@@ -57,7 +71,23 @@ public class AppEnvironment @Inject constructor(
private val identityRepositoryLazy: Lazy<IdentityRepository>,
private val httpTransportLazy: Lazy<HttpTransport>,
private val termTransportLazy: Lazy<TermTransport>,
/** The SAME jar instance installed on the one shared `OkHttpClient` (`di/AuthCookieModule`). */
private val authCookieJarLazy: Lazy<AuthCookieJar>,
/** Durable, Tink-AEAD-encrypted home of the `webterm_auth` cookie (`di/AuthCookieModule`). */
private val authCookieStoreLazy: Lazy<AuthCookieStore>,
/**
* The off-screen preview painter (§6.7). `Lazy` because constructing it measures a `Paint` — an
* `android.graphics` touch that must not happen while the Hilt graph is being built (it is stubbed
* under JVM unit tests).
*/
private val thumbnailRasterizerLazy: Lazy<ThumbnailRasterizer>,
) {
/** One-shot cold-start restore of the persisted session cookie into the live jar (see [warmUp]). */
private val authCookieHydrator = AuthCookieHydrator(
store = { authCookieStoreLazy.get() },
jar = { authCookieJarLazy.get() },
)
/**
* The mTLS device identity (A27 cert screen). Resolving the [Lazy] merely constructs the repository
* (I/O-free per A11); its FIRST touch (`currentSummary`/`hasInstalledIdentity`/handshake) does the
@@ -74,26 +104,254 @@ public class AppEnvironment @Inject constructor(
public val httpTransport: HttpTransport get() = httpTransportLazy.get()
/**
* Build the two-step pairing prober (A19) over the ONE shared transports. The transports are
* resolved LAZILY inside `probe()` (not at build time) so constructing the prober on the UI thread
* never triggers the mTLS/OkHttp build — the actual resolve happens in the probe's own coroutine,
* after [warmUp].
* Build the pairing network seam (A19) over the ONE shared transports: the two-step probe plus the
* `WEBTERM_TOKEN` gate's detect + submit. The transports are resolved LAZILY inside each call (not at
* build time) so constructing the prober on the UI thread never triggers the mTLS/OkHttp build — the
* actual resolve happens in the caller's own coroutine, after [warmUp].
*
* The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth`
* cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST
* call AND on the WS upgrade.
*/
public fun buildPairingProber(): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) }
public fun buildPairingProber(): PairingProber = object : PairingProber {
private val gateway = AccessTokenGateway { httpTransportLazy.get() }
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get())
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean =
gateway.requiresAccessToken(endpoint)
override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission =
gateway.submit(endpoint, token)
}
/**
* Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving
* either transport builds the one shared client (which reads the mTLS material); touching the
* identity forces its first AndroidKeyStore/Tink read. Idempotent at the singleton level — the
* client/identity are built once and cached. Safe to call from any coroutine; hops to
* `Dispatchers.IO` internally.
* Build the session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its
* preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient]
* (`Origin` is stamped per endpoint), so it cannot be an app singleton.
*
* The CALLER owns the returned pipeline's lifetime: keep it in a `remember(env, hostId)` and call
* `close()` from a `DisposableEffect` so its in-flight renders are cancelled with the screen.
* Resolving the rasterizer measures a `Paint` — cheap, but `android.graphics`, so call this from the
* composition, never from DI construction.
*/
public fun buildThumbnailPipeline(endpoint: HostEndpoint): ThumbnailPipeline =
ThumbnailPipeline(
previewSource = ApiPreviewSource { apiClientFactory.create(endpoint) },
rasterizer = thumbnailRasterizerLazy.get(),
)
/**
* Build the shared `OkHttpClient`, rehydrate the persisted session cookie and first-touch the device
* identity OFF `Main` (FIX 3). Resolving either transport builds the one shared client (which reads
* the mTLS material); touching the identity forces its first AndroidKeyStore/Tink read; the cookie
* hydration reads DataStore + Tink. Idempotent at the singleton level — the client/identity are built
* once and cached, and the hydration is one-shot ([AuthCookieHydrator]). Safe to call from any
* coroutine; hops to `Dispatchers.IO` internally.
*/
public suspend fun warmUp() {
withContext(Dispatchers.IO) {
// First: put the stored credential in the jar, so the very first dial is already authenticated
// on a token-gated host (the jar is consulted per request, so this races nothing).
authCookieHydrator.hydrateOnce()
sessionEngineFactory.warm() // builds TermTransport → the shared OkHttpClient → reads SSL material
apiClientFactory.warm() // HttpTransport reuses the SAME already-built client
runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch
}
}
}
// ── The WEBTERM_TOKEN gate (server src/http/auth.ts) ─────────────────────────────────────────────────
/**
* The client half of the server's optional shared-access-token gate.
*
* Server contract (`src/http/auth.ts` + the `/auth` route in `src/server.ts`):
* - with `WEBTERM_TOKEN` set, EVERY remote route (and the WS upgrade) needs the `webterm_auth` cookie;
* an unauthed non-HTML request gets **401** with an `authentication required` JSON body;
* - `POST /auth` is always reachable and rate-limited to 10/min/IP. A non-HTML request answers **204**
* with `Set-Cookie: webterm_auth=…` on a match, **401** on a mismatch, **429** over the limit;
* - `GET /?token=` exists too and is deliberately NOT used here — it would put a shell credential in a
* URL (access logs, `Referer`, history).
*
* The cookie itself is never handled here: the shared client's [AuthCookieJar] captures `Set-Cookie` and
* replays it, so "the submit succeeded" and "the credential is held" are the same event.
*
* @param http resolves the shared [HttpTransport] LAZILY (resolving it builds the mTLS `OkHttpClient`, so
* it must happen inside the calling coroutine, not when this gateway is constructed).
*/
public class AccessTokenGateway(private val http: () -> HttpTransport) {
/**
* Is [endpoint] behind the token gate? Probes the unauthenticated read and looks for exactly 401.
*
* Never throws for a network problem: an unreachable host answers `false`, so a host that is merely
* down can never make the app ask the user for a credential. Cancellation still propagates.
*/
public suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean {
val response = try {
http().send(HttpRequest(method = HttpMethod.GET, url = endpoint.hostBaseUrl() + LIVE_SESSIONS_PATH))
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
return false
}
return response.status == HTTP_UNAUTHORIZED
}
/**
* `POST /auth` with [token] in an urlencoded form body.
*
* [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never
* a log line — and nothing here retains it.
*/
public suspend fun submit(endpoint: HostEndpoint, token: String): TokenSubmission {
val request = HttpRequest(
method = HttpMethod.POST,
url = endpoint.hostBaseUrl() + AUTH_PATH,
// No `Accept: text/html`: the server answers an HTML request with 302 redirects instead of
// the 204/401 status pair this mapping depends on. No `Origin` either — /auth is registered
// before the gate and is not an Origin-guarded route (plan §4.3: Origin only where required).
headers = mapOf(CONTENT_TYPE_HEADER to FORM_CONTENT_TYPE),
body = "$TOKEN_FIELD=${formEncode(token)}".toByteArray(Charsets.UTF_8),
)
val response = try {
http().send(request)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
// The TYPE only: an exception message is host-influenced and could echo the request we sent.
return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR)
}
return when {
response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted
response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected
response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited
else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}")
}
}
private companion object {
const val LIVE_SESSIONS_PATH = "/live-sessions"
const val AUTH_PATH = "/auth"
const val TOKEN_FIELD = "token"
const val CONTENT_TYPE_HEADER = "Content-Type"
const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
const val HTTP_OK = 200
const val HTTP_MULTIPLE_CHOICES = 300
const val HTTP_UNAUTHORIZED = 401
const val HTTP_TOO_MANY_REQUESTS = 429
const val UNKNOWN_ERROR = "unknown"
}
}
/**
* `scheme://host[:port]` from the dialed URL — path/query/fragment/credentials dropped. Mirrors
* `:api-client`'s probe derivation (private there, hence re-derived rather than shared); the dialed port
* is kept verbatim and IPv6 literals arrive already bracketed from [java.net.URI].
*/
private fun HostEndpoint.hostBaseUrl(): String {
val uri = URI(baseUrl)
val scheme = (uri.scheme ?: "http").lowercase()
val host = uri.host ?: ""
val portPart = if (uri.port != -1) ":${uri.port}" else ""
return "$scheme://$host$portPart"
}
/**
* Percent-encode a form-field value, allowing ONLY RFC 3986 unreserved characters through.
*
* Stricter than necessary on purpose: the server's token charset (`[A-Za-z0-9._~+/=-]`) includes `+`,
* `/` and `=`, and in an `application/x-www-form-urlencoded` body a raw `+` decodes to a SPACE while `=`
* would split the field — so a correct token would be rejected. `java.net.URLEncoder` is avoided for the
* same reason (it emits `+` for spaces).
*/
private fun formEncode(value: String): String {
val out = StringBuilder(value.length)
for (byte in value.toByteArray(Charsets.UTF_8)) {
val octet = byte.toInt() and BYTE_MASK
val char = octet.toChar()
if (char.isUnreservedUrlChar()) {
out.append(char)
} else {
// Hand-rolled hex, NOT String.format: `%02X` is locale-sensitive and would emit non-ASCII
// digits under some locales, corrupting the credential.
out.append('%').append(HEX_DIGITS[octet shr HEX_SHIFT]).append(HEX_DIGITS[octet and LOW_NIBBLE])
}
}
return out.toString()
}
private fun Char.isUnreservedUrlChar(): Boolean =
this in 'A'..'Z' || this in 'a'..'z' || this in '0'..'9' || this in "-._~"
private const val HEX_DIGITS: String = "0123456789ABCDEF"
private const val BYTE_MASK: Int = 0xFF
private const val LOW_NIBBLE: Int = 0x0F
private const val HEX_SHIFT: Int = 4
// ── Auth-cookie durability bridge (:transport-okhttp ⇄ :host-registry) ───────────────────────────────
/**
* Cold-start restore of the durable `webterm_auth` cookie into the live jar, EXACTLY ONCE.
*
* Once, because the durable write is asynchronous ([AuthCookieJar]'s persister hands off): a second
* hydration could overwrite a freshly re-authenticated in-memory cookie with the stale persisted one and
* silently put the app back on a dead credential.
*
* A failure is swallowed (warm-up must never fail on this) but does NOT consume the one shot, so the next
* warm-up retries. Worst case the user re-enters the token — never a crash, never a plaintext fallback.
*/
internal class AuthCookieHydrator(
private val store: () -> AuthCookieStore,
private val jar: () -> AuthCookieJar,
) {
private val hydrated = AtomicBoolean(false)
/** Reads the store and restores each host's cookies under that host's own key. Call off `Main`. */
internal suspend fun hydrateOnce() {
if (!hydrated.compareAndSet(false, true)) return
val records = try {
store().loadAll()
} catch (error: Throwable) {
hydrated.set(false) // not consumed — a later warm-up may succeed
if (error is CancellationException) throw error
return
}
val cookieJar = jar()
records.groupBy { it.hostKey }.forEach { (hostKey, forHost) ->
cookieJar.restore(hostKey, forHost.map { it.toSnapshot() })
}
}
}
/**
* `:transport-okhttp`'s jar DTO → `:host-registry`'s at-rest record, stamped with the isolation
* [hostKey]. The two modules must not depend on each other (`android/README.md`: dependencies only flow
* down), so `:app` owns this field-for-field mapping. `expiresAtEpochMillis` stays ABSOLUTE — persisting
* a `Max-Age` duration would restart the credential's lifetime on every restore.
*/
internal fun AuthCookieSnapshot.toRecord(hostKey: String): AuthCookieRecord = AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = domain,
path = path,
expiresAtEpochMillis = expiresAtEpochMillis,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
)
/** The reverse mapping (cold-start hydration). `hostKey` is dropped — it is the jar's partition key. */
internal fun AuthCookieRecord.toSnapshot(): AuthCookieSnapshot = AuthCookieSnapshot(
name = name,
value = value,
domain = domain,
path = path,
expiresAtEpochMillis = expiresAtEpochMillis,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
)

View File

@@ -62,8 +62,10 @@ public class TerminalSessionControllerImpl(
) : TerminalSessionController by base {
/**
* The forked Termux emulator for this session (no local process). The screen puts it on-screen via
* `attachView` and drives `updateSize`; A21 feeds it the remote output and routes its replies out.
* The forked Termux emulator for this session (no local process). The screen only puts it on-screen
* (`attachView`) / takes it off (`detachView`); the grid is driven from INSIDE `:terminal-view`
* (`RemoteTerminalHostView.onSizeChanged` → `TerminalResizeDriver` → `updateSize`), so nothing in `:app`
* computes cols×rows. A21's own two jobs are feeding it the remote output and routing its replies out.
*/
public val remoteSession: RemoteTerminalSession =
remoteSessionFactory(::routeEmulatorMessage) { raw -> onSanitizedTitle(TitleSanitizer.sanitize(raw)) }

View File

@@ -31,17 +31,25 @@ import kotlin.math.ceil
* JVM-tested via the [ThumbnailRasterizer] seam.
*/
public class CanvasThumbnailRasterizer(
private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
typeface: Typeface = Typeface.MONOSPACE,
private val maxWidthPx: Int = ThumbnailBudget.MAX_WIDTH_PX,
) : ThumbnailRasterizer {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
/**
* METRICS ONLY — never used to draw. `ThumbnailPipeline` renders up to `MAX_CONCURRENT_RENDERS` (2)
* thumbnails at once on ONE rasterizer instance, so a shared draw `Paint` would be mutated
* (colour/bold/style per cell) by two coroutines concurrently — a data race producing wrong colours or
* a torn draw. Each [rasterize] therefore copies this template into its own local `Paint`.
*/
private val metricsPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.typeface = typeface
textSize = fontSizePx
}
private val cellWidth: Int = ceil(paint.measureText("X").toDouble()).toInt().coerceAtLeast(1)
private val cellHeight: Int = (paint.fontMetricsInt.descent - paint.fontMetricsInt.ascent).coerceAtLeast(1)
private val baselineOffset: Int = -paint.fontMetricsInt.ascent
private val cellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1)
private val cellHeight: Int =
(metricsPaint.fontMetricsInt.descent - metricsPaint.fontMetricsInt.ascent).coerceAtLeast(1)
private val baselineOffset: Int = -metricsPaint.fontMetricsInt.ascent
override fun rasterize(preview: SessionPreview): Bitmap {
val cols = preview.cols.coerceIn(1, MAX_COLS)
@@ -50,15 +58,16 @@ public class CanvasThumbnailRasterizer(
val bytes = preview.data.toByteArray(Charsets.UTF_8)
emulator.append(bytes, bytes.size)
val bitmap = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(metricsPaint) // per-render Paint — see [metricsPaint]
val full = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(full)
val palette = emulator.mColors.mCurrentColors
val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND]
canvas.drawColor(defaultBg)
for (row in 0 until rows) {
drawRow(canvas, emulator, row, cols, palette, defaultBg)
drawRow(canvas, paint, emulator, row, cols, palette, defaultBg)
}
return bitmap
return downscaleToBudget(full)
}
override fun placeholder(): Bitmap {
@@ -67,7 +76,29 @@ public class CanvasThumbnailRasterizer(
return bitmap
}
private fun drawRow(canvas: Canvas, emulator: TerminalEmulator, row: Int, cols: Int, palette: IntArray, defaultBg: Int) {
/**
* Shrink a full-resolution raster to the thumbnail budget. A 161×50 session rasterises to ~1127×700 =
* **3.1 MiB** ARGB_8888 — three of those alone exceed the pipeline's cache budget and would thrash the
* LRU on a list of sessions, re-rendering on every scroll. The card is ~96dp wide, so the full raster
* is downscaled once, here, and only the small bitmap is ever cached; the oversized source is recycled
* immediately.
*/
private fun downscaleToBudget(full: Bitmap): Bitmap {
val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx) ?: return full
val scaled = Bitmap.createScaledBitmap(full, target.width, target.height, true)
if (scaled !== full) full.recycle()
return scaled
}
private fun drawRow(
canvas: Canvas,
paint: Paint,
emulator: TerminalEmulator,
row: Int,
cols: Int,
palette: IntArray,
defaultBg: Int,
) {
val screen = emulator.screen
val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
val text = line.mText
@@ -126,6 +157,33 @@ public class CanvasThumbnailRasterizer(
}
}
/**
* The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be kept.
* Split out of [CanvasThumbnailRasterizer] so the arithmetic is JVM-unit-testable (android.graphics is
* stubbed off-device), while the `Bitmap`/`Canvas` work stays device-QA.
*/
public object ThumbnailBudget {
/**
* Max cached thumbnail width (px). The row tile is ~96dp (≈288px at 3× density) and drawn with
* `ContentScale.Crop`, so 480px keeps it crisp on any density while capping one cached bitmap at a few
* hundred KiB instead of the multi-MiB full-resolution raster.
*/
public const val MAX_WIDTH_PX: Int = 480
/** A raster target size in px. */
public data class RasterSize(val width: Int, val height: Int)
/**
* The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth] (draw as
* rendered — no extra allocation). Aspect ratio is preserved and the height never collapses to 0.
*/
public fun scaledSize(width: Int, height: Int, maxWidth: Int = MAX_WIDTH_PX): RasterSize? {
if (width <= 0 || height <= 0 || width <= maxWidth) return null
val scaledHeight = (height.toLong() * maxWidth / width).toInt().coerceAtLeast(1)
return RasterSize(width = maxWidth, height = scaledHeight)
}
}
/** Off-screen emulator sink: the preview emulator produces no wire output and needs no delegates. */
private object NoOpOutput : TerminalOutput() {
override fun write(data: ByteArray?, offset: Int, count: Int) = Unit

View File

@@ -6,13 +6,17 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.routes.ApiClient
@@ -56,16 +60,21 @@ public class ThumbnailPipeline(
private val renderGate = Semaphore(maxConcurrent)
/** In-flight renders by key; guarded by [inFlightMutex]. A second same-key request shares the entry. */
private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap>>()
private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap?>>()
private val inFlightMutex = Mutex()
/**
* The cached (or freshly rendered) thumbnail for [session]. Returns the cached bitmap immediately when
* `(id, lastOutputAt)` is unchanged; otherwise fetches the preview over mTLS, rasterises off-screen,
* caches, and returns it. Never throws for a fetch/render failure — a placeholder is cached and
* returned instead (only cooperative cancellation of [scope] propagates).
* The cached (or freshly rendered) thumbnail for [session], or `null` when no bitmap at all could be
* produced. Returns the cached bitmap immediately when `(id, lastOutputAt)` is unchanged; otherwise
* fetches the preview over mTLS, rasterises off-screen, caches, and returns it.
*
* **Never throws for a fetch/render failure** — a fetch/raster failure caches the rasterizer's
* placeholder (no retry storm), and the residual case where even the placeholder cannot be allocated
* (a `Bitmap` OOM) degrades to `null` rather than an exception: the caller is a row's `LaunchedEffect`,
* so a throw would take down the composition. Only cancellation of the CALLER propagates; a render
* killed by [close] also surfaces as `null` (the caller is still alive — it just has no thumbnail).
*/
public suspend fun thumbnail(session: LiveSessionInfo): Bitmap {
public suspend fun thumbnail(session: LiveSessionInfo): Bitmap? {
val key = ThumbnailKey(session.id, session.lastOutputAt)
// Fast path: an unchanged lastOutputAt ⇒ identical screen ⇒ never re-render (§6.7).
cache.get(key)?.let { return it }
@@ -76,7 +85,23 @@ public class ThumbnailPipeline(
cache.get(key)?.let { return it }
inFlight[key] ?: startRender(key).also { inFlight[key] = it }
}
return deferred.await()
return try {
deferred.await()
} catch (cancel: CancellationException) {
// Distinguish "the pipeline was closed under us" (→ no thumbnail) from "OUR caller was
// cancelled" (→ propagate, so the collector tears down as structured concurrency requires).
if (currentCoroutineContext().isActive) null else throw cancel
}
}
/**
* Release every cached bitmap that is not part of the CURRENT live list — killed/exited sessions and
* superseded `lastOutputAt` keys — so the cache holds only what the chooser can still display. The
* [LruCache] byte budget already bounds worst-case memory; this returns the memory promptly instead of
* waiting for eviction pressure, and keeps dead sessions from occupying the budget at all.
*/
public fun retainOnly(live: Collection<LiveSessionInfo>) {
cache.retainOnly(live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) })
}
/** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */
@@ -84,18 +109,28 @@ public class ThumbnailPipeline(
scope.cancel()
}
private fun startRender(key: ThumbnailKey): Deferred<Bitmap> = scope.async {
val bitmap = renderGuarded(key.sessionId)
// Publish under the lock so a concurrent [thumbnail] re-check sees the result the instant this
// key stops being in-flight — success AND placeholder are cached identically (§6.7 no-retry-storm).
inFlightMutex.withLock {
cache.put(key, bitmap)
inFlight.remove(key)
private fun startRender(key: ThumbnailKey): Deferred<Bitmap?> = scope.async {
var rendered: Bitmap? = null
try {
rendered = renderGuarded(key.sessionId)
rendered
} finally {
// ALWAYS release the in-flight slot — including on cancellation (scope closed) and on an
// unexpected throw. A retained entry would poison the key: every later request for it would
// await a dead Deferred forever. NonCancellable so the cleanup still runs while cancelling.
withContext(NonCancellable) {
inFlightMutex.withLock {
// Publish under the same lock, so a concurrent [thumbnail] re-check sees the result the
// instant the key stops being in-flight. Success AND placeholder cache identically
// (§6.7 no-retry-storm); an unproducible bitmap caches nothing and may be retried.
rendered?.let { cache.put(key, it) }
inFlight.remove(key)
}
}
}
bitmap
}
private suspend fun renderGuarded(sessionId: UUID): Bitmap =
private suspend fun renderGuarded(sessionId: UUID): Bitmap? =
try {
renderGate.withPermit {
val preview = previewSource.fetch(sessionId)
@@ -105,8 +140,9 @@ public class ThumbnailPipeline(
throw e // never swallow cooperative cancellation
} catch (t: Throwable) {
// Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder
// so the broken session isn't retried on every scroll frame (§6.7).
rasterizer.placeholder()
// so the broken session isn't retried on every scroll frame (§6.7). If even the placeholder
// cannot be allocated, give up with null — never throw at a UI collector.
runCatching { rasterizer.placeholder() }.getOrNull()
}
public companion object {
@@ -118,6 +154,25 @@ public class ThumbnailPipeline(
}
}
/**
* Build the production preview pipeline for ONE host: the read-only [ApiPreviewSource] over that host's
* [ApiClient] (the shared mTLS `OkHttpClient`) feeding the [CanvasThumbnailRasterizer] off-screen painter,
* with the default LRU budget / `Semaphore(2)` cap / `Dispatchers.Default` render pool.
*
* This is the ONE construction point (`SessionsHome` wires the active host through it), so nothing else
* has to know which rasterizer or which cache the session chooser uses. [api] is a PROVIDER and is
* resolved lazily on the first fetch — inside the pipeline's own background dispatcher — so calling this
* from a composition does NO `OkHttpClient` / AndroidKeyStore / Tink work on `Main`.
*
* The owner MUST call [ThumbnailPipeline.close] when the surface goes away (the render scope outlives any
* single caller by design).
*/
public fun sessionThumbnailPipeline(api: () -> ApiClient): ThumbnailPipeline =
ThumbnailPipeline(
previewSource = ApiPreviewSource(api),
rasterizer = CanvasThumbnailRasterizer(),
)
/**
* Cache key: an unchanged `(sessionId, lastOutputAt)` pair means the server's last PTY output is
* unchanged ⇒ the preview is byte-identical ⇒ the cached bitmap is reused verbatim (plan §6.7). A `null`
@@ -144,10 +199,17 @@ public interface ThumbnailRasterizer {
public fun placeholder(): Bitmap
}
/** The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. */
/**
* The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable.
* Implementations MUST be thread-safe: [ThumbnailPipeline] reads it from an unsynchronised fast path and
* writes it from its render coroutines ([LruCache] is internally synchronised).
*/
public interface BitmapCache {
public fun get(key: ThumbnailKey): Bitmap?
public fun put(key: ThumbnailKey, bitmap: Bitmap)
/** Drop every entry whose key is not in [keys] (dead sessions / superseded `lastOutputAt`). */
public fun retainOnly(keys: Set<ThumbnailKey>)
}
/**
@@ -164,14 +226,29 @@ public class LruBitmapCache(maxBytes: Int) : BitmapCache {
override fun put(key: ThumbnailKey, bitmap: Bitmap) {
lru.put(key, bitmap)
}
/** `snapshot()` is a copy, so removing while iterating it is safe (and `remove` is synchronised). */
override fun retainOnly(keys: Set<ThumbnailKey>) {
for (key in lru.snapshot().keys) {
if (key !in keys) lru.remove(key)
}
}
}
/**
* Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so
* tunnel-host previews traverse nginx). Re-caps the body at 256 KiB client-side — defence-in-depth over
* the server's own cap (plan §6.7 / §4.2).
* tunnel-host previews traverse nginx). `GET /live-sessions/:id/preview` is a READ-ONLY route: it does NOT
* attach, register a client, or touch the session's idle clock. Re-caps the body at 256 KiB client-side —
* defence-in-depth over the server's own cap (plan §6.7 / §4.2).
*
* [api] is a PROVIDER, not a client: resolving it resolves the shared `HttpTransport`, which builds the one
* `OkHttpClient` and first-touches the AndroidKeyStore/Tink mTLS material. That must never happen on the
* composition (`Main`) thread, so the client is materialised lazily inside [fetch], which runs on the
* pipeline's own background dispatcher (plan §"off-`Main` warm-up").
*/
public class ApiPreviewSource(private val apiClient: ApiClient) : PreviewSource {
public class ApiPreviewSource(api: () -> ApiClient) : PreviewSource {
private val apiClient: ApiClient by lazy(api)
override suspend fun fetch(sessionId: UUID): SessionPreview =
PreviewCap.cap(apiClient.preview(sessionId))
}

View File

@@ -204,6 +204,109 @@ class QuickReplyTest {
assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = false))
}
// ── 4. The palette presenter (the production state holder behind the chips) ────
@Test
fun `palette load publishes the stored chips`() = runTest {
val palette = QuickReplyPalette(InMemoryQuickReplyStore())
assertTrue(palette.chips.value.isEmpty(), "nothing is published before load()")
palette.load()
assertEquals(QuickReplyDefaults.chips, palette.chips.value)
}
@Test
fun `palette CRUD republishes the store's new list every time`() = runTest {
val palette = QuickReplyPalette(InMemoryQuickReplyStore(newId = sequentialId()))
palette.load()
palette.add("run tests")
assertEquals("run tests", palette.chips.value.last().text)
palette.edit("qr-yes", "yes please")
assertEquals("yes please", palette.chips.value.single { it.id == "qr-yes" }.text)
val addedId = palette.chips.value.last().id
palette.remove(addedId)
assertFalse(palette.chips.value.any { it.id == addedId })
val idsBeforeMove = palette.chips.value.map { it.id }
palette.move(0, idsBeforeMove.lastIndex)
assertEquals(idsBeforeMove.drop(1) + idsBeforeMove.first(), palette.chips.value.map { it.id })
}
@Test
fun `palette rejects a blank add without touching the published list`() = runTest {
val palette = QuickReplyPalette(InMemoryQuickReplyStore())
palette.load()
val before = palette.chips.value
palette.add(" ")
assertEquals(before, palette.chips.value)
}
@Test
fun `an emptied palette stays empty (no default re-seed) and hides the row`() = runTest {
val palette = QuickReplyPalette(InMemoryQuickReplyStore())
palette.load()
palette.chips.value.forEach { palette.remove(it.id) }
assertTrue(palette.chips.value.isEmpty())
assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = palette.chips.value.isNotEmpty()))
}
// ── 5. The editor's one-field select / commit reducer ─────────────────────────
@Test
fun `a fresh editor state adds`() {
val state = QuickReplyEditorState()
assertNull(state.selectedId)
assertEquals("", state.draft)
assertEquals(QuickReplyCommit.NONE, state.commitKind()) // blank draft → nothing to commit
}
@Test
fun `a non-blank draft with no selection is an ADD`() {
assertEquals(QuickReplyCommit.ADD, QuickReplyEditorState(draft = "deploy").commitKind())
}
@Test
fun `selecting a chip loads its text and switches the commit to EDIT`() {
val state = QuickReplyEditorState().selecting(QuickReplyChip("qr-yes", "yes"))
assertEquals("qr-yes", state.selectedId)
assertEquals("yes", state.draft)
assertEquals(QuickReplyCommit.EDIT, state.commitKind())
}
@Test
fun `a blank draft never commits, selected or not`() {
assertEquals(QuickReplyCommit.NONE, QuickReplyEditorState(selectedId = "qr-yes", draft = " ").commitKind())
assertEquals(QuickReplyCommit.NONE, QuickReplyEditorState(draft = "").commitKind())
}
@Test
fun `clearing drops the selection and the draft, and never mutates the previous state`() {
val selected = QuickReplyEditorState().selecting(QuickReplyChip("qr-no", "no"))
val cleared = selected.cleared()
assertNull(cleared.selectedId)
assertEquals("", cleared.draft)
// The previous snapshot is untouched (immutability).
assertEquals("qr-no", selected.selectedId)
assertEquals("no", selected.draft)
}
@Test
fun `the draft is carried verbatim into the commit (surrounding whitespace preserved)`() = runTest {
val state = QuickReplyEditorState(draft = " spaced ")
assertEquals(QuickReplyCommit.ADD, state.commitKind())
val palette = QuickReplyPalette(InMemoryQuickReplyStore(initial = emptyList(), newId = sequentialId()))
palette.load()
palette.add(state.draft)
assertEquals(" spaced ", palette.chips.value.single().text)
}
// ── Pure transforms never mutate the receiver ─────────────────────────────────
@Test

View File

@@ -0,0 +1,89 @@
package wang.yaojia.webterm.nav
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
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.components.sessionRowMenuActions
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.viewmodels.SessionRow
import java.util.UUID
/**
* The pointer-context-menu ACTION SET for a session row (A26 — the large-screen right-click menu).
*
* The menu's *visibility* gate is [PointerMenuPolicy] (already covered by `LayoutPolicyTest`); this covers
* the other half — which entries a row offers and what each one does. Pure JVM: the entries are built by
* [sessionRowMenuActions], so the wiring is verified without a device (the pointer gesture itself is
* device-QA, plan §7).
*
* NOTE the "copy" entry iOS has is deliberately ABSENT: copy-out belongs to the terminal's own text
* selection `ActionMode`, and a list row has no selected text — so the menu must not advertise it.
*
* (This file sits in the `nav` test package because it covers the row-menu half of the adaptive shell
* wired here; the function itself lives beside [wang.yaojia.webterm.components.PointerContextMenu] and
* [wang.yaojia.webterm.components.ContextMenuAction], which is its cohesive home.)
*/
class SessionRowMenuTest {
private fun row(cwd: String?): SessionRow = SessionRow(
info = LiveSessionInfo(
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
createdAt = 0L,
clientCount = 1,
exited = false,
cwd = cwd,
cols = 80,
rows = 24,
lastOutputAt = 1L,
),
displayStatus = DisplayStatus.Working,
title = "web-terminal",
isUnread = false,
)
@Test
fun `a row with a cwd offers open, new-session-in-cwd and kill — and never copy`() {
val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = {}, onKill = {})
assertEquals(3, actions.size)
assertTrue(actions[0].label.contains("打开"), "the first entry opens the session: ${actions[0].label}")
assertTrue(actions[1].label.contains("开新会话"), "cwd spawn entry missing: ${actions[1].label}")
assertTrue(actions[2].label.contains("终止"), "kill entry missing: ${actions[2].label}")
assertFalse(actions.any { it.label.contains("复制") }, "copy is a documented gap — must not appear")
}
@Test
fun `a row without a usable cwd drops the new-session entry`() {
assertEquals(2, sessionRowMenuActions(row(null), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size)
assertEquals(2, sessionRowMenuActions(row(" "), onOpen = {}, onNewSessionInCwd = {}, onKill = {}).size)
}
@Test
fun `no spawn handler wired means no new-session entry, even with a cwd`() {
val actions = sessionRowMenuActions(row("/srv/app"), onOpen = {}, onNewSessionInCwd = null, onKill = {})
assertEquals(2, actions.size)
assertFalse(actions.any { it.label.contains("开新会话") })
}
@Test
fun `each entry invokes its callback with the row's own id and cwd`() {
var opened: UUID? = null
var spawnedIn: String? = null
var killed: UUID? = null
val target = row("/home/dev/project")
val actions = sessionRowMenuActions(
row = target,
onOpen = { opened = it },
onNewSessionInCwd = { spawnedIn = it },
onKill = { killed = it },
)
actions.forEach { it.onClick() }
assertEquals(target.id, opened)
assertEquals("/home/dev/project", spawnedIn)
assertEquals(target.id, killed)
}
}

View File

@@ -0,0 +1,67 @@
package wang.yaojia.webterm.screens
import androidx.lifecycle.Lifecycle
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.session.FailureReason
import kotlin.time.Duration.Companion.seconds
/**
* The §6.4 latest-writer-wins reclaim policy — the pure half of the device-switch resize path
* (plan §6.4 / A21). The Compose/lifecycle plumbing that feeds it is device-QA (§7); WHICH events
* must reach `SessionEngine.notifyForegrounded` is decided here, in JVM-testable data.
*
* The rule that makes this non-obvious: `:terminal-view`'s `TerminalResizeDriver` now owns pushing
* the measured grid on a layout change (and dedups sub-cell churn), so an extra engine-level
* re-assert on the SAME size-changed event would double every rotation's `resize` frame. The engine
* call therefore belongs to the events the view layer cannot see — becoming the active device again
* (resume / window-focus gain) — plus the disconnected case, where it is the connect-now nudge.
*/
class TerminalReclaimPolicyTest {
// ── Lifecycle: only ON_RESUME means "this device is active again" ────────────────────────────
@Test
fun `ON_RESUME reclaims the shared PTY size`() {
assertTrue(TerminalReclaimPolicy.reclaimsOnLifecycle(Lifecycle.Event.ON_RESUME))
}
@Test
fun `no other lifecycle event reclaims`() {
Lifecycle.Event.values()
.filter { it != Lifecycle.Event.ON_RESUME }
.forEach { event ->
assertFalse(TerminalReclaimPolicy.reclaimsOnLifecycle(event), "$event must not reclaim")
}
}
// ── Window focus: gain only (a blur must never resize — plan §6.4 removed the size-vote) ────
@Test
fun `window-focus GAIN reclaims and a blur does not`() {
assertTrue(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = true))
assertFalse(TerminalReclaimPolicy.reclaimsOnWindowFocus(focused = false))
}
// ── View size change: nudge only while disconnected (the driver owns the connected case) ────
@Test
fun `a size change while connected does NOT re-assert (the view driver already pushed it)`() {
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Hidden))
}
@Test
fun `a size change while connecting or reconnecting nudges connect-now`() {
assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Connecting))
assertTrue(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Reconnecting(2, 4.seconds)))
}
@Test
fun `a size change on a terminal banner never nudges (no reconnect is owed)`() {
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)))
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(0, null)))
assertFalse(TerminalReclaimPolicy.nudgesOnViewSizeChange(BannerModel.Exited(-1, "spawn failed")))
}
}

View File

@@ -1,6 +1,7 @@
package wang.yaojia.webterm.viewmodels
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
@@ -9,6 +10,7 @@ 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.WorktreeState
/**
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
@@ -18,6 +20,8 @@ import wang.yaojia.webterm.api.models.UiPrefs
*/
class FakeWorktreeGateway(
private val detail: ProjectDetail? = null,
private val worktreeStates: Map<String, WorktreeState> = emptyMap(),
private val fetchOutcome: GitWriteOutcome<FetchResult> = GitWriteOutcome.Ok(FetchResult()),
private val prResult: PrStatus? = null,
private val prThrows: Boolean = false,
private val logResult: GitLogResult? = null,
@@ -29,6 +33,8 @@ class FakeWorktreeGateway(
val createCalls = mutableListOf<Triple<String, String, String?>>()
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
val pruneCalls = mutableListOf<String>()
val worktreeStateCalls = mutableListOf<String>()
val fetchCalls = mutableListOf<String>()
var detailCalls = 0
private set
@@ -51,6 +57,16 @@ class FakeWorktreeGateway(
return logResult ?: throw NotImplementedError("no log configured")
}
override suspend fun worktreeState(worktreePath: String): WorktreeState {
worktreeStateCalls += worktreePath
return worktreeStates[worktreePath] ?: throw RuntimeException("probe failed for $worktreePath")
}
override suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> {
fetchCalls += path
return fetchOutcome
}
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
createCalls += Triple(path, branch, base)
return createOutcome

View File

@@ -0,0 +1,192 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.PreviewDiffFile
import wang.yaojia.webterm.wire.PreviewDiffHunk
import wang.yaojia.webterm.wire.PreviewDiffLine
import wang.yaojia.webterm.wiring.TerminalSessionController
/**
* W1 approval preview — a remote one-tap approval must show WHAT it approves (the command or the
* diff), not just a tool name. The content is derived server-side from the hook `tool_input`, i.e. it
* is **attacker-influenced**: it renders strictly inert, and this test pins the client-side
* defence-in-depth sanitizer plus the rule that an ABSENT preview is a NORMAL state (the name-only
* gate stays fully decidable — a missing preview never blocks or breaks a gate).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class GatePreviewTest {
private class FakeController : TerminalSessionController {
private val channel = Channel<SessionEvent>(Channel.UNLIMITED)
val decisions = mutableListOf<Pair<Int, ClientMessage>>()
override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
override val output: Flow<ByteArray> = emptyFlow()
override fun start() = Unit
override fun sendInput(data: String) = Unit
override fun resize(cols: Int, rows: Int) = Unit
override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
override fun decideGate(epoch: Int, message: ClientMessage) { decisions += epoch to message }
override fun close() = Unit
fun emit(event: SessionEvent) { channel.trySend(event) }
}
// ── Absent preview is a NORMAL state ─────────────────────────────────────────────────────────
@Test
fun `a gate with no preview stays fully decidable and shows no preview block`() =
runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = 1)))
assertNotNull(vm.uiState.value.gate, "the gate itself is unaffected")
assertNull(vm.uiState.value.preview, "no preview → the name-only bar, not an error")
vm.decide(GateDecision.APPROVE, 1)
assertEquals(1, fake.decisions.size, "a preview-less gate is still approvable")
}
@Test
fun `a held gate carries its preview onto the ui state`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(
Gate(
GateState(
kind = GateKind.TOOL,
detail = "Bash",
epoch = 1,
preview = ApprovalPreview.Command("rm -rf build/"),
),
),
)
val preview = vm.uiState.value.preview
assertTrue(preview is ApprovalPreview.Command, "expected a command preview, got $preview")
assertEquals("rm -rf build/", (preview as ApprovalPreview.Command).text)
}
@Test
fun `a lifted gate clears any preview with it`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(
Gate(
GateState(
kind = GateKind.TOOL,
detail = "Bash",
epoch = 1,
preview = ApprovalPreview.Command("rm -rf build/"),
),
),
)
assertNotNull(vm.uiState.value.preview)
fake.emit(Gate(null))
assertNull(vm.uiState.value.gate)
assertNull(vm.uiState.value.preview, "a stale preview must never outlive its gate")
}
@Test
fun `a new gate replaces the previous gate's preview`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(Gate(gateWithCommand(epoch = 1)))
fake.emit(Gate(gateWithCommand(epoch = 2)))
assertEquals("cmd for epoch 2", (vm.uiState.value.preview as ApprovalPreview.Command).text)
}
private fun gateWithCommand(epoch: Int) = GateState(
kind = GateKind.TOOL,
detail = "Bash",
epoch = epoch,
preview = ApprovalPreview.Command("cmd for epoch $epoch"),
)
// ── Inert rendering (§8) ────────────────────────────────────────────────────────────────────
@Test
fun `command preview text keeps newlines and strips control and ANSI bytes`() {
val raw = "rm -rf build/\n\u001B[31mecho\u0007 boom\u0000"
val safe = inertPreviewText(raw)
assertTrue(safe.contains("\n"), "a shell command's line structure is meaningful")
assertFalse(safe.contains("\u001B"), "no ESC \u2014 preview text must never drive the terminal")
assertFalse(safe.contains("\u0007"), "no BEL")
assertFalse(safe.contains("\u0000"), "no NUL")
assertEquals("rm -rf build/\n[31mecho boom", safe)
}
@Test
fun `command preview leaves ordinary text and markup characters literal`() {
val raw = "grep -n '<script>alert(1)</script>' *.ts && echo \"a&b\""
assertEquals(raw, inertPreviewText(raw), "no escaping, no linkify — the UI renders it as text")
}
@Test
fun `tabs survive but carriage returns do not`() {
assertEquals("a\tb", inertPreviewText("a\tb"))
assertEquals("ab", inertPreviewText("a\rb"), "a bare CR would rewrite the rendered line")
}
@Test
fun `the display text of a command preview is the sanitized one`() {
val preview = ApprovalPreview.Command("echo \u001Bx", truncated = true)
assertEquals("echo x", preview.inertText(), "what the UI paints")
assertTrue(preview.truncated)
}
@Test
fun `a command preview with nothing legible left is not a preview at all`() {
assertNull(ApprovalPreview.Command(" \u0000 ").inertText())
assertEquals("ls", ApprovalPreview.Command("ls").inertText())
}
@Test
fun `a diff preview carries its one synthetic file and truncation flag`() {
val file = PreviewDiffFile(
oldPath = "a.ts",
newPath = "a.ts",
status = "modified",
added = 1,
removed = 0,
binary = false,
hunks = listOf(PreviewDiffHunk("@@ -1 +1 @@", listOf(PreviewDiffLine("added", "+x")))),
)
val preview = ApprovalPreview.Diff(file, truncated = true)
assertEquals("a.ts", preview.file.newPath)
assertEquals("+x", preview.file.hunks.single().lines.single().text)
assertTrue(preview.truncated)
}
}

View File

@@ -0,0 +1,275 @@
package wang.yaojia.webterm.viewmodels
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.SyncState
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* w6 git panel — the pure presentation core (`syncBandOf` / `boundaryIndex` / `worktreeChips` /
* `headerDirtyChip`), ported from the shipped web behaviour in `public/projects.ts` +
* `public/git-log.ts` and the design in `docs/mockups/project-detail-git.html`.
*
* The load-bearing rule these tests exist for: **exactly one state may read as the green
* "all clear"** — `ahead == 0 && behind == 0` AND a fetch inside SyncState.FETCH_FRESH_WINDOW_MS. "No upstream"
* leaves ahead/behind undefined and MUST fall through to an explicit `no upstream`, never to green
* (docs/plans/w6-project-git-panel.md: "the single easiest bug to ship").
*/
class GitSyncPanelTest {
private val now = 1_800_000_000_000L
private val fresh = now - 60_000L // 1 min ago → inside the 1 h staleness window
private val ancient = now - (19L * 24 * 60 * 60 * 1000) // 19 days ago
// ── The green path: exactly one state ────────────────────────────────────────────────────────
@Test
fun `ahead zero behind zero with a fresh fetch is the one green all-clear`() {
val band = syncBandOf(
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = fresh),
nowMs = now,
)
val tracking = assertTracking(band)
assertFalse(tracking.isStale, "a 1-minute-old fetch is fresh")
assertTrue(tracking.isAllClear, "↑0 ↓0 + fresh fetch is the single green state")
}
@Test
fun `ahead zero behind zero with a stale fetch is never green and marks the behind number`() {
val band = syncBandOf(
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = ancient),
nowMs = now,
)
val tracking = assertTracking(band)
assertTrue(tracking.isStale, "a 19-day-old FETCH_HEAD is stale")
assertFalse(tracking.isAllClear, "a stale ↓0 is a guess, not a fact — never green")
}
@Test
fun `never fetched is stale, not fresh`() {
val band = syncBandOf(
SyncState(upstream = "origin/develop", ahead = 0, behind = 0, lastFetchMs = null),
nowMs = now,
)
val tracking = assertTracking(band)
assertTrue(tracking.isStale)
assertFalse(tracking.isAllClear)
}
/** THE fall-through this feature is most likely to get wrong (plan §"Design rule"). */
@Test
fun `no upstream renders an explicit no-upstream state and never the green path`() {
val band = syncBandOf(
SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh),
nowMs = now,
)
assertEquals(SyncBand.NoUpstream, band, "a branch that tracks nothing has no ↑↓ to report")
assertFalse(band!!.isAllClear, "absent numbers must never read as 'in sync'")
}
@Test
fun `a blank upstream string is treated as no upstream`() {
val band = syncBandOf(SyncState(upstream = " ", lastFetchMs = fresh), nowMs = now)
assertEquals(SyncBand.NoUpstream, band)
}
@Test
fun `only the tracking-in-sync state is ever all clear`() {
val allClear = listOf(
syncBandOf(SyncState(detached = true), nowMs = now),
syncBandOf(SyncState(upstream = null), nowMs = now),
syncBandOf(SyncState(upstream = "origin/x", ahead = 1, behind = 0, lastFetchMs = fresh), nowMs = now),
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 3, lastFetchMs = fresh), nowMs = now),
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = ancient), nowMs = now),
syncBandOf(SyncState(upstream = "origin/x", ahead = 0, behind = 0, lastFetchMs = fresh), nowMs = now),
).map { it?.isAllClear }
assertEquals(listOf(false, false, false, false, false, true), allClear)
}
// ── Degraded shapes ─────────────────────────────────────────────────────────────────────────
@Test
fun `a detached HEAD shows the short sha, no branch, and disables fetch`() {
val band = syncBandOf(SyncState(detached = true, lastFetchMs = fresh), head = "1dbed54", nowMs = now)
assertEquals(SyncBand.Detached("1dbed54"), band)
assertFalse(band!!.canFetch, "fetch is not available on a detached HEAD")
}
@Test
fun `fetch stays available for tracking and no-upstream states`() {
assertTrue(syncBandOf(SyncState(upstream = null), nowMs = now)!!.canFetch)
assertTrue(syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now)!!.canFetch)
}
@Test
fun `absent sync facts render no band at all (non-git project)`() {
assertNull(syncBandOf(null, nowMs = now))
}
@Test
fun `absent ahead and behind on a tracking branch count as zero`() {
val tracking = assertTracking(
syncBandOf(SyncState(upstream = "origin/x", lastFetchMs = fresh), nowMs = now),
)
assertEquals(0, tracking.ahead)
assertEquals(0, tracking.behind)
assertTrue(tracking.isAllClear, "a tracking branch with no counts and a fresh fetch IS in sync")
}
// ── Header dirty chip (● 3, not a bare dot) ─────────────────────────────────────────────────
@Test
fun `a dirty count renders the number, not a bare dot`() {
assertEquals(GitPanelCopy.dirtyChip(3), headerDirtyChip(dirtyCount = 3, dirty = true))
assertTrue(headerDirtyChip(dirtyCount = 3, dirty = true)!!.contains("3"))
}
@Test
fun `dirty without a count falls back to the countless chip, and clean renders nothing`() {
assertEquals(GitPanelCopy.DIRTY_NO_COUNT, headerDirtyChip(dirtyCount = null, dirty = true))
assertNull(headerDirtyChip(dirtyCount = 0, dirty = true), "0 changed files is not dirty")
assertNull(headerDirtyChip(dirtyCount = null, dirty = false))
assertNull(headerDirtyChip(dirtyCount = null, dirty = null))
}
// ── Commit list: unpushed marking + ONE boundary ─────────────────────────────────────────────
private fun log(vararg unpushed: Boolean, upstream: String? = null, truncated: Boolean = false) =
GitLogResult(
commits = unpushed.mapIndexed { i, flag ->
CommitLogEntry(hash = "hash$i", at = now - i * 1000L, subject = "s$i", unpushed = flag)
},
truncated = truncated,
upstream = upstream,
)
@Test
fun `the boundary is drawn once, after the last unpushed commit`() {
val log = log(true, true, false, false, upstream = "origin/develop")
assertEquals(1, log.boundaryIndex(), "one boundary, right after the last unpushed row")
assertEquals("origin/develop", log.boundaryLabel())
assertEquals(2, log.unpushedCount)
}
/**
* A merge of an older branch interleaves an unpushed commit BELOW a pushed one. The per-commit flag
* is authoritative — never the row index — and the single boundary sits after the LAST unpushed row
* (plan §TDD step 19).
*/
@Test
fun `an unpushed commit sorting below a pushed one still moves the boundary`() {
val log = log(true, false, true, false, upstream = "origin/develop")
assertEquals(2, log.boundaryIndex())
assertEquals(listOf(true, false, true, false), log.commits.map { it.isUnpushed() })
}
@Test
fun `no unpushed commits means no boundary`() {
val log = log(false, false, upstream = "origin/develop")
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex())
assertEquals(0, log.unpushedCount)
}
@Test
fun `without an upstream no boundary is drawn even when rows are marked`() {
val log = log(true, true, upstream = null)
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex(), "nothing to draw the boundary against")
assertNull(log.boundaryLabel())
}
@Test
fun `a blank upstream draws no boundary and no label`() {
val log = log(true, upstream = " ")
assertEquals(NO_COMMIT_BOUNDARY, log.boundaryIndex())
assertNull(log.boundaryLabel(), "an empty label would claim a position it cannot name")
}
@Test
fun `an absent unpushed flag reads as pushed, never the other way round`() {
val commit = CommitLogEntry(hash = "h", at = now, subject = "s", unpushed = null)
assertFalse(commit.isUnpushed(), "unknown must not be marked; only an explicit true counts")
}
// ── Worktree section ────────────────────────────────────────────────────────────────────────
@Test
fun `the worktree section always reads Worktrees with its count, even at one`() {
assertEquals("Worktrees (1)", GitPanelCopy.worktreesTitle(1))
assertEquals("Worktrees (2)", GitPanelCopy.worktreesTitle(2))
assertEquals("Worktrees (0)", GitPanelCopy.worktreesTitle(0))
}
private val mainWorktree = WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true)
@Test
fun `a worktree with no upstream shows no-upstream and never a green chip`() {
val chips = worktreeChips(
WorktreeInfo(path = "/repo/.claude/worktrees/x", branch = "worktree-x"),
WorktreeRowState(sync = SyncState(upstream = null, lastFetchMs = fresh)),
)
assertTrue(chips.any { it.label == GitPanelCopy.NO_UPSTREAM_CHIP })
assertFalse(chips.any { it.tone == GitChipTone.OK }, "a worktree row has no green all-clear chip")
}
@Test
fun `worktree chips report main current locked prunable then sync then dirty`() {
val chips = worktreeChips(
mainWorktree.copy(locked = true, prunable = true),
WorktreeRowState(sync = SyncState(upstream = "origin/develop", ahead = 9, lastFetchMs = fresh), dirtyCount = 3),
)
assertEquals(
listOf(
GitPanelCopy.MAIN_CHIP,
GitPanelCopy.CURRENT_CHIP,
GitPanelCopy.LOCKED_CHIP,
GitPanelCopy.PRUNABLE_CHIP,
GitPanelCopy.aheadChip(9),
GitPanelCopy.dirtyCountChip(3),
),
chips.map { it.label },
)
}
@Test
fun `an unprobed worktree row shows no sync chip at all rather than a guess`() {
val chips = worktreeChips(mainWorktree, WorktreeRowState())
assertEquals(listOf(GitPanelCopy.MAIN_CHIP, GitPanelCopy.CURRENT_CHIP), chips.map { it.label })
}
@Test
fun `a detached worktree row says detached`() {
val chips = worktreeChips(
WorktreeInfo(path = "/repo/wt", branch = null, head = "1dbed54"),
WorktreeRowState(sync = SyncState(detached = true)),
)
assertEquals(listOf(GitPanelCopy.DETACHED_CHIP_SHORT), chips.map { it.label })
}
private fun assertTracking(band: SyncBand?): SyncBand.Tracking {
assertTrue(band is SyncBand.Tracking, "expected a tracking band, got $band")
return band as SyncBand.Tracking
}
}

View File

@@ -0,0 +1,177 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
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.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
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
import wang.yaojia.webterm.api.models.WorktreeInfo
import wang.yaojia.webterm.api.models.WorktreeState
/**
* w6/G2+G3 — the Fetch action on the project-detail sync band.
*
* `git fetch` only moves remote-tracking refs (no pull, no merge), so it is the ONE write the panel
* offers. The rules pinned here: one in-flight fetch at a time (a second tap is dropped, never
* queued), a success re-loads the detail so `behind`/`lastFetchMs` actually move, and a FAILURE
* changes nothing — `lastFetchMs` stays where it was so the `stale` flag stays ON (never silently
* mark the data fresh).
*/
class ProjectDetailFetchTest {
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "develop")
private fun okFetch(lastFetchMs: Long?): GitWriteOutcome<FetchResult> =
GitWriteOutcome.Ok(FetchResult(lastFetchMs))
private fun vmWith(
fetchRemote: (suspend () -> GitWriteOutcome<FetchResult>)?,
): Pair<ProjectDetailViewModel, IntArray> {
val detailCalls = intArrayOf(0)
val vm = ProjectDetailViewModel(
path = "/repo",
fetch = { detailCalls[0]++; detail },
fetchRemote = fetchRemote,
)
return vm to detailCalls
}
@Test
fun `no fetch seam wired means the button is not offered`() {
val (vm, _) = vmWith(null)
assertFalse(vm.canFetch, "without a fetch gateway the band must not offer the action")
assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value)
}
@Test
fun `a successful fetch reports the new lastFetchMs and re-loads the detail`() = runTest {
val (vm, detailCalls) = vmWith { okFetch(42L) }
vm.load()
val callsAfterLoad = detailCalls[0]
vm.fetchUpstream()
assertTrue(vm.canFetch)
assertEquals(ProjectDetailViewModel.FetchPhase.Done(42L), vm.fetchPhase.value)
assertEquals(callsAfterLoad + 1, detailCalls[0], "a fetch refreshes ahead/behind")
}
@Test
fun `a failing fetch surfaces a short message and does NOT re-load or mark data fresh`() = runTest {
val (vm, detailCalls) = vmWith { throw RuntimeException("offline") }
vm.load()
val callsAfterLoad = detailCalls[0]
vm.fetchUpstream()
val phase = vm.fetchPhase.value
assertTrue(phase is ProjectDetailViewModel.FetchPhase.Failed, "expected Failed, got $phase")
assertEquals(GitPanelCopy.FETCH_FAILED, (phase as ProjectDetailViewModel.FetchPhase.Failed).message)
assertEquals(callsAfterLoad, detailCalls[0], "a failed fetch must not claim fresh data")
}
@Test
fun `a second fetch while one is in flight is dropped, not queued`() = runTest {
val gate = CompletableDeferred<GitWriteOutcome<FetchResult>>()
var invocations = 0
val vm = ProjectDetailViewModel(
path = "/repo",
fetch = { detail },
fetchRemote = { invocations++; gate.await() },
)
val first = launch { vm.fetchUpstream() }
// Let the first call reach the suspension point, then tap again.
kotlinx.coroutines.yield()
assertEquals(ProjectDetailViewModel.FetchPhase.Working, vm.fetchPhase.value)
vm.fetchUpstream()
gate.complete(okFetch(7L))
first.join()
assertEquals(1, invocations, "the in-flight fetch is not re-entered")
assertEquals(ProjectDetailViewModel.FetchPhase.Done(7L), vm.fetchPhase.value)
}
@Test
fun `the fetch banner is dismissable back to idle`() = runTest {
val (vm, _) = vmWith { okFetch(1L) }
vm.fetchUpstream()
vm.clearFetchBanner()
assertEquals(ProjectDetailViewModel.FetchPhase.Idle, vm.fetchPhase.value)
}
@Test
fun `a rejected fetch surfaces the server's own safe message verbatim`() = runTest {
val (vm, detailCalls) = vmWith { GitWriteOutcome.Rejected(status = 400, message = "两个 remote无法确定目标") }
vm.load()
val callsAfterLoad = detailCalls[0]
vm.fetchUpstream()
assertEquals(
ProjectDetailViewModel.FetchPhase.Failed("两个 remote无法确定目标"),
vm.fetchPhase.value,
)
assertEquals(callsAfterLoad, detailCalls[0], "a rejected fetch must not claim fresh data")
}
@Test
fun `a rate-limited fetch says so and does not retry`() = runTest {
val (vm, _) = vmWith { GitWriteOutcome.RateLimited }
vm.fetchUpstream()
assertEquals(
ProjectDetailViewModel.FetchPhase.Failed(GitPanelCopy.FETCH_RATE_LIMITED),
vm.fetchPhase.value,
)
}
@Test
fun `a fetch failure never fails the detail load`() = runTest {
val (vm, _) = vmWith { throw RuntimeException("offline") }
vm.load()
vm.fetchUpstream()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "isolation: detail stays loaded")
}
// ── w6/G7: the per-worktree probe ────────────────────────────────────────────────────────────
@Test
fun `probing skips the current worktree and isolates a failing row`() = runTest {
val worktrees = listOf(
WorktreeInfo(path = "/repo", branch = "develop", isMain = true, isCurrent = true),
WorktreeInfo(path = "/repo/wt-a", branch = "worktree-a"),
WorktreeInfo(path = "/repo/wt-b", branch = "worktree-b"),
)
val gateway = FakeWorktreeGateway(
detail = detail.copy(worktrees = worktrees),
prResult = PrStatus(availability = PrAvailability.NO_PR),
logResult = GitLogResult(),
// wt-b is missing on purpose: its probe throws, and must cost only its own row.
worktreeStates = mapOf("/repo/wt-a" to WorktreeState(path = "/repo/wt-a", dirtyCount = 2)),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
vm.probeWorktrees()
assertEquals(listOf("/repo/wt-a", "/repo/wt-b"), gateway.worktreeStateCalls, "the current row is free")
assertEquals(setOf("/repo/wt-a"), vm.worktreeStates.value.keys)
assertEquals(2, vm.worktreeStates.value.getValue("/repo/wt-a").dirtyCount)
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "a failed probe never fails the panel")
}
}

View File

@@ -204,6 +204,8 @@ class ProjectsViewModelTest {
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
override suspend fun projectPr(path: String) = throw NotImplementedError()
override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
override suspend fun worktreeState(worktreePath: String) = throw NotImplementedError()
override suspend fun gitFetch(path: String) = 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()

View File

@@ -0,0 +1,183 @@
package wang.yaojia.webterm.wiring
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.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.viewmodels.TokenSubmission
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
import java.io.IOException
/**
* [AccessTokenGateway] — the `WEBTERM_TOKEN` half of pairing, verified against the server contract in
* `src/http/auth.ts` + `src/server.ts` (`POST /auth`: urlencoded `token` field; XHR ⇒ 204 valid / 401
* invalid / 429 over the 10-per-minute limit; the global gate answers an unauthed read with 401).
*
* The credential-hygiene assertions are as load-bearing as the status mapping: the token is a SHELL
* credential, so it must appear ONLY in the request body and never in a URL, a header, a returned
* outcome, or any `toString()` that could reach a log or a crash report.
*/
@DisplayName("AccessTokenGateway — POST /auth + 401 gate detection")
class AccessTokenGatewayTest {
private companion object {
/** A token using the full server-side charset (`[A-Za-z0-9._~+/=-]`) — `+`, `/`, `=` are the
* form-encoding traps: a raw `+` in an urlencoded body decodes to a SPACE server-side. */
const val TOKEN = "ab+cd/ef=gh-ij._~"
const val BASE = "http://10.0.0.5:3000"
}
private class RecordingTransport(
private val handler: (HttpRequest) -> HttpResponse,
) : HttpTransport {
val requests = mutableListOf<HttpRequest>()
override suspend fun send(request: HttpRequest): HttpResponse {
requests += request
return handler(request)
}
}
private class ThrowingTransport(private val error: Throwable) : HttpTransport {
override suspend fun send(request: HttpRequest): HttpResponse = throw error
}
private fun endpoint(url: String = BASE): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
private fun status(code: Int): (HttpRequest) -> HttpResponse =
{ HttpResponse(status = code, body = ByteArray(0)) }
private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport }
// ── Gate detection: is this host token-gated, or is it just not a web-terminal? ──────────────────
@Test
fun `a 401 on the unauthenticated read means the host is token-gated`() = runTest {
val transport = RecordingTransport(status(401))
assertTrue(gateway(transport).requiresAccessToken(endpoint()))
assertEquals("$BASE/live-sessions", transport.requests.single().url)
assertEquals(HttpMethod.GET, transport.requests.single().method)
// The read is Origin-free (plan §4.3: Origin only on guarded routes).
assertTrue(transport.requests.single().headers.keys.none { it.equals("Origin", ignoreCase = true) })
}
@Test
fun `a 200 host is not token-gated`() = runTest {
assertFalse(gateway(RecordingTransport(status(200))).requiresAccessToken(endpoint()))
}
@Test
fun `some other 4xx or 5xx is not read as the token gate`() = runTest {
for (code in listOf(403, 404, 418, 500, 503)) {
assertFalse(
gateway(RecordingTransport(status(code))).requiresAccessToken(endpoint()),
"HTTP $code must not be mistaken for the WEBTERM_TOKEN gate",
)
}
}
@Test
fun `an unreachable host is not reported as token-gated`() = runTest {
// "Nothing answered" must stay distinguishable from "answered 401" — never prompt for a
// credential because the host is down.
assertFalse(gateway(ThrowingTransport(IOException("connect refused"))).requiresAccessToken(endpoint()))
}
// ── POST /auth status mapping ────────────────────────────────────────────────────────────────────
@Test
fun `204 accepts the token`() = runTest {
assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN))
}
@Test
fun `401 is a wrong token, distinct from an unreachable host`() = runTest {
assertEquals(TokenSubmission.Rejected, gateway(RecordingTransport(status(401))).submit(endpoint(), TOKEN))
val unreachable = gateway(ThrowingTransport(IOException("no route"))).submit(endpoint(), TOKEN)
assertInstanceOf(TokenSubmission.Unreachable::class.java, unreachable)
}
@Test
fun `429 is surfaced as rate-limited and never auto-retried`() = runTest {
val transport = RecordingTransport(status(429))
assertEquals(TokenSubmission.RateLimited, gateway(transport).submit(endpoint(), TOKEN))
assertEquals(1, transport.requests.size, "a 429 must not be retried inside the gateway")
}
@Test
fun `an unexpected status is reported as unreachable, not as a wrong token`() = runTest {
val outcome = gateway(RecordingTransport(status(500))).submit(endpoint(), TOKEN)
assertInstanceOf(TokenSubmission.Unreachable::class.java, outcome)
assertEquals("HTTP 500", (outcome as TokenSubmission.Unreachable).reason)
}
// ── Request shape (the server contract) ──────────────────────────────────────────────────────────
@Test
fun `the token is posted as an urlencoded form field with every reserved byte escaped`() = runTest {
val transport = RecordingTransport(status(204))
gateway(transport).submit(endpoint(), TOKEN)
val request = transport.requests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth", request.url)
assertEquals(
"application/x-www-form-urlencoded",
request.headers.entries.single { it.key.equals("Content-Type", ignoreCase = true) }.value,
)
// `+` MUST NOT survive raw (Express's qs decodes it to a space) and `/`/`=` would split the field.
assertEquals("token=ab%2Bcd%2Fef%3Dgh-ij._~", request.body?.decodeToString())
}
@Test
fun `no Accept text-html header is sent, so the server answers 204 or 401 instead of redirecting`() =
runTest {
val transport = RecordingTransport(status(204))
gateway(transport).submit(endpoint(), TOKEN)
val accept = transport.requests.single().headers.entries
.firstOrNull { it.key.equals("Accept", ignoreCase = true) }?.value.orEmpty()
assertFalse(accept.contains("text/html"), "an HTML Accept makes /auth answer 302, not 204/401")
}
// ── Credential hygiene ──────────────────────────────────────────────────────────────────────────
@Test
fun `the token never reaches the URL, a header, the outcome or any toString`() = runTest {
val transport = RecordingTransport(status(401))
val outcome = gateway(transport).submit(endpoint(), TOKEN)
val request = transport.requests.single()
assertFalse(request.url.contains(TOKEN), "never put the credential in a URL (logs / Referer)")
assertFalse(request.headers.toString().contains(TOKEN))
assertFalse(outcome.toString().contains(TOKEN))
// The data class renders `body=[B@…` — the bytes are never stringified.
assertFalse(request.toString().contains(TOKEN))
}
@Test
fun `a transport failure reports only the error type, never the message or the token`() = runTest {
// An exception message is attacker/host-influenced and could echo a request; carry the type only.
val error = IOException("failed posting token=$TOKEN")
val outcome = gateway(ThrowingTransport(error)).submit(endpoint(), TOKEN)
val reason = (outcome as TokenSubmission.Unreachable).reason
assertEquals("IOException", reason)
assertFalse(outcome.toString().contains(TOKEN))
}
}

View File

@@ -0,0 +1,174 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
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.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.hostregistry.AuthCookieId
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
import wang.yaojia.webterm.hostregistry.AuthCookieStore
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME
import wang.yaojia.webterm.transport.AuthCookieJar
/**
* [AuthCookieHydrator] — the cold-start half of the `WEBTERM_TOKEN` gate: without it the durable
* `webterm_auth` cookie never reaches the live [AuthCookieJar], so a paired token-gated host would ask
* for the token again on every launch.
*
* Two properties are security-load-bearing and asserted directly:
* - **per-host isolation** — a record filed under host A must never be presented to host B;
* - **hydrate exactly once** — a second hydration could overwrite a FRESHER in-memory cookie with the
* stale persisted one (the durable write is asynchronous), silently reverting to a dead credential.
*/
@DisplayName("AuthCookieHydrator — cold-start restore of the webterm_auth cookie")
class AuthCookieHydratorTest {
private companion object {
const val HOST_A = "http://10.0.0.5:3000"
const val HOST_B = "http://10.0.0.9:3000"
const val TOKEN = "persisted-shell-credential"
const val FAR_FUTURE = 4_000_000_000_000L // ~2096
}
private fun record(
hostKey: String,
host: String,
value: String = TOKEN,
expiresAt: Long = FAR_FUTURE,
) = AuthCookieRecord(
hostKey = hostKey,
name = AUTH_COOKIE_NAME,
value = value,
domain = host,
path = "/",
expiresAtEpochMillis = expiresAt,
secure = false,
httpOnly = true,
hostOnly = true,
)
private fun hydrator(store: AuthCookieStore, jar: AuthCookieJar) =
AuthCookieHydrator(store = { store }, jar = { jar })
private fun AuthCookieJar.valuesFor(baseUrl: String): List<String> =
loadForRequest("$baseUrl/live-sessions".toHttpUrl()).map { it.value }
@Test
fun `a persisted cookie is restored onto its own host and nowhere else`() = runTest {
val store = InMemoryAuthCookieStore(
listOf(record(HOST_A, "10.0.0.5"), record(HOST_B, "10.0.0.9", value = "other-host-token")),
)
val jar = AuthCookieJar()
hydrator(store, jar).hydrateOnce()
assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A))
assertEquals(listOf("other-host-token"), jar.valuesFor(HOST_B))
}
@Test
fun `an expired record is not resurrected`() = runTest {
val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5", expiresAt = 1L)))
val jar = AuthCookieJar()
hydrator(store, jar).hydrateOnce()
assertTrue(jar.valuesFor(HOST_A).isEmpty())
}
@Test
fun `hydration happens exactly once, so a fresher in-memory cookie is never clobbered`() = runTest {
val store = CountingStore(listOf(record(HOST_A, "10.0.0.5", value = "stale")))
val jar = AuthCookieJar()
val hydrator = hydrator(store, jar)
hydrator.hydrateOnce()
assertEquals(listOf("stale"), jar.valuesFor(HOST_A))
// A live re-auth replaces the credential in memory (its durable write is async and may lag).
jar.saveFromResponse(
"$HOST_A/auth".toHttpUrl(),
listOf(
okhttp3.Cookie.Builder()
.name(AUTH_COOKIE_NAME)
.value("fresh")
.expiresAt(FAR_FUTURE)
.path("/")
.hostOnlyDomain("10.0.0.5")
.build(),
),
)
hydrator.hydrateOnce()
assertEquals(1, store.loads, "a second warm-up must not re-read the store")
assertEquals(listOf("fresh"), jar.valuesFor(HOST_A))
}
@Test
fun `a failing store never breaks warm-up and is retried on the next one`() = runTest {
val store = FailingThenWorkingStore(listOf(record(HOST_A, "10.0.0.5")))
val jar = AuthCookieJar()
val hydrator = hydrator(store, jar)
hydrator.hydrateOnce() // throws inside → swallowed
assertTrue(jar.valuesFor(HOST_A).isEmpty())
hydrator.hydrateOnce() // the failure did not consume the one-shot
assertEquals(listOf(TOKEN), jar.valuesFor(HOST_A))
}
@Test
fun `the restored credential is not exposed by the jar or record toString`() = runTest {
val store = InMemoryAuthCookieStore(listOf(record(HOST_A, "10.0.0.5")))
val jar = AuthCookieJar()
hydrator(store, jar).hydrateOnce()
assertFalse(jar.toString().contains(TOKEN))
assertFalse(store.loadAll().toString().contains(TOKEN))
}
// ── Doubles ─────────────────────────────────────────────────────────────────────────────────────
private class CountingStore(initial: List<AuthCookieRecord>) : AuthCookieStore {
private val delegate = InMemoryAuthCookieStore(initial)
var loads: Int = 0
override suspend fun loadAll(): List<AuthCookieRecord> {
loads++
return delegate.loadAll()
}
override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey)
override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record)
override suspend fun remove(id: AuthCookieId) = delegate.remove(id)
override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey)
override suspend fun replaceHost(hostKey: String, records: List<AuthCookieRecord>) =
delegate.replaceHost(hostKey, records)
}
private class FailingThenWorkingStore(initial: List<AuthCookieRecord>) : AuthCookieStore {
private val delegate = InMemoryAuthCookieStore(initial)
private var failed = false
override suspend fun loadAll(): List<AuthCookieRecord> {
if (!failed) {
failed = true
error("keystore unavailable")
}
return delegate.loadAll()
}
override suspend fun loadForHost(hostKey: String) = delegate.loadForHost(hostKey)
override suspend fun upsert(record: AuthCookieRecord) = delegate.upsert(record)
override suspend fun remove(id: AuthCookieId) = delegate.remove(id)
override suspend fun removeHost(hostKey: String) = delegate.removeHost(hostKey)
override suspend fun replaceHost(hostKey: String, records: List<AuthCookieRecord>) =
delegate.replaceHost(hostKey, records)
}
}

View File

@@ -0,0 +1,62 @@
package wang.yaojia.webterm.wiring
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.transport.AUTH_COOKIE_NAME
import wang.yaojia.webterm.transport.AuthCookieSnapshot
/**
* The `:app`-owned bridge between `:transport-okhttp`'s [AuthCookieSnapshot] (what the live cookie jar
* publishes) and `:host-registry`'s `AuthCookieRecord` (what is encrypted at rest). Neither module may
* depend on the other (`android/README.md`: dependencies only flow down), so this 2-function mapping is
* the seam — and a field dropped here is a silently broken credential, hence the field-for-field test.
*/
@DisplayName("AuthCookie snapshot ⇄ record mapping")
class AuthCookieMappingTest {
private companion object {
const val HOST_KEY = "https://star.terminal.yaojia.wang:443"
const val TOKEN = "shell-credential"
}
private fun snapshot() = AuthCookieSnapshot(
name = AUTH_COOKIE_NAME,
value = TOKEN,
domain = "star.terminal.yaojia.wang",
path = "/",
expiresAtEpochMillis = 4_000_000_000_000L,
secure = true,
httpOnly = true,
hostOnly = true,
)
@Test
fun `every field survives the round trip, with the host key stamped on the record`() {
val original = snapshot()
val record = original.toRecord(HOST_KEY)
assertEquals(HOST_KEY, record.hostKey)
assertEquals(original.name, record.name)
assertEquals(original.value, record.value)
assertEquals(original.domain, record.domain)
assertEquals(original.path, record.path)
// ABSOLUTE wall-clock expiry, never a Max-Age duration (which would restart on every restore).
assertEquals(original.expiresAtEpochMillis, record.expiresAtEpochMillis)
assertEquals(original.secure, record.secure)
assertEquals(original.httpOnly, record.httpOnly)
assertEquals(original.hostOnly, record.hostOnly)
assertEquals(original, record.toSnapshot())
}
@Test
fun `neither side renders the credential in toString`() {
val record = snapshot().toRecord(HOST_KEY)
assertFalse(record.toString().contains(TOKEN))
assertFalse(record.toSnapshot().toString().contains(TOKEN))
}
}

View File

@@ -0,0 +1,303 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
import wang.yaojia.webterm.viewmodels.PairingProber
import wang.yaojia.webterm.viewmodels.PairingUiState
import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.TokenError
import wang.yaojia.webterm.viewmodels.TokenSubmission
import wang.yaojia.webterm.wire.HostEndpoint
/**
* The `WEBTERM_TOKEN` acquisition flow in [PairingViewModel]: prompt → submit → success / wrong / 429.
*
* Lives in the `wiring` test package rather than next to `PairingViewModelTest` because the
* `app/src/test` `viewmodels` package belongs to another agent's file-ownership lane in this run; the
* subject under test is the (public) pairing state machine either way.
*
* Why the flow exists: when a host runs with `WEBTERM_TOKEN` set, `GET /live-sessions` answers **401**
* before anything else can happen (`src/server.ts` `authGate`). The frozen probe taxonomy can only
* report that as `HttpOkButNotWebTerminal` ("wrong port?"), which is actively misleading and leaves a
* token-gated host impossible to pair. So a failed probe asks the prober whether the host is gated, and
* the answer routes to a token prompt instead of the dead-end failure card.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@DisplayName("Pairing — WEBTERM_TOKEN acquisition")
class PairingTokenFlowTest {
private companion object {
const val TOKEN = "s3cret-shell-token"
const val LAN = "http://10.0.0.5:3000"
const val TUNNEL = "https://star.terminal.yaojia.wang"
}
/** Records every probe AND every token submission so "no retry / no re-probe" is directly assertable. */
private class FakeProber(
var probeResult: (HostEndpoint) -> PairingProbeResult = { PairingProbeResult.Success(it) },
var gated: Boolean = false,
var submission: (String) -> TokenSubmission = { TokenSubmission.Accepted },
) : PairingProber {
val probes = mutableListOf<HostEndpoint>()
val submitted = mutableListOf<String>()
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult {
probes += endpoint
return probeResult(endpoint)
}
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = gated
override suspend fun submitAccessToken(endpoint: HostEndpoint, token: String): TokenSubmission {
submitted += token
return submission(token)
}
}
private fun endpoint(url: String): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
private fun gatedThenOpen(prober: FakeProber): (HostEndpoint) -> PairingProbeResult = { ep ->
// Before the token: the gate 401s the reachability read, which the probe can only classify as
// "not a web-terminal". After the token is accepted the same probe succeeds.
if (prober.gated) PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
else PairingProbeResult.Success(ep)
}
private fun newVm(
prober: PairingProber,
hasCert: () -> Boolean = { false },
store: InMemoryHostStore = InMemoryHostStore(),
) = PairingViewModel(
hostStore = store,
prober = prober,
hasDeviceCert = { hasCert() },
newId = { "fixed-id" },
)
// ── Prompt ──────────────────────────────────────────────────────────────────────────────────────
@Test
fun `a token-gated host asks for the token instead of showing the wrong-port failure`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
val state = vm.uiState.value
assertInstanceOf(PairingUiState.TokenRequired::class.java, state)
assertEquals(endpoint(LAN), (state as PairingUiState.TokenRequired).endpoint)
assertEquals(null, state.error, "the first prompt is not an error")
}
@Test
fun `an ungated host still shows the wrong-port failure`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(
probeResult = { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) },
gated = false,
)
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
}
@Test
fun `a non-gate failure is never re-interpreted as a token prompt`() = runTest(UnconfinedTestDispatcher()) {
// `gated` is true here, but the failure is a timeout — only the ambiguous
// HttpOkButNotWebTerminal case is allowed to become a token prompt.
val prober = FakeProber(probeResult = { PairingProbeResult.Failure(PairingError.Timeout) }, gated = true)
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
val state = vm.uiState.value
assertInstanceOf(PairingUiState.Failed::class.java, state)
assertEquals(PairingError.Timeout, (state as PairingUiState.Failed).error)
}
// ── Submit ──────────────────────────────────────────────────────────────────────────────────────
@Test
fun `submitting the right token continues pairing and persists the host`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
// Accepting the token means the cookie jar now holds the credential → the gate opens.
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
val store = InMemoryHostStore()
val vm = newVm(prober, store = store)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken(TOKEN)
assertEquals(listOf(TOKEN), prober.submitted)
assertEquals(2, prober.probes.size, "an accepted token re-runs the probe")
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
assertEquals(LAN, store.loadAll().single().endpoint.baseUrl)
}
@Test
fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { TokenSubmission.Rejected }
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken("wrong")
val state = vm.uiState.value
assertInstanceOf(PairingUiState.TokenRequired::class.java, state)
assertEquals(TokenError.INVALID, (state as PairingUiState.TokenRequired).error)
assertEquals(1, prober.probes.size, "a rejected token must not re-probe")
}
@Test
fun `a 429 is surfaced honestly and never auto-retried`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { TokenSubmission.RateLimited }
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken(TOKEN)
assertEquals(TokenError.RATE_LIMITED, (vm.uiState.value as PairingUiState.TokenRequired).error)
assertEquals(1, prober.submitted.size, "rate-limited must not resubmit by itself")
assertEquals(1, prober.probes.size)
}
@Test
fun `an unreachable host during submit is distinguishable from a wrong token`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { TokenSubmission.Unreachable("IOException") }
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken(TOKEN)
assertEquals(TokenError.UNREACHABLE, (vm.uiState.value as PairingUiState.TokenRequired).error)
}
@Test
fun `a prober with no token support reports the capability honestly`() =
runTest(UnconfinedTestDispatcher()) {
// The interface defaults exist so a probe-only seam (a test double) still compiles; they must
// surface as an explicit "cannot submit", never as a silent success.
val probeOnly = object : PairingProber {
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
val vm = newVm(probeOnly)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
// Not gated by default → the plain failure card, exactly as before this feature existed.
assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
}
@Test
fun `a blank token is never submitted`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
vm.submitAccessToken(" ")
assertTrue(prober.submitted.isEmpty())
assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value)
}
@Test
fun `a token submitted outside the prompt is a no-op`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber()
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN) // still on the confirm gate — nothing has asked for a token
vm.submitAccessToken(TOKEN)
assertTrue(prober.submitted.isEmpty())
assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
}
// ── The token must not outlive the submit ────────────────────────────────────────────────────────
@Test
fun `the token is never held in the UI state or any of its toStrings`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { TokenSubmission.Rejected }
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry(LAN)
vm.confirm()
val promptState = vm.uiState.value
vm.submitAccessToken(TOKEN)
val afterState = vm.uiState.value
assertFalse(promptState.toString().contains(TOKEN))
assertFalse(afterState.toString().contains(TOKEN))
}
// ── The token path cannot bypass the tunnel cert-gate (RULE 4) ──────────────────────────────────
@Test
fun `an accepted token still funnels through the device-cert gate`() =
runTest(UnconfinedTestDispatcher()) {
var certInstalled = true
val prober = FakeProber(gated = true)
prober.probeResult = gatedThenOpen(prober)
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
val vm = newVm(prober, hasCert = { certInstalled })
vm.bind(backgroundScope)
vm.onManualEntry(TUNNEL)
vm.confirm()
assertInstanceOf(PairingUiState.TokenRequired::class.java, vm.uiState.value)
// The cert is removed while the token prompt is open; the post-token probe must re-refuse.
certInstalled = false
vm.submitAccessToken(TOKEN)
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
assertEquals(1, prober.probes.size, "no network probe without a device cert")
}
}

View File

@@ -10,12 +10,18 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNotSame
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
@@ -59,7 +65,7 @@ class ThumbnailPipelineTest {
}
/** Hands back a fresh mock bitmap per raster (so identity distinguishes renders) + a fixed placeholder. */
private class FakeRasterizer : ThumbnailRasterizer {
private class FakeRasterizer(private val placeholderFailure: Throwable? = null) : ThumbnailRasterizer {
var rasterizeCount = 0
val placeholderBitmap: Bitmap = mockk()
@@ -68,7 +74,10 @@ class ThumbnailPipelineTest {
return mockk()
}
override fun placeholder(): Bitmap = placeholderBitmap
override fun placeholder(): Bitmap {
placeholderFailure?.let { throw it }
return placeholderBitmap
}
}
/** Map-backed cache seam — keeps LRU KEYING testable without touching the stubbed android.util.LruCache. */
@@ -78,6 +87,10 @@ class ThumbnailPipelineTest {
override fun put(key: ThumbnailKey, bitmap: Bitmap) {
map[key] = bitmap
}
override fun retainOnly(keys: Set<ThumbnailKey>) {
map.keys.retainAll(keys)
}
}
private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo(
@@ -201,6 +214,158 @@ class ThumbnailPipelineTest {
assertEquals(1, source.fetchStarts)
}
/**
* A rasterizer failure that ALSO breaks the placeholder (a `Bitmap` OOM is the realistic case) must
* surface as `null`, NOT as an exception — the row's `LaunchedEffect` collects this directly, so a
* throw would take down the composition. It must also leave no poisoned in-flight entry: the next
* request retries instead of re-awaiting a permanently-failed `Deferred`.
*/
@Test
fun `an unrenderable thumbnail returns null and is retried rather than poisoning the key`() = runTest {
val source = FakePreviewSource(failure = RuntimeException("boom"))
val raster = FakeRasterizer(placeholderFailure = OutOfMemoryError("bitmap"))
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
val s = session(UUID.randomUUID(), lastOutputAt = 9L)
val first = async { pipeline.thumbnail(s) }
advanceUntilIdle()
assertNull(first.await())
assertEquals(1, source.fetchStarts)
// Nothing cacheable ⇒ the key is free again (no stale in-flight Deferred to re-await forever).
val second = async { pipeline.thumbnail(s) }
advanceUntilIdle()
assertNull(second.await())
assertEquals(2, source.fetchStarts)
}
/**
* The render lives in the PIPELINE's scope, not the caller's: a card scrolled off-screen mid-render
* (its collector cancelled) must not cancel the shared render another card is awaiting.
*/
@Test
fun `a cancelled caller does not cancel the shared render`() = runTest {
val gate = CompletableDeferred<Unit>()
val source = FakePreviewSource(block = gate)
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
val s = session(UUID.randomUUID(), lastOutputAt = 3L)
val scrolledAway = launch { pipeline.thumbnail(s) }
advanceUntilIdle()
scrolledAway.cancel()
advanceUntilIdle()
val stillVisible = async { pipeline.thumbnail(s) }
advanceUntilIdle()
gate.complete(Unit)
advanceUntilIdle()
assertNotNull(stillVisible.await())
assertEquals(1, source.fetchStarts) // the first render survived the cancelled caller
assertEquals(1, raster.rasterizeCount)
}
/**
* Bitmaps must not be retained for sessions that are gone (killed/exited) or for superseded
* `lastOutputAt` keys: [ThumbnailPipeline.retainOnly] prunes everything outside the current live list,
* so a still-live session keeps its cache hit while a dead one's bitmap is released.
*/
@Test
fun `retainOnly releases bitmaps for sessions no longer live`() = runTest {
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = FakePreviewSource(),
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
val live = session(UUID.randomUUID(), lastOutputAt = 1L)
val dead = session(UUID.randomUUID(), lastOutputAt = 1L)
val warm = launch {
pipeline.thumbnail(live)
pipeline.thumbnail(dead)
}
advanceUntilIdle()
assertTrue(warm.isCompleted)
assertEquals(2, raster.rasterizeCount)
pipeline.retainOnly(listOf(live))
// The live session is still a cache hit …
val hit = async { pipeline.thumbnail(live) }
advanceUntilIdle()
hit.await()
assertEquals(2, raster.rasterizeCount)
// … the dead one's bitmap was released, so asking again re-renders (it is no longer retained).
val miss = async { pipeline.thumbnail(dead) }
advanceUntilIdle()
miss.await()
assertEquals(3, raster.rasterizeCount)
}
@Test
fun `raster budget downscales an oversized terminal raster and leaves small ones alone`() {
// A 161x50 session at 12px monospace rasterises to ~1127x700 = 3.1 MiB ARGB_8888 — three of those
// blow an 8 MiB cache. The budget scales it to the card's resolution, preserving aspect ratio.
val scaled = ThumbnailBudget.scaledSize(width = 1127, height = 700, maxWidth = 480)
assertNotNull(scaled)
assertEquals(480, scaled!!.width)
assertEquals(298, scaled.height) // 700 * 480 / 1127, floored
// Already inside the budget → no scale step at all (null = draw as rendered).
assertNull(ThumbnailBudget.scaledSize(width = 320, height = 200, maxWidth = 480))
// A degenerate ratio never collapses to a zero-height bitmap.
val sliver = ThumbnailBudget.scaledSize(width = 4000, height = 3, maxWidth = 480)
assertNotNull(sliver)
assertEquals(1, sliver!!.height)
}
/**
* **A thumbnail fetch must NEVER attach.** Looking at the home chooser reads the ring-buffer tail over
* the read-only `GET /live-sessions/:id/preview` route ONLY — it must not open a WS, must not register
* a client, and must not touch the session's idle clock, or merely displaying the grid would keep every
* session alive forever and defeat the idle-reclaim design (plan §5.2 / §6.7).
*
* Asserted at the transport: exactly ONE request, `GET`, on the preview path, carrying **no `Origin`
* header** (which is the marker of a guarded/state-changing route — plan §4.2/§4.3).
*/
@Test
fun `the production preview source issues only the read-only preview GET and never attaches`() = runTest {
val transport = FakeHttpTransport()
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.7:3000"))
val id = UUID.fromString("11111111-2222-4333-8444-555555555555")
val previewUrl = "http://10.0.0.7:3000/live-sessions/$id/preview"
transport.queueSuccess(
method = HttpMethod.GET,
url = previewUrl,
body = """{"id":"$id","cols":80,"rows":24,"data":"hello"}""".toByteArray(Charsets.UTF_8),
)
val fetched = ApiPreviewSource { ApiClient(endpoint = endpoint, http = transport) }.fetch(id)
assertEquals("hello", fetched.data)
assertEquals(1, transport.recordedRequests.size)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.GET, request.method)
assertEquals(previewUrl, request.url)
assertNull(request.body)
assertNull(request.headers["Origin"], "the RO preview route must not carry Origin: ${request.headers}")
}
@Test
fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() {
val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL"