Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b8b0abc1 | ||
|
|
d8030b59f9 |
@@ -4,49 +4,19 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
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.ModalDrawerSheet
|
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import pt.naps.maestro.connection.DaemonLink
|
import pt.naps.maestro.connection.DaemonLink
|
||||||
import pt.naps.maestro.connection.DaemonPrefs
|
import pt.naps.maestro.connection.DaemonPrefs
|
||||||
import pt.naps.maestro.connection.LinkState
|
import pt.naps.maestro.ui.MaestroApp
|
||||||
import pt.naps.maestro.data.Session
|
|
||||||
import pt.naps.maestro.data.SessionView
|
|
||||||
import pt.naps.maestro.ui.sidebar.SidebarContent
|
|
||||||
import pt.naps.maestro.ui.sidebar.sessionName
|
|
||||||
import pt.naps.maestro.ui.structured.StructuredScreen
|
|
||||||
import pt.naps.maestro.ui.terminal.TerminalScreen
|
|
||||||
import pt.naps.maestro.ui.theme.MaestroTheme
|
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single activity for the whole app (#268): owns the [DaemonLink] for its
|
||||||
|
* lifetime and hosts [MaestroApp], which carries the drawer, navigation and
|
||||||
|
* every pane.
|
||||||
|
*/
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private lateinit var daemonLink: DaemonLink
|
private lateinit var daemonLink: DaemonLink
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -57,59 +27,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MaestroTheme {
|
MaestroTheme {
|
||||||
val linkState by daemonLink.state.collectAsState()
|
MaestroApp(daemonLink = daemonLink, prefs = prefs)
|
||||||
val baseUrl by prefs.baseUrl.collectAsState()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var openSession by remember { mutableStateOf<Session?>(null) }
|
|
||||||
|
|
||||||
val session = openSession
|
|
||||||
if (session != null && session.view == SessionView.Structured) {
|
|
||||||
StructuredScreen(
|
|
||||||
daemonBaseUrl = baseUrl,
|
|
||||||
sessionId = session.id,
|
|
||||||
sessionTitle = sessionName(session),
|
|
||||||
onBack = { openSession = null },
|
|
||||||
)
|
|
||||||
} else if (session != null) {
|
|
||||||
TerminalScreen(
|
|
||||||
daemonBaseUrl = baseUrl,
|
|
||||||
sessionId = session.id,
|
|
||||||
sessionTitle = sessionName(session),
|
|
||||||
onBack = { openSession = null },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ModalNavigationDrawer(
|
|
||||||
drawerState = drawerState,
|
|
||||||
drawerContent = {
|
|
||||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
|
||||||
SidebarContent(
|
|
||||||
catalogStore = daemonLink.catalog,
|
|
||||||
onOpenSession = { s ->
|
|
||||||
openSession = s
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,33 +37,3 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Maestro",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.placeholder_message),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Daemon: ${linkState.name.lowercase()}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun PlaceholderScreenPreview() {
|
|
||||||
MaestroTheme {
|
|
||||||
PlaceholderScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package pt.naps.maestro.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusTarget
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.connection.DaemonLink
|
||||||
|
import pt.naps.maestro.connection.DaemonPrefs
|
||||||
|
import pt.naps.maestro.connection.LinkState
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionView
|
||||||
|
import pt.naps.maestro.ui.commandbar.CommandBarSheet
|
||||||
|
import pt.naps.maestro.ui.commandbar.CommandBarViewModel
|
||||||
|
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||||
|
import pt.naps.maestro.ui.sidebar.sessionName
|
||||||
|
import pt.naps.maestro.ui.spawn.SpawnSheet
|
||||||
|
import pt.naps.maestro.ui.spawn.SpawnViewModel
|
||||||
|
import pt.naps.maestro.ui.structured.StructuredScreen
|
||||||
|
import pt.naps.maestro.ui.terminal.TerminalScreen
|
||||||
|
|
||||||
|
/** Route names for the single-activity nav graph (#268). */
|
||||||
|
private object Routes {
|
||||||
|
const val WELCOME = "welcome"
|
||||||
|
private const val SESSION_ID_ARG = "sessionId"
|
||||||
|
const val TERMINAL = "terminal/{$SESSION_ID_ARG}"
|
||||||
|
const val STRUCTURED = "structured/{$SESSION_ID_ARG}"
|
||||||
|
|
||||||
|
fun terminal(id: String) = "terminal/$id"
|
||||||
|
fun structured(id: String) = "structured/$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to pre-fill the spawn form with when it was opened from the command
|
||||||
|
* bar (#270) rather than from a blank "Spawn session" tap. */
|
||||||
|
private sealed interface SpawnSeed {
|
||||||
|
data class Similar(val session: Session) : SpawnSeed
|
||||||
|
data class Directory(val path: String) : SpawnSeed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level app structure (#268): a persistent [ModalNavigationDrawer] around
|
||||||
|
* a single-back-stack [NavHost], tying together the sidebar (#264/#265), the
|
||||||
|
* terminal pane (#266), the structured pane (#267), and the spawn form
|
||||||
|
* (#269) that were each built standalone.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val sessions by daemonLink.catalog.sessions.collectAsState()
|
||||||
|
val linkState by daemonLink.state.collectAsState()
|
||||||
|
val baseUrl by prefs.baseUrl.collectAsState()
|
||||||
|
var showSpawnSheet by remember { mutableStateOf(false) }
|
||||||
|
var spawnSeed by remember { mutableStateOf<SpawnSeed?>(null) }
|
||||||
|
var showCommandBar by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val infoClient = remember(baseUrl) { MaestroClient(baseUrl) }
|
||||||
|
var hostname by remember(baseUrl) { mutableStateOf("") }
|
||||||
|
LaunchedEffect(linkState, infoClient) {
|
||||||
|
if (linkState == LinkState.ONLINE) {
|
||||||
|
hostname = runCatching { infoClient.getDaemonInfo().hostname }.getOrDefault(hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSession(session: Session) {
|
||||||
|
val route = if (session.view == SessionView.Structured) {
|
||||||
|
Routes.structured(session.id)
|
||||||
|
} else {
|
||||||
|
Routes.terminal(session.id)
|
||||||
|
}
|
||||||
|
navController.navigate(route) {
|
||||||
|
popUpTo(Routes.WELCOME)
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun spawnNew() {
|
||||||
|
spawnSeed = null
|
||||||
|
showSpawnSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun spawnInDirectory(path: String) {
|
||||||
|
spawnSeed = SpawnSeed.Directory(path)
|
||||||
|
showSpawnSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun spawnSimilar(session: Session) {
|
||||||
|
spawnSeed = SpawnSeed.Similar(session)
|
||||||
|
showSpawnSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl-K opens the bar from anywhere (docs/COMMAND-BAR.md); a focusable
|
||||||
|
// root keeps the shortcut live except while something else (the bar's own
|
||||||
|
// search field, a text input) holds focus instead.
|
||||||
|
val rootFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(showCommandBar) {
|
||||||
|
if (!showCommandBar) rootFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(rootFocusRequester)
|
||||||
|
.focusTarget()
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.isCtrlPressed && event.key == Key.K) {
|
||||||
|
showCommandBar = true
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||||
|
SidebarContent(
|
||||||
|
catalogStore = daemonLink.catalog,
|
||||||
|
onOpenSession = ::openSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ConnectionStatusBar(linkState = linkState, hostname = hostname)
|
||||||
|
NavHost(navController = navController, startDestination = Routes.WELCOME) {
|
||||||
|
composable(Routes.WELCOME) {
|
||||||
|
WelcomeScreen(
|
||||||
|
onOpenSessions = { scope.launch { drawerState.open() } },
|
||||||
|
onSpawnSession = { spawnNew() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
Routes.TERMINAL,
|
||||||
|
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||||
|
TerminalScreen(
|
||||||
|
daemonBaseUrl = baseUrl,
|
||||||
|
sessionId = id,
|
||||||
|
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||||
|
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
Routes.STRUCTURED,
|
||||||
|
arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val id = backStackEntry.arguments?.getString("sessionId") ?: return@composable
|
||||||
|
StructuredScreen(
|
||||||
|
daemonBaseUrl = baseUrl,
|
||||||
|
sessionId = id,
|
||||||
|
sessionTitle = sessions[id]?.let(::sessionName) ?: id,
|
||||||
|
onBack = { navController.popBackStack(Routes.WELCOME, inclusive = false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { showCommandBar = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
.semantics { contentDescription = "Search sessions and commands" },
|
||||||
|
) {
|
||||||
|
Text("⌕")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSpawnSheet) {
|
||||||
|
val seed = spawnSeed
|
||||||
|
val viewModel = remember(infoClient, seed) {
|
||||||
|
SpawnViewModel(infoClient).also {
|
||||||
|
when (seed) {
|
||||||
|
is SpawnSeed.Similar -> {
|
||||||
|
it.setCwd(seed.session.cwd)
|
||||||
|
it.setCustomCommand(seed.session.command)
|
||||||
|
}
|
||||||
|
is SpawnSeed.Directory -> it.setCwd(seed.path)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SpawnSheet(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onDismiss = {
|
||||||
|
showSpawnSheet = false
|
||||||
|
spawnSeed = null
|
||||||
|
},
|
||||||
|
onSpawned = { session ->
|
||||||
|
showSpawnSheet = false
|
||||||
|
spawnSeed = null
|
||||||
|
openSession(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCommandBar) {
|
||||||
|
val commandBarViewModel = remember(daemonLink, infoClient) {
|
||||||
|
CommandBarViewModel(daemonLink.catalog, infoClient)
|
||||||
|
}
|
||||||
|
CommandBarSheet(
|
||||||
|
viewModel = commandBarViewModel,
|
||||||
|
onDismiss = { showCommandBar = false },
|
||||||
|
onOpenSession = ::openSession,
|
||||||
|
onSpawnNew = ::spawnNew,
|
||||||
|
onSpawnDirectory = ::spawnInDirectory,
|
||||||
|
onSpawnSimilar = ::spawnSimilar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared last so it sits above the NavHost's own back handling on the
|
||||||
|
// dispatcher stack: a drawer open over a session must close first, not
|
||||||
|
// pop the session back to Welcome.
|
||||||
|
BackHandler(enabled = drawerState.isOpen) {
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connection indicator (green online / red down) plus the daemon hostname
|
||||||
|
* from `/api/info`, visible above every route. */
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionStatusBar(linkState: LinkState, hostname: String) {
|
||||||
|
val online = linkState == LinkState.ONLINE
|
||||||
|
val dotColor = if (online) Color(0xFF2E7D32) else Color(0xFFC62828)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(dotColor),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = hostname.ifEmpty { linkState.name.lowercase() },
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package pt.naps.maestro.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when no session is open (#268): the app's resting state, reached at
|
||||||
|
* launch and every time a session closes or the operator backs out of one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WelcomeScreen(
|
||||||
|
onOpenSessions: () -> Unit,
|
||||||
|
onSpawnSession: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = "Maestro", style = MaterialTheme.typography.displaySmall)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "No session open",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(onClick = onOpenSessions, modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||||
|
Text("Sessions")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedButton(onClick = onSpawnSession, modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||||
|
Text("Spawn session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun WelcomeScreenPreview() {
|
||||||
|
MaestroTheme {
|
||||||
|
WelcomeScreen(onOpenSessions = {}, onSpawnSession = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package pt.naps.maestro.ui.commandbar
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
import pt.naps.maestro.ui.sidebar.programName
|
||||||
|
import pt.naps.maestro.ui.sidebar.sessionName
|
||||||
|
|
||||||
|
/** The command bar's pure model (docs/COMMAND-BAR.md): candidates, the sigil
|
||||||
|
* grammar, and the ranking. Android's v1 matcher is deliberately a simple
|
||||||
|
* weighted substring/fuzzy scorer, not the scored dynamic-program matcher
|
||||||
|
* `web`/#146 and the TUI/#147 share — see the issue for why a lighter
|
||||||
|
* matcher was chosen for the first cut. */
|
||||||
|
sealed interface BarCandidate {
|
||||||
|
data class SessionItem(val session: Session) : BarCandidate
|
||||||
|
data class CommandItem(val command: BarCommand) : BarCandidate
|
||||||
|
data class DirectoryItem(val path: String, val display: String) : BarCandidate
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BarCommand(val id: String, val label: String)
|
||||||
|
|
||||||
|
/** The bar's fixed `>` registry for v1: the app has no keymap/command palette
|
||||||
|
* of its own yet, so spawning is the one action worth reaching from anywhere. */
|
||||||
|
val BAR_COMMANDS: List<BarCommand> = listOf(BarCommand(id = "spawn", label = "Spawn session"))
|
||||||
|
|
||||||
|
sealed interface BarSigil {
|
||||||
|
object Commands : BarSigil
|
||||||
|
object Directories : BarSigil
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ParsedBarQuery(val sigil: BarSigil?, val query: String)
|
||||||
|
|
||||||
|
/** A sigil is only a sigil in first position (docs/COMMAND-BAR.md) — typed
|
||||||
|
* anywhere else, or backspaced away, it is an ordinary query character. */
|
||||||
|
fun parseBarQuery(raw: String): ParsedBarQuery = when (raw.firstOrNull()) {
|
||||||
|
'>' -> ParsedBarQuery(BarSigil.Commands, raw.substring(1))
|
||||||
|
'~' -> ParsedBarQuery(BarSigil.Directories, raw.substring(1))
|
||||||
|
else -> ParsedBarQuery(null, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun instantMs(iso: String?): Long? {
|
||||||
|
if (iso.isNullOrEmpty()) return null
|
||||||
|
return runCatching { Instant.parse(iso).toEpochMilli() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty-query session order (docs/COMMAND-BAR.md, "Empty query"): most
|
||||||
|
* recently focused first; a session never focused sorts after every session
|
||||||
|
* that has been, most recently created first among those; a dead session
|
||||||
|
* sorts last regardless of recency. */
|
||||||
|
fun orderSessionsForBar(sessions: List<Session>): List<Session> = sessions.sortedWith(
|
||||||
|
Comparator { a, b ->
|
||||||
|
val deadA = if (a.status == SessionStatus.Dead) 1 else 0
|
||||||
|
val deadB = if (b.status == SessionStatus.Dead) 1 else 0
|
||||||
|
if (deadA != deadB) return@Comparator deadA - deadB
|
||||||
|
|
||||||
|
val focusA = instantMs(a.last_focused_at)
|
||||||
|
val focusB = instantMs(b.last_focused_at)
|
||||||
|
if ((focusA == null) != (focusB == null)) return@Comparator if (focusA == null) 1 else -1
|
||||||
|
if (focusA != null && focusB != null && focusA != focusB) return@Comparator focusB.compareTo(focusA)
|
||||||
|
|
||||||
|
(instantMs(b.created_at) ?: 0L).compareTo(instantMs(a.created_at) ?: 0L)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `~/dev/api`, not `/home/me/dev/api` — the abbreviation the sidebar and the
|
||||||
|
* spawn form both use. */
|
||||||
|
fun abbreviateCwd(path: String, homeDir: String): String = when {
|
||||||
|
homeDir.isEmpty() -> path
|
||||||
|
path == homeDir -> "~"
|
||||||
|
path.startsWith("$homeDir/") -> "~" + path.removePrefix(homeDir)
|
||||||
|
else -> path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inverse of [abbreviateCwd], for a path the operator typed. */
|
||||||
|
fun expandHome(path: String, homeDir: String): String = when {
|
||||||
|
homeDir.isEmpty() -> path
|
||||||
|
path == "~" -> homeDir
|
||||||
|
path.startsWith("~/") -> homeDir + path.removePrefix("~")
|
||||||
|
else -> path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `~`'s candidates, deduped by the directory they spawn in and keeping the
|
||||||
|
* first mention of each. */
|
||||||
|
fun directoryCandidates(paths: List<String>, homeDir: String): List<BarCandidate.DirectoryItem> {
|
||||||
|
val seen = LinkedHashSet<String>()
|
||||||
|
val out = mutableListOf<BarCandidate.DirectoryItem>()
|
||||||
|
for (path in paths) {
|
||||||
|
if (path.isEmpty() || !seen.add(path)) continue
|
||||||
|
out += BarCandidate.DirectoryItem(path = path, display = abbreviateCwd(path, homeDir))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BarField(val name: String, val text: String, val weight: Int)
|
||||||
|
|
||||||
|
/** Field weights, ported from docs/COMMAND-BAR.md's candidate table — kept so
|
||||||
|
* an exact `tool` match can still outrank a weaker `name` match, even though
|
||||||
|
* the per-character scoring underneath is simplified. */
|
||||||
|
private fun fieldsFor(candidate: BarCandidate, homeDir: String): List<BarField> = when (candidate) {
|
||||||
|
is BarCandidate.SessionItem -> {
|
||||||
|
val session = candidate.session
|
||||||
|
val directory = session.worktree?.base_repo ?: session.cwd
|
||||||
|
listOfNotNull(
|
||||||
|
BarField("name", sessionName(session), 0),
|
||||||
|
BarField("directory", abbreviateCwd(directory, homeDir), -24),
|
||||||
|
session.command.takeIf { it.isNotEmpty() }?.let { BarField("command", it, -32) },
|
||||||
|
BarField("tool", programName(session.command), -40),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is BarCandidate.CommandItem -> listOf(BarField("label", candidate.command.label, 0))
|
||||||
|
is BarCandidate.DirectoryItem -> listOf(BarField("path", candidate.display, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MATCH_EXACT = 100
|
||||||
|
private const val MATCH_PREFIX = 70
|
||||||
|
private const val MATCH_CONTAINS = 50
|
||||||
|
private const val MATCH_FUZZY = 20
|
||||||
|
|
||||||
|
/** Case-insensitive subsequence: every character of [query] appears in [text]
|
||||||
|
* in order, not necessarily adjacent. */
|
||||||
|
private fun isSubsequence(query: String, text: String): Boolean {
|
||||||
|
var i = 0
|
||||||
|
for (c in text) {
|
||||||
|
if (i >= query.length) break
|
||||||
|
if (c == query[i]) i++
|
||||||
|
}
|
||||||
|
return i == query.length
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fieldMatchScore(query: String, text: String): Int? {
|
||||||
|
val q = query.lowercase()
|
||||||
|
val t = text.lowercase()
|
||||||
|
return when {
|
||||||
|
t == q -> MATCH_EXACT
|
||||||
|
t.startsWith(q) -> MATCH_PREFIX
|
||||||
|
t.contains(q) -> MATCH_CONTAINS
|
||||||
|
isSubsequence(q, t) -> MATCH_FUZZY
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun kindOrder(candidate: BarCandidate): Int = when (candidate) {
|
||||||
|
is BarCandidate.SessionItem -> 0
|
||||||
|
is BarCandidate.CommandItem -> 1
|
||||||
|
is BarCandidate.DirectoryItem -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BarResult(val candidate: BarCandidate, val score: Int, val matchedField: String?)
|
||||||
|
|
||||||
|
/** Ranks [candidates] against [query]. An empty query matches everything at
|
||||||
|
* score 0 in input order (docs/COMMAND-BAR.md, "Empty query"); the bar's own
|
||||||
|
* rules decide what is in the candidate list at that point. */
|
||||||
|
fun rankCandidates(query: String, candidates: List<BarCandidate>, homeDir: String): List<BarResult> {
|
||||||
|
val trimmed = query.trim()
|
||||||
|
if (trimmed.isEmpty()) return candidates.map { BarResult(it, 0, null) }
|
||||||
|
|
||||||
|
val results = candidates.mapNotNull { candidate ->
|
||||||
|
var best: BarResult? = null
|
||||||
|
for (field in fieldsFor(candidate, homeDir)) {
|
||||||
|
val matchScore = fieldMatchScore(trimmed, field.text) ?: continue
|
||||||
|
val total = matchScore + field.weight
|
||||||
|
if (best == null || total > best.score) best = BarResult(candidate, total, field.name)
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
// Stable sort: ties keep the caller's order (sessions most-recently-focused
|
||||||
|
// first, commands in registry order), matching the shared matcher's last
|
||||||
|
// tie-break without reproducing its length/codepoint rules.
|
||||||
|
return results.sortedWith(compareByDescending<BarResult> { it.score }.thenBy { kindOrder(it.candidate) })
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BarGroup(val title: String, val results: List<BarResult>)
|
||||||
|
|
||||||
|
private fun titleFor(candidate: BarCandidate): String = when (candidate) {
|
||||||
|
is BarCandidate.SessionItem -> "Sessions"
|
||||||
|
is BarCandidate.CommandItem -> "Commands"
|
||||||
|
is BarCandidate.DirectoryItem -> "Directories"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buckets a ranked list by kind for rendering. Grouping is a rendering of the
|
||||||
|
* ranking, never a reordering of it: groups appear in the order their
|
||||||
|
* best-scoring member does, because [results] is already rank-ordered and a
|
||||||
|
* `LinkedHashMap` keeps first-seen order. */
|
||||||
|
fun groupResults(results: List<BarResult>): List<BarGroup> {
|
||||||
|
val groups = LinkedHashMap<String, MutableList<BarResult>>()
|
||||||
|
for (result in results) {
|
||||||
|
groups.getOrPut(titleFor(result.candidate)) { mutableListOf() }.add(result)
|
||||||
|
}
|
||||||
|
return groups.map { (title, group) -> BarGroup(title, group) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package pt.naps.maestro.ui.commandbar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
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.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
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.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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
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.Session
|
||||||
|
import pt.naps.maestro.ui.sidebar.ActivityRail
|
||||||
|
import pt.naps.maestro.ui.sidebar.MetaLine
|
||||||
|
import pt.naps.maestro.ui.sidebar.customSessionName
|
||||||
|
import pt.naps.maestro.ui.sidebar.formatInTheWay
|
||||||
|
import pt.naps.maestro.ui.sidebar.rememberTickingNowMs
|
||||||
|
import pt.naps.maestro.ui.sidebar.sessionBlockers
|
||||||
|
import pt.naps.maestro.ui.sidebar.sessionName
|
||||||
|
|
||||||
|
/** #270's `ctrl-k` command bar (docs/COMMAND-BAR.md): a search field over a
|
||||||
|
* bottom sheet, sessions and the `>` registry mixed by default, `>` and `~`
|
||||||
|
* narrowing to one kind. Tap opens a result; long-press on a session opens
|
||||||
|
* its action menu (rename, kill, spawn similar) — the bar picks the action
|
||||||
|
* and hands off to the flow that already implements it, it never
|
||||||
|
* reimplements one. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CommandBarSheet(
|
||||||
|
viewModel: CommandBarViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onOpenSession: (Session) -> Unit,
|
||||||
|
onSpawnNew: () -> Unit,
|
||||||
|
onSpawnDirectory: (String) -> Unit,
|
||||||
|
onSpawnSimilar: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val nowMs = rememberTickingNowMs()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var actionSession by remember { mutableStateOf<Session?>(null) }
|
||||||
|
var renaming by remember { mutableStateOf<Session?>(null) }
|
||||||
|
var confirmingKill by remember { mutableStateOf<Session?>(null) }
|
||||||
|
|
||||||
|
fun activate(candidate: BarCandidate) {
|
||||||
|
when (candidate) {
|
||||||
|
is BarCandidate.SessionItem -> onOpenSession(candidate.session)
|
||||||
|
is BarCandidate.CommandItem -> onSpawnNew()
|
||||||
|
is BarCandidate.DirectoryItem -> onSpawnDirectory(candidate.path)
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||||
|
Column(Modifier.fillMaxWidth().heightIn(max = 560.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.query,
|
||||||
|
onValueChange = viewModel::onQueryChange,
|
||||||
|
placeholder = { Text(placeholderFor(state.sigil)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
|
||||||
|
if (state.groups.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"No matches",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val showHeaders = state.sigil == null
|
||||||
|
LazyColumn(Modifier.fillMaxWidth()) {
|
||||||
|
for (group in state.groups) {
|
||||||
|
if (showHeaders) {
|
||||||
|
item(key = "header:${group.title}") {
|
||||||
|
Text(
|
||||||
|
text = group.title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(group.results, key = { rowKey(it.candidate) }) { result ->
|
||||||
|
BarRow(
|
||||||
|
candidate = result.candidate,
|
||||||
|
nowMs = nowMs,
|
||||||
|
onClick = { activate(result.candidate) },
|
||||||
|
onLongClick = {
|
||||||
|
val session = (result.candidate as? BarCandidate.SessionItem)?.session
|
||||||
|
if (session != null) actionSession = session
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actionSession?.let { session ->
|
||||||
|
SessionActionMenu(
|
||||||
|
session = session,
|
||||||
|
onDismiss = { actionSession = null },
|
||||||
|
onRename = {
|
||||||
|
actionSession = null
|
||||||
|
renaming = session
|
||||||
|
},
|
||||||
|
onKill = {
|
||||||
|
actionSession = null
|
||||||
|
confirmingKill = session
|
||||||
|
},
|
||||||
|
onSpawnSimilar = {
|
||||||
|
actionSession = null
|
||||||
|
onDismiss()
|
||||||
|
onSpawnSimilar(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renaming?.let { session ->
|
||||||
|
RenameDialog(
|
||||||
|
initial = customSessionName(session) ?: "",
|
||||||
|
onCommit = {
|
||||||
|
renaming = null
|
||||||
|
viewModel.rename(session, it)
|
||||||
|
},
|
||||||
|
onCancel = { renaming = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmingKill?.let { session ->
|
||||||
|
KillConfirmDialog(
|
||||||
|
session = session,
|
||||||
|
onConfirm = {
|
||||||
|
confirmingKill = null
|
||||||
|
viewModel.kill(session)
|
||||||
|
},
|
||||||
|
onCancel = { confirmingKill = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun placeholderFor(sigil: BarSigil?): String = when (sigil) {
|
||||||
|
BarSigil.Commands -> "Run a command…"
|
||||||
|
BarSigil.Directories -> "Spawn in a directory…"
|
||||||
|
null -> "Go to a session…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rowKey(candidate: BarCandidate): String = when (candidate) {
|
||||||
|
is BarCandidate.SessionItem -> "session:${candidate.session.id}"
|
||||||
|
is BarCandidate.CommandItem -> "command:${candidate.command.id}"
|
||||||
|
is BarCandidate.DirectoryItem -> "directory:${candidate.path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun BarRow(
|
||||||
|
candidate: BarCandidate,
|
||||||
|
nowMs: Long,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
when (candidate) {
|
||||||
|
is BarCandidate.SessionItem -> {
|
||||||
|
ActivityRail(activity = candidate.session.activity, modifier = Modifier.height(36.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = sessionName(candidate.session),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
MetaLine(session = candidate.session, nowMs = nowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is BarCandidate.CommandItem -> {
|
||||||
|
Text(
|
||||||
|
text = candidate.command.label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is BarCandidate.DirectoryItem -> {
|
||||||
|
Text(
|
||||||
|
text = candidate.display,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-press's action menu (docs/COMMAND-BAR.md, "Drill-in"), collapsed from
|
||||||
|
* the desktop clients' fuzzy-searchable action page to a plain dialog — a
|
||||||
|
* touch long-press already named the one session being acted on, so there is
|
||||||
|
* nothing left to search. */
|
||||||
|
@Composable
|
||||||
|
private fun SessionActionMenu(
|
||||||
|
session: Session,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onKill: () -> Unit,
|
||||||
|
onSpawnSimilar: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(sessionName(session)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
TextButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("Rename") }
|
||||||
|
TextButton(onClick = onSpawnSimilar, modifier = Modifier.fillMaxWidth()) { Text("Spawn similar") }
|
||||||
|
TextButton(onClick = onKill, modifier = Modifier.fillMaxWidth()) { Text("Kill") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,135 @@
|
|||||||
|
package pt.naps.maestro.ui.commandbar
|
||||||
|
|
||||||
|
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.MaestroClient
|
||||||
|
import pt.naps.maestro.connection.CatalogStore
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
|
||||||
|
/** Quiet period before a typed `~` path is probed over `GET /api/dirs`,
|
||||||
|
* mirroring the spawn form's own autocomplete debounce. */
|
||||||
|
private const val PROBE_DEBOUNCE_MS = 250L
|
||||||
|
|
||||||
|
data class CommandBarUiState(
|
||||||
|
val query: String = "",
|
||||||
|
val sigil: BarSigil? = null,
|
||||||
|
val groups: List<BarGroup> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search logic behind [pt.naps.maestro.ui.commandbar.CommandBarSheet]
|
||||||
|
* (#270): folds the live session catalog, the fixed `>` command registry and
|
||||||
|
* the `~` directory list into one ranked, grouped result set as the query
|
||||||
|
* changes. A plain class, not an `androidx.lifecycle.ViewModel` — same shape
|
||||||
|
* as [pt.naps.maestro.ui.spawn.SpawnViewModel], which this otherwise mirrors.
|
||||||
|
*/
|
||||||
|
class CommandBarViewModel(
|
||||||
|
private val catalogStore: CatalogStore,
|
||||||
|
private val client: MaestroClient,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(CommandBarUiState())
|
||||||
|
val state: StateFlow<CommandBarUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var homeDir: String = ""
|
||||||
|
private var recentDirectories: List<BarCandidate.DirectoryItem> = emptyList()
|
||||||
|
private var probedDirectories: List<BarCandidate.DirectoryItem> = emptyList()
|
||||||
|
private var recentDirectoriesLoaded = false
|
||||||
|
private var probeJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
homeDir = runCatching { client.getDaemonInfo().home_dir }.getOrDefault("")
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
catalogStore.sessions.collect { recompute() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onQueryChange(raw: String) {
|
||||||
|
_state.update { it.copy(query = raw, sigil = parseBarQuery(raw).sigil) }
|
||||||
|
val parsed = parseBarQuery(raw)
|
||||||
|
if (parsed.sigil is BarSigil.Directories) {
|
||||||
|
ensureRecentDirectoriesLoaded()
|
||||||
|
probeTypedDirectory(parsed.query)
|
||||||
|
}
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PATCH /api/sessions/:id`. Fire-and-forget: the catalog reflects the
|
||||||
|
* rename over the events socket like any other client's edit. */
|
||||||
|
fun rename(session: Session, name: String) {
|
||||||
|
scope.launch { runCatching { client.renameSession(session.id, name) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /api/sessions/:id`, unforced — the same default the sidebar's
|
||||||
|
* own kill confirmation uses. */
|
||||||
|
fun kill(session: Session) {
|
||||||
|
scope.launch { runCatching { client.killSession(session.id) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureRecentDirectoriesLoaded() {
|
||||||
|
if (recentDirectoriesLoaded) return
|
||||||
|
recentDirectoriesLoaded = true
|
||||||
|
scope.launch {
|
||||||
|
val recents = runCatching { client.recentDirs().dirs.map { it.cwd } }.getOrDefault(emptyList())
|
||||||
|
val projects = runCatching { client.listProjects().map { it.path } }.getOrDefault(emptyList())
|
||||||
|
recentDirectories = directoryCandidates(recents + projects, homeDir)
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills the `~` list in as a path is typed, the way the spawn form's own
|
||||||
|
* autocomplete does: the directory named so far is listed and its
|
||||||
|
* children join the candidates. */
|
||||||
|
private fun probeTypedDirectory(query: String) {
|
||||||
|
val slash = query.lastIndexOf('/')
|
||||||
|
if (slash < 0) {
|
||||||
|
probedDirectories = emptyList()
|
||||||
|
probeJob?.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val dir = expandHome(query.substring(0, slash + 1), homeDir)
|
||||||
|
probeJob?.cancel()
|
||||||
|
probeJob = scope.launch {
|
||||||
|
delay(PROBE_DEBOUNCE_MS)
|
||||||
|
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
||||||
|
probedDirectories = directoryCandidates(entries.map { "$dir$it" }, homeDir)
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun directoryListCandidates(): List<BarCandidate> {
|
||||||
|
val seen = LinkedHashSet<String>()
|
||||||
|
val out = mutableListOf<BarCandidate.DirectoryItem>()
|
||||||
|
for (candidate in recentDirectories + probedDirectories) {
|
||||||
|
if (seen.add(candidate.path)) out += candidate
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recompute() {
|
||||||
|
val raw = _state.value.query
|
||||||
|
val parsed = parseBarQuery(raw)
|
||||||
|
val candidates: List<BarCandidate> = when (parsed.sigil) {
|
||||||
|
is BarSigil.Commands -> BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
|
||||||
|
is BarSigil.Directories -> directoryListCandidates()
|
||||||
|
null -> {
|
||||||
|
val sessions = orderSessionsForBar(catalogStore.sessions.value.values.toList())
|
||||||
|
.map { BarCandidate.SessionItem(it) }
|
||||||
|
if (parsed.query.isEmpty()) sessions else sessions + BAR_COMMANDS.map { BarCandidate.CommandItem(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ranked = rankCandidates(parsed.query, candidates, homeDir)
|
||||||
|
_state.update { it.copy(groups = groupResults(ranked)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package pt.naps.maestro.ui.commandbar
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
|
||||||
|
private fun session(
|
||||||
|
id: String,
|
||||||
|
name: String = id,
|
||||||
|
command: String = "bash",
|
||||||
|
cwd: String = "/home/user/project",
|
||||||
|
status: SessionStatus = SessionStatus.Running,
|
||||||
|
createdAt: String = "2026-01-01T00:00:00Z",
|
||||||
|
lastFocusedAt: String? = null,
|
||||||
|
) = Session(
|
||||||
|
id = id,
|
||||||
|
cwd = cwd,
|
||||||
|
command = command,
|
||||||
|
metadata = mapOf("name" to JsonPrimitive(name)),
|
||||||
|
status = status,
|
||||||
|
created_at = createdAt,
|
||||||
|
last_focused_at = lastFocusedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
class CommandBarModelTest {
|
||||||
|
@Test
|
||||||
|
fun `a sigil is only read in first position`() {
|
||||||
|
assertEquals(BarSigil.Commands, parseBarQuery(">quit").sigil)
|
||||||
|
assertEquals("quit", parseBarQuery(">quit").query)
|
||||||
|
assertEquals(BarSigil.Directories, parseBarQuery("~/dev").sigil)
|
||||||
|
assertEquals("/dev", parseBarQuery("~/dev").query)
|
||||||
|
assertNull(parseBarQuery("foo>bar").sigil)
|
||||||
|
assertEquals("foo>bar", parseBarQuery("foo>bar").query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `backspacing past a sigil returns the mixed list`() {
|
||||||
|
assertNull(parseBarQuery("").sigil)
|
||||||
|
assertEquals("", parseBarQuery("").query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `most recently focused session sorts first`() {
|
||||||
|
val a = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
|
||||||
|
val b = session("b", lastFocusedAt = "2026-01-02T00:00:00Z")
|
||||||
|
assertEquals(listOf(b, a), orderSessionsForBar(listOf(a, b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a never-focused session sorts after every focused one`() {
|
||||||
|
val focused = session("a", lastFocusedAt = "2026-01-01T00:00:00Z")
|
||||||
|
val neverFocused = session("b", createdAt = "2026-01-05T00:00:00Z")
|
||||||
|
assertEquals(listOf(focused, neverFocused), orderSessionsForBar(listOf(neverFocused, focused)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `among never-focused sessions the most recently created sorts first`() {
|
||||||
|
val older = session("a", createdAt = "2026-01-01T00:00:00Z")
|
||||||
|
val newer = session("b", createdAt = "2026-01-02T00:00:00Z")
|
||||||
|
assertEquals(listOf(newer, older), orderSessionsForBar(listOf(older, newer)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dead sessions sort last regardless of recency`() {
|
||||||
|
val dead = session("a", status = SessionStatus.Dead, lastFocusedAt = "2026-01-09T00:00:00Z")
|
||||||
|
val alive = session("b", lastFocusedAt = "2026-01-01T00:00:00Z")
|
||||||
|
assertEquals(listOf(alive, dead), orderSessionsForBar(listOf(dead, alive)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `abbreviateCwd replaces the home prefix`() {
|
||||||
|
assertEquals("~/dev/api", abbreviateCwd("/home/user/dev/api", "/home/user"))
|
||||||
|
assertEquals("~", abbreviateCwd("/home/user", "/home/user"))
|
||||||
|
assertEquals("/var/lib", abbreviateCwd("/var/lib", "/home/user"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `expandHome inverts abbreviateCwd`() {
|
||||||
|
assertEquals("/home/user/dev/api", expandHome("~/dev/api", "/home/user"))
|
||||||
|
assertEquals("/home/user", expandHome("~", "/home/user"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty query matches every candidate at score 0 in input order`() {
|
||||||
|
val a = BarCandidate.SessionItem(session("a"))
|
||||||
|
val b = BarCandidate.SessionItem(session("b"))
|
||||||
|
val results = rankCandidates("", listOf(a, b), homeDir = "")
|
||||||
|
assertEquals(listOf(a, b), results.map { it.candidate })
|
||||||
|
assertTrue(results.all { it.score == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a query matching nothing returns an empty list`() {
|
||||||
|
val candidates = listOf(BarCandidate.SessionItem(session("a", command = "bash")))
|
||||||
|
assertTrue(rankCandidates("zzz", candidates, homeDir = "").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exact name match outranks a session found through its directory`() {
|
||||||
|
val api = BarCandidate.SessionItem(session("api", cwd = "/home/user/web"))
|
||||||
|
val worker = BarCandidate.SessionItem(session("worker", cwd = "/home/user/api"))
|
||||||
|
val results = rankCandidates("api", listOf(api, worker), homeDir = "")
|
||||||
|
assertEquals(listOf(api, worker), results.map { it.candidate })
|
||||||
|
assertEquals("name", results[0].matchedField)
|
||||||
|
assertEquals("directory", results[1].matchedField)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a session beats a command of equal score, kind order tie-break`() {
|
||||||
|
val sessionItem = BarCandidate.SessionItem(session("kill"))
|
||||||
|
val command = BarCandidate.CommandItem(BarCommand(id = "kill", label = "kill"))
|
||||||
|
val results = rankCandidates("kill", listOf(command, sessionItem), homeDir = "")
|
||||||
|
assertEquals(listOf(sessionItem, command), results.map { it.candidate })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `groups are ordered by their best-scoring member and drop empty kinds`() {
|
||||||
|
val sessionItem = BarCandidate.SessionItem(session("web-search"))
|
||||||
|
val command = BarCandidate.CommandItem(BarCommand(id = "spawn", label = "Spawn session"))
|
||||||
|
val ranked = rankCandidates("web", listOf(command, sessionItem), homeDir = "")
|
||||||
|
val groups = groupResults(ranked)
|
||||||
|
assertEquals(listOf("Sessions"), groups.map { it.title })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a fuzzy subsequence still matches out of order characters`() {
|
||||||
|
val candidate = BarCandidate.SessionItem(session("maestro-tui"))
|
||||||
|
assertTrue(rankCandidates("mtu", listOf(candidate), homeDir = "").isNotEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user