diff --git a/android/app/src/main/java/pt/naps/maestro/ui/sidebar/ActivityRail.kt b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/ActivityRail.kt new file mode 100644 index 0000000..dbe24c7 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/ActivityRail.kt @@ -0,0 +1,91 @@ +package pt.naps.maestro.ui.sidebar + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.InfiniteTransition +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import pt.naps.maestro.data.ActivityState + +/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in + * density-independent pixels rather than raw pixels. */ +val ACTIVITY_RAIL_WIDTH = 7.dp + +private val RAIL_ACTIVE = Color(0xFF3DDC84) +private val RAIL_IDLE = Color(0xFF9AA0A6) +private val RAIL_AWAITING = Color(0xFFF5A623) +private val RAIL_ERROR = Color(0xFFE5484D) + +/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting` + * shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so + * rendering never fails (Precedence). */ +fun activityColor(activity: ActivityState): Color = + when (activity) { + ActivityState.Active -> RAIL_ACTIVE + ActivityState.AwaitingInput -> RAIL_AWAITING + ActivityState.Error -> RAIL_ERROR + ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE + } + +/** The accessible name the rail carries, since colour is never the only + * signal (docs/SIDEBAR.md). */ +fun activityStateWord(activity: ActivityState): String = + when (activity) { + ActivityState.Active -> "Active" + ActivityState.Idle -> "Idle" + ActivityState.Starting -> "Starting" + ActivityState.AwaitingInput -> "Awaiting input" + ActivityState.Error -> "Error" + } + +/** A held bar that pulses its alpha between 1 and [minAlpha] every + * [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */ +@Composable +private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float { + val alpha by animateFloat( + initialValue = 1f, + targetValue = minAlpha, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = periodMs, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "activity-rail-alpha", + ) + return alpha +} + +/** The card's rail: a coloured bar in the gutter, full card height. It paints + * for every card, `Idle` included, and carries motion only for states that + * need attention — a hard blink for `AwaitingInput`, a fast flash for + * `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */ +@Composable +fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) { + val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color") + val infinite = rememberInfiniteTransition(label = "activity-rail-motion") + val alpha = when (activity) { + ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600) + ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220) + else -> 1f + } + Box( + modifier = modifier + .fillMaxHeight() + .width(ACTIVITY_RAIL_WIDTH) + .semantics { contentDescription = activityStateWord(activity) } + .background(color.copy(alpha = alpha)), + ) +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/sidebar/FolderNode.kt b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/FolderNode.kt new file mode 100644 index 0000000..f9dc669 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/FolderNode.kt @@ -0,0 +1,58 @@ +package pt.naps.maestro.ui.sidebar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded + * folder's cards are one row away, so it carries no highlight of its own — + * only a collapsed header is ever a keyboard-walk stop, but that distinction + * belongs to the caller driving the walk, not this composable. */ +@Composable +fun FolderNode( + label: String, + collapsed: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + depthPadding: Int = 0, + content: @Composable () -> Unit, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (collapsed) "▸" else "▾", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + AnimatedVisibility(visible = !collapsed) { + Column { content() } + } + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SessionCard.kt b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SessionCard.kt new file mode 100644 index 0000000..24acba2 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SessionCard.kt @@ -0,0 +1,242 @@ +package pt.naps.maestro.ui.sidebar + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import pt.naps.maestro.data.InTheWay +import pt.naps.maestro.data.Session +import pt.naps.maestro.data.SessionStatus + +/** docs/SIDEBAR.md: a card is always two lines behind an activity rail — + * line 1 the name, line 2 the adaptive meta line. Long-press opens a context + * menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill + * only through the callbacks the caller supplies. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SessionCard( + session: Session, + nowMs: Long, + modifier: Modifier = Modifier, + depthPadding: Int = 0, + onOpen: (Session) -> Unit = {}, + onRename: (Session, String) -> Unit = { _, _ -> }, + onKill: (Session) -> Unit = {}, + onSpawnSimilar: (Session) -> Unit = {}, +) { + var menuExpanded by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(false) } + var confirmingKill by remember { mutableStateOf(false) } + + val running = session.status == SessionStatus.Running + val name = sessionName(session) + val muted = isProvisionalName(session) + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = { onOpen(session) }, + onLongClick = { menuExpanded = true }, + ) + .padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp)) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = when { + !running -> MaterialTheme.colorScheme.onSurfaceVariant + muted -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurface + }, + style = MaterialTheme.typography.bodyMedium, + ) + MetaLine(session = session, nowMs = nowMs) + } + } + + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text("Rename") }, + onClick = { + menuExpanded = false + renaming = true + }, + ) + DropdownMenuItem( + text = { Text("Spawn similar") }, + onClick = { + menuExpanded = false + onSpawnSimilar(session) + }, + ) + DropdownMenuItem( + text = { Text("Kill") }, + onClick = { + menuExpanded = false + confirmingKill = true + }, + ) + } + + if (renaming) { + RenameDialog( + initial = customSessionName(session) ?: "", + onCommit = { + renaming = false + onRename(session, it) + }, + onCancel = { renaming = false }, + ) + } + + if (confirmingKill) { + KillConfirmDialog( + session = session, + onConfirm = { + confirmingKill = false + onKill(session) + }, + onCancel = { confirmingKill = false }, + ) + } +} + +/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]` + * — the line itself is unconditional, only its fragments are adaptive. */ +@Composable +fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) { + val heldMs = activityHeldMs(session.activity_since, nowMs) + val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) { + activityDurationLabel(heldMs) + } else { + null + } + val blockers = sessionBlockers(session) + + Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (duration != null) { + Text( + text = duration, + color = activityColor(session.activity), + style = MaterialTheme.typography.labelSmall, + ) + Spacer(Modifier.width(6.dp)) + } + if (session.activity_detail != null) { + Text( + text = session.activity_detail, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = programName(session.command), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + ) + if (blockers != null) { + Spacer(Modifier.width(6.dp)) + BlockedFragment(blockers) + } + if (session.status == SessionStatus.Dead) { + Spacer(Modifier.width(6.dp)) + Text( + text = "exited", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + ) + } + } +} + +@Composable +private fun BlockedFragment(blockers: InTheWay) { + Row(verticalAlignment = Alignment.CenterVertically) { + blockerStats(blockers).forEachIndexed { index, stat -> + if (index > 0) Spacer(Modifier.width(4.dp)) + Text( + text = stat.text, + style = MaterialTheme.typography.labelSmall, + color = when (stat.kind) { + "add" -> MaterialTheme.colorScheme.primary + "del" -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} + +@Composable +private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) { + var text by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onCancel, + title = { Text("Rename session") }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + }, + confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } }, + dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } }, + ) +} + +@Composable +private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) { + val blockers = sessionBlockers(session) + AlertDialog( + onDismissRequest = onCancel, + title = { Text("Kill “${sessionName(session)}”?") }, + text = { + Text( + if (blockers != null) { + "This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced." + } else { + "This ends the session's process. This can't be undone." + }, + ) + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } }, + dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } }, + ) +} diff --git a/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SidebarContent.kt b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SidebarContent.kt new file mode 100644 index 0000000..080bb21 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/ui/sidebar/SidebarContent.kt @@ -0,0 +1,123 @@ +package pt.naps.maestro.ui.sidebar + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import pt.naps.maestro.connection.CatalogStore +import pt.naps.maestro.data.Session +import pt.naps.maestro.data.Uuid + +/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked + * faster than that so a duration's cutover lands within one visible frame. */ +@Composable +fun rememberTickingNowMs(periodMs: Long = 5_000L): Long { + var now by remember { mutableStateOf(System.currentTimeMillis()) } + LaunchedEffect(periodMs) { + while (true) { + delay(periodMs) + now = System.currentTimeMillis() + } + } + return now +} + +/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions + * grouped into collapsible folders by repository, each rendered as a + * two-line [SessionCard] behind its [ActivityRail]. */ +@Composable +fun SidebarContent( + catalogStore: CatalogStore, + onOpenSession: (Session) -> Unit, + modifier: Modifier = Modifier, + onRenameSession: (Uuid, String) -> Unit = { _, _ -> }, + onKillSession: (Uuid) -> Unit = {}, + onSpawnSimilar: (Session) -> Unit = {}, +) { + val sessions by catalogStore.sessions.collectAsState() + SidebarContent( + sessions = sessions.values.toList(), + onOpenSession = onOpenSession, + modifier = modifier, + onRenameSession = onRenameSession, + onKillSession = onKillSession, + onSpawnSimilar = onSpawnSimilar, + ) +} + +/** Same as the [CatalogStore]-driven overload, but takes the session list + * directly — the shape used by tests and previews. */ +@Composable +fun SidebarContent( + sessions: List, + onOpenSession: (Session) -> Unit, + modifier: Modifier = Modifier, + onRenameSession: (Uuid, String) -> Unit = { _, _ -> }, + onKillSession: (Uuid) -> Unit = {}, + onSpawnSimilar: (Session) -> Unit = {}, +) { + val nowMs = rememberTickingNowMs() + val groups = remember(sessions) { groupSessions(sessions) } + val collapsed = remember { mutableStateMapOf() } + + Column(modifier = modifier.fillMaxSize()) { + Text( + text = "Sessions", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + if (groups.isEmpty()) { + Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text( + text = "No sessions", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + LazyColumn(modifier = Modifier.fillMaxSize()) { + for (group in groups) { + val isCollapsed = collapsed[group.key] == true + item(key = "folder:${group.key}") { + FolderNode( + label = group.label, + collapsed = isCollapsed, + onToggle = { collapsed[group.key] = !isCollapsed }, + depthPadding = 8, + ) { + Column { + for (session in group.sessions) { + SessionCard( + session = session, + nowMs = nowMs, + depthPadding = 16, + onOpen = onOpenSession, + onRename = { s, name -> onRenameSession(s.id, name) }, + onKill = { s -> onKillSession(s.id) }, + onSpawnSimilar = onSpawnSimilar, + ) + } + } + } + } + } + } + } +}