Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
391efc72d2 | ||
|
|
cf582be306 |
@@ -71,6 +71,9 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.turbine)
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
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"
|
okhttp = "4.12.0"
|
||||||
kotlinxSerializationJson = "1.7.3"
|
kotlinxSerializationJson = "1.7.3"
|
||||||
kotlinxCoroutines = "1.9.0"
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
turbine = "1.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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-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-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" }
|
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||||
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
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" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
||||||
|
|||||||
Reference in New Issue
Block a user