Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
391efc72d2 | ||
|
|
cf582be306 |
@@ -71,6 +71,9 @@ dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.turbine)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import pt.naps.maestro.data.CreateSessionRequest
|
||||
import pt.naps.maestro.data.SessionView
|
||||
import pt.naps.maestro.data.SpawnPreset
|
||||
import pt.naps.maestro.data.WorktreeSpec
|
||||
|
||||
/** Tools whose sessions can render as a structured (ACP) transcript instead
|
||||
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
|
||||
* `web/src/components/spawn/model.ts`. */
|
||||
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
|
||||
|
||||
/** Commands that just launch another program, looked through to find the
|
||||
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
|
||||
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
|
||||
|
||||
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
|
||||
|
||||
/** Program a command line would run, stripped of leading `FOO=bar`
|
||||
* assignments, known wrappers, its own arguments, and any path prefix. A
|
||||
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
|
||||
* client uses — good enough for the preset commands and the common
|
||||
* `env FOO=1 claude` shapes the spawn form actually produces. */
|
||||
internal fun resolveProgram(command: String): String {
|
||||
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
var i = 0
|
||||
while (i < words.size) {
|
||||
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
|
||||
if (i >= words.size) return ""
|
||||
val word = words[i].substringAfterLast('/').lowercase()
|
||||
if (word !in WRAPPER_PROGRAMS) return word
|
||||
i++
|
||||
while (i < words.size && words[i].startsWith("-")) i++
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
|
||||
* `web/src/components/spawn/model.ts`. */
|
||||
enum class WorktreeBranchMode { New, Existing }
|
||||
|
||||
/** The spawn form's current values, gathered at submit time. Mirrors
|
||||
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
|
||||
data class SpawnFormValues(
|
||||
val command: String,
|
||||
val cwd: String,
|
||||
val name: String,
|
||||
val structuredView: Boolean,
|
||||
val autoApprove: Boolean,
|
||||
val worktreeEnabled: Boolean,
|
||||
val worktreeBranch: String,
|
||||
val worktreeBranchMode: WorktreeBranchMode,
|
||||
)
|
||||
|
||||
sealed interface SpawnBuildResult {
|
||||
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
|
||||
data class Error(val message: String) : SpawnBuildResult
|
||||
}
|
||||
|
||||
/** Turns validated form values into a `POST /api/sessions` body, or an error
|
||||
* message when something required is missing. Mirrors `buildSpawnRequest` in
|
||||
* `web/src/components/spawn/model.ts`. */
|
||||
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
|
||||
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
|
||||
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
|
||||
|
||||
val name = values.name.trim()
|
||||
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
|
||||
|
||||
var worktree: WorktreeSpec? = null
|
||||
if (values.worktreeEnabled) {
|
||||
val branch = values.worktreeBranch.trim()
|
||||
if (baseRepo.isNullOrBlank()) {
|
||||
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
|
||||
}
|
||||
if (branch.isEmpty()) {
|
||||
return SpawnBuildResult.Error(
|
||||
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
|
||||
else "Worktree requires a branch name.",
|
||||
)
|
||||
}
|
||||
worktree = WorktreeSpec(
|
||||
base_repo = baseRepo,
|
||||
branch = branch,
|
||||
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
|
||||
)
|
||||
}
|
||||
|
||||
return SpawnBuildResult.Ok(
|
||||
CreateSessionRequest(
|
||||
cwd = values.cwd,
|
||||
command = values.command,
|
||||
metadata = metadata,
|
||||
worktree = worktree,
|
||||
view = if (values.structuredView && supportsStructuredView(values.command)) {
|
||||
SessionView.Structured
|
||||
} else {
|
||||
SessionView.Terminal
|
||||
},
|
||||
auto_approve = values.autoApprove,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
|
||||
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
|
||||
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
|
||||
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
|
||||
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
|
||||
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
|
||||
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
|
||||
SpawnPreset(id = "custom", label = "Custom…", command = ""),
|
||||
)
|
||||
@@ -0,0 +1,432 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import pt.naps.maestro.data.BranchInfo
|
||||
import pt.naps.maestro.data.Project
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.data.SpawnPreset
|
||||
|
||||
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
|
||||
* a preset chip row, a searchable project list, a raw-path autocomplete
|
||||
* fallback, a branch/worktree picker, a session name, and the
|
||||
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
|
||||
* [SpawnSheetContent] is the pure, state-driven body used by previews and
|
||||
* tests. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SpawnSheet(
|
||||
viewModel: SpawnViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onSpawned: (Session) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||
SpawnSheetContent(
|
||||
state = state,
|
||||
onSelectPreset = viewModel::selectPreset,
|
||||
onCustomCommandChange = viewModel::setCustomCommand,
|
||||
onStructuredViewChange = viewModel::setStructuredView,
|
||||
onProjectQueryChange = viewModel::setProjectQuery,
|
||||
onSelectProject = viewModel::selectProject,
|
||||
onCwdChange = viewModel::setCwd,
|
||||
onPickDirSuggestion = viewModel::pickDirSuggestion,
|
||||
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
|
||||
onBranchModeChange = viewModel::setBranchMode,
|
||||
onNewBranchChange = viewModel::setNewBranch,
|
||||
onExistingBranchChange = viewModel::setExistingBranch,
|
||||
onNameChange = viewModel::setName,
|
||||
onReroll = viewModel::rerollName,
|
||||
onDismissError = viewModel::dismissError,
|
||||
onSubmit = { viewModel.submit(onSpawned) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpawnSheetContent(
|
||||
state: SpawnUiState,
|
||||
onSelectPreset: (String) -> Unit,
|
||||
onCustomCommandChange: (String) -> Unit,
|
||||
onStructuredViewChange: (Boolean) -> Unit,
|
||||
onProjectQueryChange: (String) -> Unit,
|
||||
onSelectProject: (Project) -> Unit,
|
||||
onCwdChange: (String) -> Unit,
|
||||
onPickDirSuggestion: (String) -> Unit,
|
||||
onWorktreeEnabledChange: (Boolean) -> Unit,
|
||||
onBranchModeChange: (WorktreeBranchMode) -> Unit,
|
||||
onNewBranchChange: (String) -> Unit,
|
||||
onExistingBranchChange: (String) -> Unit,
|
||||
onNameChange: (String) -> Unit,
|
||||
onReroll: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(state.error) {
|
||||
val message = state.error ?: return@LaunchedEffect
|
||||
snackbarHostState.showSnackbar(message)
|
||||
onDismissError()
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
PresetRow(
|
||||
presets = state.presets,
|
||||
selectedPresetId = state.selectedPresetId,
|
||||
onSelectPreset = onSelectPreset,
|
||||
)
|
||||
if (state.isCustomSelected) {
|
||||
OutlinedTextField(
|
||||
value = state.customCommand,
|
||||
onValueChange = onCustomCommandChange,
|
||||
label = { Text("Command") },
|
||||
placeholder = { Text("bash -l") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
ProjectPicker(
|
||||
query = state.projectQuery,
|
||||
onQueryChange = onProjectQueryChange,
|
||||
projects = state.visibleProjects,
|
||||
loading = state.projectsLoading,
|
||||
selectedPath = state.selectedProjectPath,
|
||||
homeDir = state.homeDir,
|
||||
onSelectProject = onSelectProject,
|
||||
)
|
||||
|
||||
DirAutocompleteField(
|
||||
cwd = state.cwd,
|
||||
suggestions = state.dirSuggestions,
|
||||
onCwdChange = onCwdChange,
|
||||
onPickSuggestion = onPickDirSuggestion,
|
||||
)
|
||||
|
||||
BranchSection(
|
||||
enabled = state.worktreeEnabled,
|
||||
onEnabledChange = onWorktreeEnabledChange,
|
||||
baseRepo = state.baseRepo,
|
||||
mode = state.branchMode,
|
||||
onModeChange = onBranchModeChange,
|
||||
newBranch = state.newBranch,
|
||||
onNewBranchChange = onNewBranchChange,
|
||||
branches = state.branches,
|
||||
branchesLoading = state.branchesLoading,
|
||||
existingBranch = state.effectiveExistingBranch,
|
||||
onExistingBranchChange = onExistingBranchChange,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Session name") },
|
||||
placeholder = { Text("named after your first prompt") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(onClick = onReroll) { Text("Reroll") }
|
||||
}
|
||||
|
||||
if (supportsStructuredView(state.command)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
|
||||
Text("Structured view")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSubmit,
|
||||
enabled = !state.submitting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.submitting) {
|
||||
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Spawn")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
|
||||
Snackbar(snackbarData = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row is its own small touch target: tap anywhere in it, not just the
|
||||
* leading control, to select it. */
|
||||
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
|
||||
this.clickable(enabled = enabled, onClick = onClick)
|
||||
|
||||
@Composable
|
||||
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Harness", style = MaterialTheme.typography.labelLarge)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
for (preset in presets) {
|
||||
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
|
||||
FilterChip(
|
||||
selected = preset.id == selectedPresetId,
|
||||
onClick = { onSelectPreset(preset.id) },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectPicker(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
projects: List<Project>,
|
||||
loading: Boolean,
|
||||
selectedPath: String?,
|
||||
homeDir: String,
|
||||
onSelectProject: (Project) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Project", style = MaterialTheme.typography.labelLarge)
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Filter projects…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
when {
|
||||
loading -> Text(
|
||||
"Loading projects…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
projects.isEmpty() -> Text(
|
||||
"No projects match.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
|
||||
items(projects, key = { it.path }) { project ->
|
||||
ProjectRow(
|
||||
project = project,
|
||||
active = project.path == selectedPath,
|
||||
homeDir = homeDir,
|
||||
onClick = { onSelectProject(project) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
|
||||
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(background)
|
||||
.clickableRow(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
abbreviateHome(parentDir(project.path), homeDir),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun abbreviateHome(path: String, homeDir: String): String =
|
||||
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
|
||||
|
||||
@Composable
|
||||
private fun DirAutocompleteField(
|
||||
cwd: String,
|
||||
suggestions: List<String>,
|
||||
onCwdChange: (String) -> Unit,
|
||||
onPickSuggestion: (String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = cwd,
|
||||
onValueChange = onCwdChange,
|
||||
label = { Text("Or type a directory") },
|
||||
placeholder = { Text("/home/user/project") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (suggestions.isNotEmpty()) {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
|
||||
items(suggestions) { entry ->
|
||||
Text(
|
||||
text = "$entry/",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickableRow { onPickSuggestion(entry) }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BranchSection(
|
||||
enabled: Boolean,
|
||||
onEnabledChange: (Boolean) -> Unit,
|
||||
baseRepo: String?,
|
||||
mode: WorktreeBranchMode,
|
||||
onModeChange: (WorktreeBranchMode) -> Unit,
|
||||
newBranch: String,
|
||||
onNewBranchChange: (String) -> Unit,
|
||||
branches: List<BranchInfo>,
|
||||
branchesLoading: Boolean,
|
||||
existingBranch: String,
|
||||
onExistingBranchChange: (String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
|
||||
Text("Create git worktree")
|
||||
}
|
||||
if (!enabled) return@Column
|
||||
|
||||
if (baseRepo == null) {
|
||||
Text(
|
||||
"Choose a directory inside a git repository first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
|
||||
Text("New branch")
|
||||
Spacer(Modifier.width(16.dp))
|
||||
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
|
||||
Text("Existing branch")
|
||||
}
|
||||
|
||||
if (mode == WorktreeBranchMode.New) {
|
||||
OutlinedTextField(
|
||||
value = newBranch,
|
||||
onValueChange = onNewBranchChange,
|
||||
label = { Text("New branch name") },
|
||||
placeholder = { Text("feat/foo") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
branchesLoading -> Text(
|
||||
"Loading branches…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
branches.isEmpty() -> Text(
|
||||
"No local branches found.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
|
||||
items(branches, key = { it.name }) { branch ->
|
||||
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = branch.name == existingBranch,
|
||||
onClick = { onExistingBranchChange(branch.name) },
|
||||
enabled = held == null,
|
||||
)
|
||||
Column {
|
||||
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
|
||||
if (held != null) {
|
||||
Text(
|
||||
"held by $held",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import pt.naps.maestro.api.MaestroApiException
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.data.BranchInfo
|
||||
import pt.naps.maestro.data.Project
|
||||
import pt.naps.maestro.data.Session
|
||||
import pt.naps.maestro.data.SpawnPreset
|
||||
|
||||
/** Quiet period after the operator types a raw path before `/api/dirs` and
|
||||
* `/api/gitroot` fire (#269). */
|
||||
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
|
||||
|
||||
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
|
||||
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
|
||||
* `parentDir` in `web/src/paths.ts`. */
|
||||
internal fun parentDir(path: String): String {
|
||||
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
|
||||
val slash = trimmed.lastIndexOf('/')
|
||||
return when {
|
||||
slash < 0 -> ""
|
||||
slash == 0 -> "/"
|
||||
else -> trimmed.substring(0, slash)
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits a path into its directory and trailing (possibly partial) segment.
|
||||
* Mirrors `splitPath` in `web/src/paths.ts`. */
|
||||
internal fun splitPath(value: String): Pair<String, String> {
|
||||
val slash = value.lastIndexOf('/')
|
||||
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
|
||||
}
|
||||
|
||||
data class SpawnUiState(
|
||||
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
|
||||
val selectedPresetId: String = "",
|
||||
val customCommand: String = "",
|
||||
val commandTouched: Boolean = false,
|
||||
val structuredView: Boolean = false,
|
||||
val structuredViewTouched: Boolean = false,
|
||||
val homeDir: String = "",
|
||||
val projects: List<Project> = emptyList(),
|
||||
val projectsLoading: Boolean = true,
|
||||
val projectQuery: String = "",
|
||||
val selectedProjectPath: String? = null,
|
||||
val cwd: String = "",
|
||||
val dirSuggestions: List<String> = emptyList(),
|
||||
val baseRepo: String? = null,
|
||||
val worktreeEnabled: Boolean = false,
|
||||
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
|
||||
val newBranch: String = "",
|
||||
val existingBranch: String = "",
|
||||
val branches: List<BranchInfo> = emptyList(),
|
||||
val branchesLoading: Boolean = false,
|
||||
val name: String = "",
|
||||
val submitting: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
|
||||
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
|
||||
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
|
||||
|
||||
val visibleProjects: List<Project>
|
||||
get() {
|
||||
val query = projectQuery.trim().lowercase()
|
||||
val filtered = if (query.isEmpty()) {
|
||||
projects
|
||||
} else {
|
||||
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
|
||||
}
|
||||
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
|
||||
}
|
||||
|
||||
val effectiveExistingBranch: String
|
||||
get() = existingBranch.ifEmpty {
|
||||
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
|
||||
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
|
||||
* for a short-lived form, and this keeps the class trivially testable with a
|
||||
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
|
||||
*/
|
||||
class SpawnViewModel(
|
||||
private val client: MaestroClient,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
private val _state = MutableStateFlow(SpawnUiState())
|
||||
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
|
||||
|
||||
private var dirJob: Job? = null
|
||||
private var branchesJob: Job? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val info = runCatching { client.getDaemonInfo() }.getOrNull()
|
||||
_state.update { s ->
|
||||
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
|
||||
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
|
||||
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
|
||||
} else {
|
||||
s.selectedPresetId
|
||||
}
|
||||
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
|
||||
.withSyncedStructuredView()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
|
||||
_state.update { it.copy(projects = projects, projectsLoading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPreset(id: String) {
|
||||
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
|
||||
}
|
||||
|
||||
fun setCustomCommand(text: String) {
|
||||
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
|
||||
}
|
||||
|
||||
fun setStructuredView(enabled: Boolean) {
|
||||
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
|
||||
}
|
||||
|
||||
/** Re-derives the structured-view checkbox from the command this state
|
||||
* carries, unless the operator has already ticked/unticked it by hand. */
|
||||
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
|
||||
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
|
||||
|
||||
fun setProjectQuery(query: String) {
|
||||
_state.update { it.copy(projectQuery = query) }
|
||||
}
|
||||
|
||||
fun selectProject(project: Project) {
|
||||
dirJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedProjectPath = project.path,
|
||||
cwd = project.path,
|
||||
dirSuggestions = emptyList(),
|
||||
baseRepo = project.base_repo ?: project.path,
|
||||
branches = emptyList(),
|
||||
existingBranch = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The operator typed a path by hand: clears any project pick, and
|
||||
* schedules a debounced refresh of both the directory-suggestion
|
||||
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
|
||||
fun setCwd(raw: String) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
cwd = raw,
|
||||
selectedProjectPath = null,
|
||||
baseRepo = null,
|
||||
branches = emptyList(),
|
||||
existingBranch = "",
|
||||
)
|
||||
}
|
||||
dirJob?.cancel()
|
||||
dirJob = scope.launch {
|
||||
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
|
||||
refreshDirSuggestions(raw)
|
||||
refreshBaseRepo(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
|
||||
* drill-in gesture the web `DirAutocomplete` uses. */
|
||||
fun pickDirSuggestion(entry: String) {
|
||||
val (dir, _) = splitPath(_state.value.cwd)
|
||||
setCwd("$dir$entry/")
|
||||
}
|
||||
|
||||
private suspend fun refreshDirSuggestions(raw: String) {
|
||||
val (dir, prefix) = splitPath(raw)
|
||||
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
||||
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
|
||||
_state.update { it.copy(dirSuggestions = filtered) }
|
||||
}
|
||||
|
||||
private suspend fun refreshBaseRepo(raw: String) {
|
||||
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
|
||||
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||
_state.update { it.copy(baseRepo = root) }
|
||||
}
|
||||
|
||||
fun setWorktreeEnabled(enabled: Boolean) {
|
||||
_state.update { it.copy(worktreeEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setBranchMode(mode: WorktreeBranchMode) {
|
||||
_state.update { it.copy(branchMode = mode) }
|
||||
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
|
||||
}
|
||||
|
||||
fun setNewBranch(text: String) {
|
||||
_state.update { it.copy(newBranch = text) }
|
||||
}
|
||||
|
||||
fun setExistingBranch(text: String) {
|
||||
_state.update { it.copy(existingBranch = text) }
|
||||
}
|
||||
|
||||
private fun loadBranchesIfNeeded() {
|
||||
val cwd = _state.value.cwd
|
||||
if (cwd.isEmpty() || _state.value.branchesLoading) return
|
||||
branchesJob?.cancel()
|
||||
_state.update { it.copy(branchesLoading = true) }
|
||||
branchesJob = scope.launch {
|
||||
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
|
||||
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
|
||||
_state.update { it.copy(branches = branches, branchesLoading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setName(text: String) {
|
||||
_state.update { it.copy(name = text) }
|
||||
}
|
||||
|
||||
/** `GET /api/names?count=1`: replaces the session-name field with a
|
||||
* freshly generated handle. */
|
||||
fun rerollName() {
|
||||
scope.launch {
|
||||
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
|
||||
if (handle != null) _state.update { it.copy(name = handle) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
|
||||
* session. Any failure — validation or API — lands in
|
||||
* [SpawnUiState.error] instead. */
|
||||
fun submit(onSuccess: (Session) -> Unit) {
|
||||
val s = _state.value
|
||||
val values = SpawnFormValues(
|
||||
command = s.command,
|
||||
cwd = s.cwd,
|
||||
name = s.name,
|
||||
structuredView = s.structuredView,
|
||||
autoApprove = s.activePreset?.auto_approve ?: false,
|
||||
worktreeEnabled = s.worktreeEnabled,
|
||||
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
|
||||
worktreeBranchMode = s.branchMode,
|
||||
)
|
||||
when (val built = buildSpawnRequest(values, s.baseRepo)) {
|
||||
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
|
||||
is SpawnBuildResult.Ok -> {
|
||||
_state.update { it.copy(submitting = true, error = null) }
|
||||
scope.launch {
|
||||
try {
|
||||
val session = client.createSession(built.request)
|
||||
_state.update { it.copy(submitting = false) }
|
||||
onSuccess(session)
|
||||
} catch (e: MaestroApiException) {
|
||||
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
_state.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import pt.naps.maestro.data.SessionView
|
||||
|
||||
private val base = SpawnFormValues(
|
||||
command = "claude",
|
||||
cwd = "/home/user/project",
|
||||
name = "amber-fox",
|
||||
structuredView = false,
|
||||
autoApprove = false,
|
||||
worktreeEnabled = false,
|
||||
worktreeBranch = "",
|
||||
worktreeBranchMode = WorktreeBranchMode.New,
|
||||
)
|
||||
|
||||
class SpawnModelTest {
|
||||
@Test
|
||||
fun `builds a plain request with the picked cwd and command`() {
|
||||
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
|
||||
assertEquals("/home/user/project", result.request.cwd)
|
||||
assertEquals("claude", result.request.command)
|
||||
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
|
||||
assertEquals(SessionView.Terminal, result.request.view)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omits metadata name when the name is blank`() {
|
||||
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
|
||||
assertTrue(result.request.metadata.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requests structured view for resolved claude and codex commands`() {
|
||||
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
|
||||
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
|
||||
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not request structured view for unsupported commands`() {
|
||||
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
|
||||
assertEquals(SessionView.Terminal, result.request.view)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attaches a trimmed branch when worktree is enabled`() {
|
||||
val result = buildSpawnRequest(
|
||||
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
|
||||
"/home/user/project",
|
||||
) as SpawnBuildResult.Ok
|
||||
assertEquals("/home/user/project", result.request.worktree?.base_repo)
|
||||
assertEquals("feat/foo", result.request.worktree?.branch)
|
||||
assertTrue(result.request.worktree?.create_branch == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checks out an existing branch when the mode is existing`() {
|
||||
val result = buildSpawnRequest(
|
||||
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||
"/home/user/project",
|
||||
) as SpawnBuildResult.Ok
|
||||
assertEquals(false, result.request.worktree?.create_branch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects an existing-mode worktree with no branch picked`() {
|
||||
val result = buildSpawnRequest(
|
||||
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||
"/repo",
|
||||
)
|
||||
assertTrue(result is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a missing working directory`() {
|
||||
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a missing command`() {
|
||||
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a worktree without an inferred base repo`() {
|
||||
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
|
||||
assertTrue(result is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects a worktree without a branch name`() {
|
||||
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
|
||||
assertTrue(result is SpawnBuildResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveProgram strips env assignments and paths`() {
|
||||
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
|
||||
assertEquals("claude", resolveProgram("claude"))
|
||||
assertEquals("zsh", resolveProgram("zsh -l"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package pt.naps.maestro.ui.spawn
|
||||
|
||||
import app.cash.turbine.test
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import pt.naps.maestro.api.MaestroClient
|
||||
import pt.naps.maestro.data.Project
|
||||
|
||||
/** Routes each enqueued response by request path rather than arrival order —
|
||||
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
|
||||
* so a plain FIFO queue can hand either endpoint the other's response. */
|
||||
private class RoutedDispatcher : Dispatcher() {
|
||||
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
|
||||
|
||||
fun enqueue(path: String, response: MockResponse) {
|
||||
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
|
||||
}
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty().substringBefore('?')
|
||||
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
|
||||
class SpawnViewModelTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var dispatcher: RoutedDispatcher
|
||||
private lateinit var client: MaestroClient
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
dispatcher = RoutedDispatcher()
|
||||
server = MockWebServer()
|
||||
server.dispatcher = dispatcher
|
||||
server.start()
|
||||
client = MaestroClient(server.url("/").toString())
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun jsonResponse(body: String) =
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
|
||||
|
||||
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
|
||||
* every test needs before it can observe anything past init. */
|
||||
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
|
||||
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
|
||||
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||
}
|
||||
|
||||
private fun newViewModel() = SpawnViewModel(client, scope)
|
||||
|
||||
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
|
||||
var result: SpawnUiState? = null
|
||||
state.test {
|
||||
var item = awaitItem()
|
||||
while (!predicate(item)) item = awaitItem()
|
||||
result = item
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
return result ?: error("state predicate never satisfied")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
|
||||
enqueueInit(
|
||||
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
|
||||
{"id":"claude","label":"Claude","command":"claude"},
|
||||
{"id":"custom","label":"Custom…","command":""}
|
||||
],"instance":"i"}""",
|
||||
)
|
||||
|
||||
val vm = newViewModel()
|
||||
val settled = vm.awaitState { it.selectedPresetId == "claude" }
|
||||
|
||||
assertEquals("claude", settled.command)
|
||||
assertTrue("structured view should auto-tick for claude", settled.structuredView)
|
||||
assertEquals("/home/x", settled.homeDir)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
|
||||
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
|
||||
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||
|
||||
val vm = newViewModel()
|
||||
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
|
||||
|
||||
assertEquals(FALLBACK_PRESETS, settled.presets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting a preset updates the command and structured view`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
|
||||
|
||||
vm.selectPreset("zsh")
|
||||
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
|
||||
assertEquals("zsh", afterZsh.command)
|
||||
assertTrue("zsh has no structured view", !afterZsh.structuredView)
|
||||
|
||||
vm.selectPreset("claude")
|
||||
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
|
||||
assertTrue(afterClaude.structuredView)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual structured view toggle survives a later preset change`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId == "claude" }
|
||||
|
||||
vm.setStructuredView(false)
|
||||
vm.selectPreset("codex")
|
||||
val after = vm.awaitState { it.selectedPresetId == "codex" }
|
||||
assertTrue("operator's untick should stick", !after.structuredView)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting a project fills cwd and base repo`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { !it.projectsLoading }
|
||||
|
||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||
val after = vm.awaitState { it.cwd == "/repo" }
|
||||
assertEquals("/repo", after.baseRepo)
|
||||
assertEquals("/repo", after.selectedProjectPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { !it.projectsLoading }
|
||||
|
||||
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
|
||||
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
|
||||
|
||||
vm.setCwd("/home/user/pro")
|
||||
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
|
||||
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
|
||||
val withRepo = vm.awaitState { it.baseRepo != null }
|
||||
assertEquals("/home/user/proj-a", withRepo.baseRepo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reroll fills the name field from the daemon`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { !it.projectsLoading }
|
||||
|
||||
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
|
||||
vm.rerollName()
|
||||
val after = vm.awaitState { it.name == "amber-fox" }
|
||||
assertEquals("amber-fox", after.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit rejects a missing working directory without a network call`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId == "claude" }
|
||||
|
||||
var succeeded = false
|
||||
vm.submit { succeeded = true }
|
||||
val after = vm.awaitState { it.error != null }
|
||||
assertNotNull(after.error)
|
||||
assertTrue(!succeeded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit posts the built request and reports the new session`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId == "claude" }
|
||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||
vm.awaitState { it.cwd == "/repo" }
|
||||
|
||||
dispatcher.enqueue(
|
||||
"/api/sessions",
|
||||
jsonResponse(
|
||||
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
|
||||
),
|
||||
)
|
||||
var result: pt.naps.maestro.data.Session? = null
|
||||
vm.submit { result = it }
|
||||
val after = vm.awaitState { !it.submitting && it.error == null }
|
||||
assertNull(after.error)
|
||||
assertEquals("s1", result?.id)
|
||||
|
||||
var request = server.takeRequest()
|
||||
while (request.method != "POST") request = server.takeRequest()
|
||||
assertEquals("/api/sessions", request.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
|
||||
enqueueInit()
|
||||
val vm = newViewModel()
|
||||
vm.awaitState { it.selectedPresetId == "claude" }
|
||||
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||
vm.awaitState { it.cwd == "/repo" }
|
||||
|
||||
dispatcher.enqueue(
|
||||
"/api/sessions",
|
||||
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
|
||||
)
|
||||
vm.submit {}
|
||||
val after = vm.awaitState { it.error != null }
|
||||
assertTrue(after.error!!.contains("cwd does not exist"))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ navigationCompose = "2.8.2"
|
||||
okhttp = "4.12.0"
|
||||
kotlinxSerializationJson = "1.7.3"
|
||||
kotlinxCoroutines = "1.9.0"
|
||||
turbine = "1.1.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -27,6 +28,9 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
||||
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
||||
|
||||
Reference in New Issue
Block a user