Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b8b0abc1 |
@@ -6,6 +6,7 @@ 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
|
||||
@@ -13,6 +14,7 @@ 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
|
||||
@@ -26,9 +28,21 @@ 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
|
||||
@@ -42,6 +56,8 @@ 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
|
||||
@@ -60,6 +76,13 @@ private object Routes {
|
||||
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
|
||||
@@ -77,6 +100,8 @@ fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
|
||||
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("") }
|
||||
@@ -99,66 +124,144 @@ fun MaestroApp(daemonLink: DaemonLink, prefs: DaemonPrefs) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = ::openSession,
|
||||
)
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
) {
|
||||
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 = { showSpawnSheet = true },
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||
SidebarContent(
|
||||
catalogStore = daemonLink.catalog,
|
||||
onOpenSession = ::openSession,
|
||||
)
|
||||
}
|
||||
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) },
|
||||
)
|
||||
},
|
||||
) {
|
||||
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 viewModel = remember(infoClient) { SpawnViewModel(infoClient) }
|
||||
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 },
|
||||
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.
|
||||
|
||||
@@ -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