Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3403224dec | ||
|
|
a9c3cfe6d0 | ||
|
|
3ef1decbdd |
@@ -8,25 +8,37 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import pt.naps.maestro.connection.DaemonLink
|
import pt.naps.maestro.connection.DaemonLink
|
||||||
import pt.naps.maestro.connection.DaemonPrefs
|
import pt.naps.maestro.connection.DaemonPrefs
|
||||||
import pt.naps.maestro.connection.LinkState
|
import pt.naps.maestro.connection.LinkState
|
||||||
|
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||||
import pt.naps.maestro.ui.theme.MaestroTheme
|
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private lateinit var daemonLink: DaemonLink
|
private lateinit var daemonLink: DaemonLink
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -38,8 +50,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
MaestroTheme {
|
MaestroTheme {
|
||||||
val linkState by daemonLink.state.collectAsState()
|
val linkState by daemonLink.state.collectAsState()
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||||
|
SidebarContent(
|
||||||
|
catalogStore = daemonLink.catalog,
|
||||||
|
onOpenSession = { scope.launch { drawerState.close() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Maestro") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||||
|
Text("☰")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.NameSource
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
|
||||||
|
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
|
||||||
|
|
||||||
|
/** Payloads predating #102 default `activity_since` to the Unix epoch;
|
||||||
|
* anything implausibly old is "never observed" rather than a huge duration. */
|
||||||
|
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
|
||||||
|
|
||||||
|
/** Milliseconds held in the current activity state, or `null` when `since` is
|
||||||
|
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
|
||||||
|
fun activityHeldMs(since: String?, nowMs: Long): Long? {
|
||||||
|
if (since.isNullOrEmpty()) return null
|
||||||
|
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
|
||||||
|
if (t < PLAUSIBLE_EPOCH_MS) return null
|
||||||
|
return (nowMs - t).coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
|
||||||
|
* everywhere so the counter never shows a duration the state hasn't reached. */
|
||||||
|
fun activityDurationLabel(heldMs: Long): String {
|
||||||
|
val seconds = heldMs / 1000
|
||||||
|
if (seconds < 60) return "${seconds}s"
|
||||||
|
val minutes = seconds / 60
|
||||||
|
if (minutes < 60) return "${minutes}m"
|
||||||
|
return "${minutes / 60}h"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's own name (#125), without the command fallback: `null` for a
|
||||||
|
* session that was never given one. */
|
||||||
|
fun customSessionName(session: Session): String? {
|
||||||
|
val element = session.metadata["name"] ?: return null
|
||||||
|
val primitive = element as? JsonPrimitive ?: return null
|
||||||
|
if (!primitive.isString) return null
|
||||||
|
return primitive.content.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line 1's name: `metadata.name` if present, else the command line. */
|
||||||
|
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
|
||||||
|
|
||||||
|
/** Where the session's name came from (#131), read leniently: anything
|
||||||
|
* unrecognised — including a session named before the field existed — reads
|
||||||
|
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
|
||||||
|
fun sessionNameSource(session: Session): NameSource {
|
||||||
|
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
|
||||||
|
return when (element.jsonPrimitive.content) {
|
||||||
|
"Generated" -> NameSource.Generated
|
||||||
|
"Haiku" -> NameSource.Haiku
|
||||||
|
else -> NameSource.User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether line 1 renders the name muted (#131): a handle the daemon
|
||||||
|
* generated at spawn and may still replace with one derived from the first
|
||||||
|
* prompt. */
|
||||||
|
fun isProvisionalName(session: Session): Boolean =
|
||||||
|
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
|
||||||
|
|
||||||
|
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
|
||||||
|
* picks the colour; `text` is what the card prints. */
|
||||||
|
data class BlockerStat(val kind: String, val text: String)
|
||||||
|
|
||||||
|
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||||
|
* an unforced kill; `null` while clear or not yet probed. */
|
||||||
|
fun sessionBlockers(session: Session): InTheWay? {
|
||||||
|
val w = session.blocked_work ?: return null
|
||||||
|
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
|
||||||
|
return if (blocked) w else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
|
||||||
|
* only tracked change carries no line delta falls back to the file count
|
||||||
|
* (`~2`) rather than claiming `+0 -0`. */
|
||||||
|
fun blockerStats(w: InTheWay): List<BlockerStat> {
|
||||||
|
val stats = mutableListOf<BlockerStat>()
|
||||||
|
if (w.insertions > 0 || w.deletions > 0) {
|
||||||
|
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
|
||||||
|
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
|
||||||
|
} else if (w.uncommitted_files > 0) {
|
||||||
|
stats += BlockerStat("files", "~${w.uncommitted_files}")
|
||||||
|
}
|
||||||
|
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
|
||||||
|
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spelled-out phrasing shared by the kill refusal and every client's
|
||||||
|
* tooltip, so no two describe the same tree differently. */
|
||||||
|
fun formatInTheWay(w: InTheWay): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
fun push(n: Int, unit: String) {
|
||||||
|
if (n <= 0) return
|
||||||
|
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
|
||||||
|
}
|
||||||
|
push(w.uncommitted_files, "uncommitted file")
|
||||||
|
push(w.untracked_files, "untracked file")
|
||||||
|
push(w.unpushed_commits, "unpushed commit")
|
||||||
|
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
||||||
|
private val PLAIN_WRAPPERS = setOf("sudo", "env")
|
||||||
|
private val DLX_CAPABLE = setOf("pnpm", "yarn")
|
||||||
|
|
||||||
|
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
|
||||||
|
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
|
||||||
|
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
|
||||||
|
* dlx`), then basenames what is left. A command that is nothing but wrappers
|
||||||
|
* is returned as written rather than as an empty string. */
|
||||||
|
fun programName(command: String): String {
|
||||||
|
val trimmed = command.trim()
|
||||||
|
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) return trimmed
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
|
||||||
|
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
|
||||||
|
i++
|
||||||
|
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
|
||||||
|
}
|
||||||
|
if (i >= words.size) return basename(words.last())
|
||||||
|
|
||||||
|
when {
|
||||||
|
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
|
||||||
|
words[i] == "npx" || words[i] == "bunx" -> i += 1
|
||||||
|
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
|
||||||
|
}
|
||||||
|
val program = words.getOrNull(i) ?: return basename(words.last())
|
||||||
|
return basename(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basename(path: String): String = path.substringAfterLast('/')
|
||||||
|
|
||||||
|
/** Sessions are grouped by the repository they belong to (#265): a
|
||||||
|
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
|
||||||
|
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
|
||||||
|
|
||||||
|
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
|
||||||
|
* sorted by name. */
|
||||||
|
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
|
||||||
|
|
||||||
|
/** Group sessions by [sessionGroupKey], each group sorted by name and the
|
||||||
|
* groups themselves sorted by label. */
|
||||||
|
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
|
||||||
|
sessions
|
||||||
|
.groupBy(::sessionGroupKey)
|
||||||
|
.map { (key, members) ->
|
||||||
|
SessionGroup(
|
||||||
|
key = key,
|
||||||
|
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
|
||||||
|
sessions = members.sortedBy { sessionName(it).lowercase() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.label.lowercase() }
|
||||||
@@ -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<Session>,
|
||||||
|
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<String, Boolean>() }
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
import pt.naps.maestro.data.SessionWorktree
|
||||||
|
|
||||||
|
private fun session(
|
||||||
|
command: String = "claude",
|
||||||
|
cwd: String = "/home/user/project",
|
||||||
|
metadata: Map<String, JsonPrimitive> = emptyMap(),
|
||||||
|
worktree: SessionWorktree? = null,
|
||||||
|
blockedWork: InTheWay? = null,
|
||||||
|
activitySince: String = "1970-01-01T00:00:00Z",
|
||||||
|
) = Session(
|
||||||
|
id = "s1",
|
||||||
|
cwd = cwd,
|
||||||
|
command = command,
|
||||||
|
metadata = metadata,
|
||||||
|
worktree = worktree,
|
||||||
|
status = SessionStatus.Running,
|
||||||
|
created_at = "2026-01-01T00:00:00Z",
|
||||||
|
blocked_work = blockedWork,
|
||||||
|
activity_since = activitySince,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DurationFormattingTest {
|
||||||
|
@Test
|
||||||
|
fun `floors seconds under a minute`() {
|
||||||
|
assertEquals("30s", activityDurationLabel(30_000))
|
||||||
|
assertEquals("59s", activityDurationLabel(59_999))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floors minutes under an hour`() {
|
||||||
|
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floors hours with no days unit`() {
|
||||||
|
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held ms is null for missing or implausible since`() {
|
||||||
|
assertNull(activityHeldMs(null, 1_000))
|
||||||
|
assertNull(activityHeldMs("", 1_000))
|
||||||
|
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
|
||||||
|
assertNull(activityHeldMs("not-a-timestamp", 1_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held ms is the gap to now for a plausible timestamp`() {
|
||||||
|
val since = "2026-01-01T00:00:00Z"
|
||||||
|
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
|
||||||
|
assertEquals(31_000L, activityHeldMs(since, nowMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionNameTest {
|
||||||
|
@Test
|
||||||
|
fun `falls back to the command when no name is set`() {
|
||||||
|
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uses the custom name when set`() {
|
||||||
|
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
|
||||||
|
assertEquals("api-work", sessionName(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty stored name is treated as unset`() {
|
||||||
|
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
|
||||||
|
assertNull(customSessionName(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generated name is provisional, user and haiku are not`() {
|
||||||
|
val generated = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("brave-otter"),
|
||||||
|
"name_source" to JsonPrimitive("Generated"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val user = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("api-work"),
|
||||||
|
"name_source" to JsonPrimitive("User"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val unknownSource = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("api-work"),
|
||||||
|
"name_source" to JsonPrimitive("something-new"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(isProvisionalName(generated))
|
||||||
|
assertFalse(isProvisionalName(user))
|
||||||
|
assertFalse(isProvisionalName(unknownSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockerStatsTest {
|
||||||
|
@Test
|
||||||
|
fun `renders insertions and deletions in diff notation`() {
|
||||||
|
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
|
||||||
|
val stats = blockerStats(w).associate { it.kind to it.text }
|
||||||
|
assertEquals("+124", stats["add"])
|
||||||
|
assertEquals("-18", stats["del"])
|
||||||
|
assertEquals("?3", stats["untracked"])
|
||||||
|
assertEquals("↑2", stats["unpushed"])
|
||||||
|
assertNull(stats["files"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to a file count when a tracked change carries no line delta`() {
|
||||||
|
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
|
||||||
|
val stats = blockerStats(w)
|
||||||
|
assertEquals(listOf(BlockerStat("files", "~2")), stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session blockers are null when nothing blocks a kill`() {
|
||||||
|
assertNull(sessionBlockers(session(blockedWork = null)))
|
||||||
|
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format in the way spells out zero as no unsaved work`() {
|
||||||
|
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
|
||||||
|
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProgramNameTest {
|
||||||
|
@Test
|
||||||
|
fun `drops arguments and basenames the path`() {
|
||||||
|
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
|
||||||
|
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through leading env assignments`() {
|
||||||
|
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through sudo and env wrappers with their own flags`() {
|
||||||
|
assertEquals("claude", programName("sudo claude"))
|
||||||
|
assertEquals("claude", programName("env -i FOO=bar claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through js-toolchain launchers`() {
|
||||||
|
assertEquals("claude-code", programName("npx claude-code"))
|
||||||
|
assertEquals("claude", programName("pnpm dlx claude"))
|
||||||
|
assertEquals("claude", programName("bunx claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a command that is nothing but wrappers is shown as written`() {
|
||||||
|
assertEquals("sudo", programName("sudo"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupingTest {
|
||||||
|
@Test
|
||||||
|
fun `groups by worktree base_repo when present`() {
|
||||||
|
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
|
||||||
|
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
|
||||||
|
val groups = groupSessions(listOf(a, b))
|
||||||
|
assertEquals(1, groups.size)
|
||||||
|
assertEquals("maestro", groups.single().label)
|
||||||
|
assertEquals(2, groups.single().sessions.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to cwd with no worktree`() {
|
||||||
|
val a = session(cwd = "/home/user/scratch")
|
||||||
|
val groups = groupSessions(listOf(a))
|
||||||
|
assertEquals("scratch", groups.single().label)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user