Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
391efc72d2 | ||
|
|
cf582be306 | ||
|
|
3403224dec | ||
|
|
a9c3cfe6d0 | ||
|
|
3ef1decbdd | ||
|
|
b22539bd5b | ||
|
|
8f0d73d89c |
@@ -58,6 +58,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
@@ -70,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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Maestro">
|
android:theme="@style/Theme.Maestro">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -8,33 +8,90 @@ 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.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.DaemonPrefs
|
||||||
|
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
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val prefs = DaemonPrefs(applicationContext)
|
||||||
|
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
|
||||||
|
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MaestroTheme {
|
MaestroTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
val linkState by daemonLink.state.collectAsState()
|
||||||
PlaceholderScreen(modifier = Modifier.padding(innerPadding))
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
daemonLink.close()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaceholderScreen(modifier: Modifier = Modifier) {
|
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -49,6 +106,9 @@ fun PlaceholderScreen(modifier: Modifier = Modifier) {
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.placeholder_message),
|
text = stringResource(id = R.string.placeholder_message),
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = "Daemon: ${linkState.name.lowercase()}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff for daemon reconnect attempts: doubles from
|
||||||
|
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
|
||||||
|
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
|
||||||
|
* change than the LAN blips the web/TUI clients ride out with a flat poll,
|
||||||
|
* so this backs off instead of hammering the daemon while nothing changed.
|
||||||
|
*/
|
||||||
|
class Backoff(
|
||||||
|
private val initialMs: Long = INITIAL_MS,
|
||||||
|
private val maxMs: Long = MAX_MS,
|
||||||
|
) {
|
||||||
|
private var currentMs = initialMs
|
||||||
|
|
||||||
|
/** Delay before the next attempt; advances the sequence for the one after. */
|
||||||
|
fun nextDelayMs(): Long {
|
||||||
|
val delay = currentMs
|
||||||
|
currentMs = (currentMs * 2).coerceAtMost(maxMs)
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to [initialMs] after a successful connection. */
|
||||||
|
fun reset() {
|
||||||
|
currentMs = initialMs
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val INITIAL_MS = 500L
|
||||||
|
const val MAX_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
|
||||||
|
* replaces the map outright; deltas mutate one entry.
|
||||||
|
*/
|
||||||
|
class CatalogStore {
|
||||||
|
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
|
||||||
|
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
|
||||||
|
|
||||||
|
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
|
||||||
|
* the caller re-fetches a full snapshot over REST and calls [replace],
|
||||||
|
* mirroring how the web client reacts to a dropped broadcast frame. */
|
||||||
|
fun apply(event: CatalogEvent): Long? =
|
||||||
|
when (event) {
|
||||||
|
is CatalogEvent.Snapshot -> {
|
||||||
|
_sessions.value = event.sessions.associateBy { it.id }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionAdded -> {
|
||||||
|
_sessions.value = _sessions.value + (event.session.id to event.session)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionUpdated -> {
|
||||||
|
_sessions.value = _sessions.value + (event.session.id to event.session)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionRemoved -> {
|
||||||
|
_sessions.value = _sessions.value - event.id
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.Lagged -> event.skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replace(sessions: List<Session>) {
|
||||||
|
_sessions.value = sessions.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_sessions.value = emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies [onAvailable] whenever a usable network appears — including the
|
||||||
|
* wifi/cellular handoff, which Android reports as one network disappearing
|
||||||
|
* and another becoming available rather than a single "changed" event.
|
||||||
|
* [DaemonLink] treats every call as "worth reconnecting for" rather than
|
||||||
|
* inspecting which network it is.
|
||||||
|
*/
|
||||||
|
class ConnectivityObserver(
|
||||||
|
context: Context,
|
||||||
|
private val onAvailable: () -> Unit,
|
||||||
|
) {
|
||||||
|
private val connectivityManager =
|
||||||
|
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) = onAvailable.invoke()
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||||
|
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
) {
|
||||||
|
onAvailable.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var registered = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (registered) return
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build()
|
||||||
|
connectivityManager.registerNetworkCallback(request, callback)
|
||||||
|
registered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!registered) return
|
||||||
|
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
|
||||||
|
registered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
/** What happened between two `/api/info` replies. */
|
||||||
|
enum class RestartKind { SAME, RESTARTED, UPGRADED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two daemon replies. Mirrors `compareIdentity` in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
|
||||||
|
* `crates/maestro-tui/src/daemon_link.rs`.
|
||||||
|
*
|
||||||
|
* The commit decides first: a different one is new server code. `instance`
|
||||||
|
* catches everything else — a restart of the same build — and is the only
|
||||||
|
* signal for a daemon built outside a git checkout, where the commit is
|
||||||
|
* always empty and an empty-vs-empty pair must never read as an upgrade.
|
||||||
|
*/
|
||||||
|
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
||||||
|
val a = before.build.commit
|
||||||
|
val b = after.build.commit
|
||||||
|
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
|
||||||
|
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
|
||||||
|
return RestartKind.RESTARTED
|
||||||
|
}
|
||||||
|
return RestartKind.SAME
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
||||||
|
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
|
||||||
|
fun formatRelease(build: BuildInfo): String {
|
||||||
|
if (build.commit.isEmpty()) return "unknown release"
|
||||||
|
val parts = mutableListOf(build.commit)
|
||||||
|
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
|
||||||
|
if (build.dirty) parts.add("(dirty)")
|
||||||
|
return parts.joinToString(" ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import pt.naps.maestro.api.EventsConnection
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.api.MaestroWebSocket
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
|
||||||
|
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
|
||||||
|
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
|
||||||
|
private const val GRACE_MS = 1_200L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the connection to one daemon for the app's whole life: liveness,
|
||||||
|
* identity, and the session catalog. A Kotlin port of the state machine in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
||||||
|
* — see `docs/RESTART.md` for the contract both mirror — with exponential
|
||||||
|
* [Backoff] standing in for their flat poll, since a phone's network changes
|
||||||
|
* are bigger and longer-lived than the LAN blips those clients ride out.
|
||||||
|
*
|
||||||
|
* Everything that can change the link — a probe landing, the events socket
|
||||||
|
* dropping, the app resuming, the network coming back, a new daemon URL —
|
||||||
|
* goes through one [signals] channel consumed by one coroutine, so state
|
||||||
|
* transitions never race each other.
|
||||||
|
*/
|
||||||
|
class DaemonLink(
|
||||||
|
context: Context,
|
||||||
|
private val prefs: DaemonPrefs,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) : DefaultLifecycleObserver {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(LinkState.CONNECTING)
|
||||||
|
val state: StateFlow<LinkState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _build = MutableStateFlow<BuildInfo?>(null)
|
||||||
|
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
|
||||||
|
|
||||||
|
private val _attempts = MutableStateFlow(0)
|
||||||
|
val attempts: StateFlow<Int> = _attempts.asStateFlow()
|
||||||
|
|
||||||
|
val catalog = CatalogStore()
|
||||||
|
|
||||||
|
private val backoff = Backoff()
|
||||||
|
private val signals = Channel<Signal>(Channel.UNLIMITED)
|
||||||
|
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
|
||||||
|
|
||||||
|
private var generation = 0
|
||||||
|
private var currentBaseUrl: String = prefs.baseUrl.value
|
||||||
|
private var client = MaestroClient(currentBaseUrl, httpClient)
|
||||||
|
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
|
||||||
|
private var identity: DaemonInfo? = null
|
||||||
|
|
||||||
|
private var eventsJob: Job? = null
|
||||||
|
private var eventsConnection: EventsConnection? = null
|
||||||
|
private var retryJob: Job? = null
|
||||||
|
private var graceJob: Job? = null
|
||||||
|
private var loopJob: Job? = null
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
private sealed class Signal {
|
||||||
|
object Start : Signal()
|
||||||
|
object Resume : Signal()
|
||||||
|
object NetworkAvailable : Signal()
|
||||||
|
object SocketDropped : Signal()
|
||||||
|
data class BaseUrlChanged(val url: String) : Signal()
|
||||||
|
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
|
||||||
|
data class Lagged(val skipped: Long) : Signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the link: subscribes to process lifecycle and connectivity, and
|
||||||
|
* kicks off the first probe. Call once. */
|
||||||
|
fun start() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
loopJob = scope.launch { runLoop() }
|
||||||
|
connectivity.start()
|
||||||
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
prefs.baseUrl.collect { url ->
|
||||||
|
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signals.trySend(Signal.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tears everything down. Only needed by tests and by an owner that is
|
||||||
|
* itself going away for good (this link is meant to outlive an Activity). */
|
||||||
|
fun close() {
|
||||||
|
connectivity.stop()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
closeEvents()
|
||||||
|
loopJob?.cancel()
|
||||||
|
signals.close()
|
||||||
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
|
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
signals.trySend(Signal.Resume)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
for (signal in signals) {
|
||||||
|
when (signal) {
|
||||||
|
is Signal.Start -> probeNow()
|
||||||
|
is Signal.Resume -> {
|
||||||
|
probeNow()
|
||||||
|
ensureEventsConnected()
|
||||||
|
}
|
||||||
|
is Signal.NetworkAvailable -> {
|
||||||
|
cancelRetry()
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
is Signal.SocketDropped -> handleSocketDropped()
|
||||||
|
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
|
||||||
|
is Signal.ProbeResult -> {
|
||||||
|
if (signal.generation == generation) handleProbeResult(signal.info)
|
||||||
|
}
|
||||||
|
is Signal.Lagged -> handleLagged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun probeNow() {
|
||||||
|
val gen = generation
|
||||||
|
val currentClient = client
|
||||||
|
scope.launch {
|
||||||
|
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
|
||||||
|
signals.trySend(Signal.ProbeResult(gen, info))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleProbeResult(info: DaemonInfo?) {
|
||||||
|
if (info == null) {
|
||||||
|
onProbeFailed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val before = identity
|
||||||
|
identity = info
|
||||||
|
_build.value = info.build
|
||||||
|
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
|
||||||
|
_state.value = LinkState.UPDATING
|
||||||
|
fullReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoff.reset()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
_attempts.value = 0
|
||||||
|
_state.value = LinkState.ONLINE
|
||||||
|
ensureEventsConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onProbeFailed() {
|
||||||
|
_attempts.value += 1
|
||||||
|
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
|
||||||
|
_state.value = LinkState.RECONNECTING
|
||||||
|
armGrace()
|
||||||
|
}
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun armGrace() {
|
||||||
|
if (graceJob?.isActive == true) return
|
||||||
|
graceJob = scope.launch {
|
||||||
|
delay(GRACE_MS)
|
||||||
|
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelGrace() {
|
||||||
|
graceJob?.cancel()
|
||||||
|
graceJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRetry() {
|
||||||
|
if (retryJob?.isActive == true) return
|
||||||
|
val delayMs = backoff.nextDelayMs()
|
||||||
|
retryJob = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
retryJob = null
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRetry() {
|
||||||
|
retryJob?.cancel()
|
||||||
|
retryJob = null
|
||||||
|
backoff.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A build-stamp change: drop the events socket and the catalog it fed,
|
||||||
|
* then probe again shortly. The re-probe sees the same identity it just
|
||||||
|
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
|
||||||
|
* the socket — this *is* the full reconnect. */
|
||||||
|
private fun fullReconnect() {
|
||||||
|
closeEvents()
|
||||||
|
catalog.clear()
|
||||||
|
backoff.reset()
|
||||||
|
scope.launch {
|
||||||
|
delay(200)
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSocketDropped() {
|
||||||
|
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
|
||||||
|
_state.value = LinkState.RECONNECTING
|
||||||
|
armGrace()
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBaseUrlChanged(url: String) {
|
||||||
|
currentBaseUrl = url
|
||||||
|
generation += 1
|
||||||
|
client = MaestroClient(url, httpClient)
|
||||||
|
socket = MaestroWebSocket(url, httpClient)
|
||||||
|
identity = null
|
||||||
|
_build.value = null
|
||||||
|
_attempts.value = 0
|
||||||
|
backoff.reset()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
closeEvents()
|
||||||
|
catalog.clear()
|
||||||
|
_state.value = LinkState.CONNECTING
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The broadcast channel dropped frames: re-fetch the full list over REST
|
||||||
|
* rather than trusting the map to still be complete. */
|
||||||
|
private fun handleLagged() {
|
||||||
|
val currentClient = client
|
||||||
|
scope.launch {
|
||||||
|
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureEventsConnected() {
|
||||||
|
if (eventsJob?.isActive == true) return
|
||||||
|
val connection = socket.connectEvents()
|
||||||
|
eventsConnection = connection
|
||||||
|
eventsJob = scope.launch {
|
||||||
|
try {
|
||||||
|
connection.events.collect { event ->
|
||||||
|
val skipped = catalog.apply(event)
|
||||||
|
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
|
||||||
|
}
|
||||||
|
signals.trySend(Signal.SocketDropped)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
signals.trySend(Signal.SocketDropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeEvents() {
|
||||||
|
eventsJob?.cancel()
|
||||||
|
eventsJob = null
|
||||||
|
eventsConnection?.close()
|
||||||
|
eventsConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the daemon base URL lives between launches. A settings screen and
|
||||||
|
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
|
||||||
|
* reads [baseUrl] and reconnects on change.
|
||||||
|
*/
|
||||||
|
class DaemonPrefs(context: Context) {
|
||||||
|
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _baseUrl =
|
||||||
|
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
|
||||||
|
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
||||||
|
|
||||||
|
fun setBaseUrl(url: String) {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
|
||||||
|
prefs.edit { putString(KEY_BASE_URL, trimmed) }
|
||||||
|
_baseUrl.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FILE = "daemon_prefs"
|
||||||
|
private const val KEY_BASE_URL = "base_url"
|
||||||
|
|
||||||
|
/** Shown until the user points the app at their own daemon. */
|
||||||
|
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
|
||||||
|
|
||||||
|
/** Intent extra a launcher may set to point this launch at a specific
|
||||||
|
* daemon without going through settings. Persisted on read, so it
|
||||||
|
* sticks after the launch that carried it. */
|
||||||
|
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection lifecycle to the daemon. Mirrors the states in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
||||||
|
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
|
||||||
|
*
|
||||||
|
* - [CONNECTING] — first probe hasn't landed yet.
|
||||||
|
* - [ONLINE] — the daemon answered and identity is settled.
|
||||||
|
* - [RECONNECTING] — the link dropped, still inside the grace window.
|
||||||
|
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
|
||||||
|
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
|
||||||
|
*/
|
||||||
|
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
|
||||||
@@ -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,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,21 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BackoffTest {
|
||||||
|
@Test
|
||||||
|
fun `doubles from 500ms and caps at 10s`() {
|
||||||
|
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
||||||
|
val delays = List(8) { backoff.nextDelayMs() }
|
||||||
|
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset returns to the initial delay`() {
|
||||||
|
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
||||||
|
repeat(4) { backoff.nextDelayMs() }
|
||||||
|
backoff.reset()
|
||||||
|
assertEquals(500L, backoff.nextDelayMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
|
||||||
|
class CatalogStoreTest {
|
||||||
|
private fun session(id: String) = Session(
|
||||||
|
id = id,
|
||||||
|
cwd = "/tmp",
|
||||||
|
command = "bash",
|
||||||
|
status = SessionStatus.Running,
|
||||||
|
created_at = "2026-05-24T12:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snapshot replaces the map keyed by id`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
||||||
|
assertEquals(setOf("a", "b"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `added and updated upsert one entry`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(emptyList()))
|
||||||
|
store.apply(CatalogEvent.SessionAdded(session("a")))
|
||||||
|
assertEquals(1, store.sessions.value.size)
|
||||||
|
|
||||||
|
val renamed = session("a").copy(command = "zsh")
|
||||||
|
store.apply(CatalogEvent.SessionUpdated(renamed))
|
||||||
|
assertEquals("zsh", store.sessions.value.getValue("a").command)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removed drops the entry`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
||||||
|
store.apply(CatalogEvent.SessionRemoved("a"))
|
||||||
|
assertEquals(setOf("b"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lagged reports the skip count without touching the map`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
|
||||||
|
val skipped = store.apply(CatalogEvent.Lagged(3))
|
||||||
|
assertEquals(3L, skipped)
|
||||||
|
assertEquals(setOf("a"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-lagged events report no skip`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
class DaemonIdentityTest {
|
||||||
|
private fun info(commit: String = "", instance: String = "") =
|
||||||
|
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same commit and instance is Same`() {
|
||||||
|
val a = info(commit = "abc123", instance = "i1")
|
||||||
|
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `different commit is Upgraded`() {
|
||||||
|
val before = info(commit = "abc123", instance = "i1")
|
||||||
|
val after = info(commit = "def456", instance = "i2")
|
||||||
|
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same commit but different instance is Restarted`() {
|
||||||
|
val before = info(commit = "abc123", instance = "i1")
|
||||||
|
val after = info(commit = "abc123", instance = "i2")
|
||||||
|
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty commits on both sides never read as an upgrade`() {
|
||||||
|
val before = info(commit = "", instance = "i1")
|
||||||
|
val after = info(commit = "", instance = "i2")
|
||||||
|
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format release names the commit and short date`() {
|
||||||
|
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
|
||||||
|
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dirty build is flagged`() {
|
||||||
|
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
|
||||||
|
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty commit is unknown release`() {
|
||||||
|
assertEquals("unknown release", formatRelease(BuildInfo()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 +9,12 @@ 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" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
@@ -26,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