feat(skills): hub sources + featured landing and security scan (#779) (#793)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-03 22:13:28 +03:00
1 parent 00a0eedc77
commit 1870b375b1
7 files changed
+812 -304

No files matched your search

@@ -56,7 +56,39 @@ data class SkillHubUninstallRequest(
@Serializable
data class SkillScanResponse(
val identifier: String,
val passed: Boolean? = null,
val issues: List<String>? = null,
val warnings: List<String>? = null,
val name: String? = null,
val source: String? = null,
@SerialName("trust_level") val trustLevel: String? = null,
val verdict: String? = null,
val summary: String? = null,
val policy: String? = null,
@SerialName("policy_reason") val policyReason: String? = null,
val findings: List<SkillScanFinding> = emptyList(),
@SerialName("severity_counts") val severityCounts: Map<String, Int> = emptyMap(),
)
@Serializable
data class SkillScanFinding(
val severity: String,
val category: String,
val file: String? = null,
val line: Int? = null,
val description: String? = null,
)
@Serializable
data class SkillHubSource(
val id: String,
val label: String? = null,
@SerialName("rate_limited") val rateLimited: Boolean? = null,
val available: Boolean? = null,
val searchable: Boolean? = null,
)
@Serializable
data class SkillHubSourcesResponse(
val sources: List<SkillHubSource> = emptyList(),
@SerialName("index_available") val indexAvailable: Boolean? = null,
val featured: List<HubSkill> = emptyList(),
val installed: Map<String, SkillHubInstalledEntry>? = null,
)
@@ -88,7 +88,9 @@ import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubSearchResponse
import com.m57.hermescontrol.data.model.SkillHubSourcesResponse
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.TelegramOnboardingApplyRequest
@@ -240,6 +242,11 @@ interface HermesApiService {
@Query("identifier") identifier: String,
): Response<SkillContentResponse>
@GET("api/skills/hub/scan")
suspend fun scanHubSkill(
@Query("identifier") identifier: String,
): Response<SkillScanResponse>
@POST("api/skills/hub/install")
suspend fun installHubSkill(
@Body body: SkillHubInstallRequest,
@@ -250,6 +257,9 @@ interface HermesApiService {
@Body body: SkillHubUninstallRequest,
): Response<ActionResponse>
@GET("api/skills/hub/sources")
suspend fun getSkillHubSources(): Response<SkillHubSourcesResponse>
@PUT("api/skills/toggle")
suspend fun toggleSkill(
@Body body: ToggleSkillRequest,
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.skills
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -30,12 +31,14 @@ import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -55,6 +58,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -69,6 +73,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillHubSource
import com.m57.hermescontrol.data.model.SkillScanFinding
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.DetailRow
@@ -174,6 +181,9 @@ fun SkillsScreen(
onClearSearch = viewModel::clearHubSearch,
onInstall = viewModel::installSkill,
onPreviewHubSkill = viewModel::previewHubSkill,
onScanHubSkill = viewModel::scanHubSkill,
onClearHubScan = viewModel::clearHubScan,
onLoadHubSources = viewModel::loadHubSources,
isInstalling = state.isInstalling,
installingSkillName = state.installingSkillName,
)
@@ -533,6 +543,9 @@ private fun HubBrowseView(
onClearSearch: () -> Unit,
onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
onScanHubSkill: (String) -> Unit,
onClearHubScan: () -> Unit,
onLoadHubSources: () -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
) {
@@ -629,9 +642,16 @@ private fun HubBrowseView(
}
else -> {
EmptyState(
icon = Icons.Filled.Search,
title = stringResource(R.string.skills_hub_search_hint),
HubLandingView(
state = state,
onLoadHubSources = onLoadHubSources,
onInstall = onInstall,
isInstalling = isInstalling,
installingSkillName = installingSkillName,
onOpenDetail = { skill ->
hubShowDetail = skill
skill.identifier?.let { onPreviewHubSkill(it) }
},
)
}
}
@@ -655,17 +675,435 @@ private fun HubBrowseView(
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
),
actions =
if (previewReady && state.isHubPreviewing) {
{ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) }
} else {
null
},
onDismiss = { hubShowDetail = null },
actions = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (previewReady && state.isHubPreviewing) {
CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp))
}
hubSkill.identifier?.let { identifier ->
SkillScanSection(
scanResult =
state.hubScanResult.takeIf {
state.hubScanIdentifier == identifier
},
isScanning =
state.isHubScanning &&
state.hubScanIdentifier == identifier,
scanError =
state.hubScanError.takeIf {
state.hubScanIdentifier == identifier
},
onScan = { onScanHubSkill(identifier) },
)
}
}
},
onDismiss = {
hubShowDetail = null
onClearHubScan()
},
)
}
}
// ── Hub Landing (sources + featured) ───────────────────────────────────
@Composable
private fun HubLandingView(
state: SkillsUiState,
onLoadHubSources: () -> Unit,
onInstall: (String) -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
onOpenDetail: (HubSkill) -> Unit,
) {
when {
state.isHubSourcesLoading -> {
SkeletonListState()
}
state.hubSourcesError != null -> {
ErrorState(
message = state.hubSourcesError,
onRetry = onLoadHubSources,
)
}
state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_hub_landing_hint),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = listItemSpacing,
) {
if (state.hubSources.isNotEmpty()) {
item(key = "hub_sources_header") {
Column(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.skills_hub_sources_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier =
Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
state.hubSources.forEach { source ->
SourceChip(source = source)
}
}
}
}
}
if (state.hubFeatured.isNotEmpty()) {
item(key = "hub_featured_header") {
Column(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.skills_hub_featured_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.skills_hub_featured_subtitle),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
itemsIndexed(
state.hubFeatured,
key = { index, hubSkill ->
"featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index"
},
) { _, hubSkill ->
HubSkillCard(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling =
isInstalling && installingSkillName == hubSkill.name,
onClick = { onOpenDetail(hubSkill) },
)
}
}
}
}
}
}
@Composable
private fun SourceChip(source: SkillHubSource) {
val statusColors = LocalHermesStatusColors.current
val label =
buildString {
append(source.label ?: source.id)
when {
source.rateLimited == true -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_rate_limited))
}
source.available == false -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_offline))
}
source.searchable == false -> {
append(" · ")
append(stringResource(R.string.skills_hub_source_via_index))
}
}
}
val color =
when {
source.rateLimited == true -> statusColors.warning
source.available == false -> statusColors.error
else -> MaterialTheme.colorScheme.primary
}
Surface(
color = color.copy(alpha = 0.12f),
shape = RoundedCornerShape(50),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = color,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
}
// ── Hub security scan ──────────────────────────────────────────────────
@Composable
private fun SkillScanSection(
scanResult: SkillScanResponse?,
isScanning: Boolean,
scanError: String?,
onScan: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onScan,
enabled = !isScanning,
) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.skills_hub_scan))
}
}
when {
isScanning -> Unit
scanError != null -> {
Surface(
color = LocalHermesStatusColors.current.errorContainer,
shape = RoundedCornerShape(8.dp),
) {
Text(
text = scanError,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
scanResult != null -> {
ScanResultCard(result = scanResult)
}
}
}
}
@Composable
private fun ScanResultCard(result: SkillScanResponse) {
val statusColors = LocalHermesStatusColors.current
val verdictColor =
when (result.verdict) {
"safe" -> statusColors.success
"dangerous" -> statusColors.error
else -> statusColors.warning
}
val policyLabel =
when (result.policy) {
"allow" -> stringResource(R.string.skills_hub_scan_policy_allow)
"ask" -> stringResource(R.string.skills_hub_scan_policy_ask)
"block" -> stringResource(R.string.skills_hub_scan_policy_block)
else -> result.policy.orEmpty()
}
val policyColor =
when (result.policy) {
"allow" -> statusColors.success
"ask" -> statusColors.warning
"block" -> statusColors.error
else -> statusColors.neutral
}
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// ── Verdict header ──
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Shield,
contentDescription = null,
tint = verdictColor,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()),
style = MaterialTheme.typography.titleSmall,
color = verdictColor,
modifier = Modifier.weight(1f),
)
Surface(
color = policyColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(50),
) {
Text(
text = policyLabel,
style = MaterialTheme.typography.labelSmall,
color = policyColor,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
// ── Trust + finding count ──
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!result.trustLevel.isNullOrBlank()) {
Text(
text = result.trustLevel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text =
pluralStringResource(
R.plurals.skills_hub_scan_findings_count,
result.findings.size,
result.findings.size,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Severity tally ──
val severityColors =
mapOf(
"critical" to statusColors.error,
"high" to statusColors.error,
"medium" to statusColors.warning,
"low" to statusColors.neutral,
)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
listOf("critical", "high", "medium", "low").forEach { sev ->
val count = result.severityCounts[sev] ?: 0
if (count > 0) {
val sevColor = severityColors[sev] ?: statusColors.neutral
Surface(
color = sevColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(50),
) {
Text(
text = "$count $sev",
style = MaterialTheme.typography.labelSmall,
color = sevColor,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
}
if (result.findings.isEmpty()) {
Text(
text = stringResource(R.string.skills_hub_scan_no_findings),
style = MaterialTheme.typography.labelSmall,
color = statusColors.success,
)
}
}
// ── Policy reason ──
if (!result.policyReason.isNullOrBlank()) {
Text(
text = result.policyReason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Findings ──
if (result.findings.isNotEmpty()) {
HorizontalDivider()
result.findings.forEach { finding ->
ScanFindingRow(finding = finding)
}
}
}
}
}
@Composable
private fun ScanFindingRow(finding: SkillScanFinding) {
val statusColors = LocalHermesStatusColors.current
val severityColor =
when (finding.severity) {
"critical", "high" -> statusColors.error
"medium" -> statusColors.warning
else -> statusColors.neutral
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Surface(
color = severityColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(4.dp),
) {
Text(
text = finding.severity,
style = MaterialTheme.typography.labelSmall,
color = severityColor,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text(
text = finding.category,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
)
val location =
buildString {
finding.file?.let { append(it) }
finding.line?.let { append(":").append(it) }
}
if (location.isNotEmpty()) {
Text(
text = location,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
finding.description?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// ── Hub Skill Card ─────────────────────────────────────────────────────
@Composable
@@ -8,7 +8,9 @@ import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubSource
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -58,6 +60,16 @@ data class SkillsUiState(
val hubResults: List<HubSkill> = emptyList(),
val isHubSearching: Boolean = false,
val hubSearchError: String? = null,
// Hub sources + featured (landing, before any search)
val hubSources: List<SkillHubSource> = emptyList(),
val hubFeatured: List<HubSkill> = emptyList(),
val isHubSourcesLoading: Boolean = false,
val hubSourcesError: String? = null,
// Hub security scan
val hubScanIdentifier: String? = null,
val hubScanResult: SkillScanResponse? = null,
val isHubScanning: Boolean = false,
val hubScanError: String? = null,
// Hub detail preview (full description/content via preview endpoint)
val hubPreviewIdentifier: String? = null,
val hubPreviewContent: String? = null,
@@ -111,6 +123,9 @@ class SkillsViewModel(
if (mode == SkillsViewMode.INSTALLED && _uiState.value.skills.isEmpty()) {
loadSkills()
}
if (mode == SkillsViewMode.HUB && _uiState.value.hubSources.isEmpty()) {
loadHubSources()
}
}
fun setSourceFilter(source: String?) {
@@ -161,6 +176,94 @@ class SkillsViewModel(
}
}
// ── Hub sources + featured landing ────────────────────────────────────
/**
* Loads the configured hub sources and featured skills (the browse-hub
* landing state shown before any search runs).
*/
fun loadHubSources() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getSkillHubSources() } },
onStart = {
_uiState.update {
it.copy(isHubSourcesLoading = true, hubSourcesError = null)
}
},
onSuccess = { data ->
_uiState.update {
it.copy(
isHubSourcesLoading = false,
hubSources = data.sources,
hubFeatured = data.featured,
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isHubSourcesLoading = false,
hubSourcesError = "Failed to load hub sources: $errorMsg",
)
}
},
)
}
// ── Hub security scan ─────────────────────────────────────────────────
/**
* Runs the install-time security scan on a hub skill WITHOUT installing
* it, so the user sees the safety verdict before deciding.
*/
fun scanHubSkill(identifier: String) {
if (identifier.isBlank()) return
_uiState.update {
it.copy(
hubScanIdentifier = identifier,
isHubScanning = true,
hubScanResult = null,
hubScanError = null,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isHubScanning = false,
hubScanResult = result.data,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isHubScanning = false,
hubScanError = result.error.message,
)
}
}
}
}
}
fun clearHubScan() {
_uiState.update {
it.copy(
hubScanIdentifier = null,
hubScanResult = null,
isHubScanning = false,
hubScanError = null,
)
}
}
// ── Hub detail preview ──────────────────────────────────────────────────
/**
@@ -1,291 +0,0 @@
package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.DetailRow
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.skills.SkillsUiState
@Composable
internal fun HubBrowseView(
state: SkillsUiState,
hubQuery: String,
onHubQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
onClearSearch: () -> Unit,
onInstall: (String) -> Unit,
onPreviewHubSkill: (String) -> Unit,
isInstalling: Boolean,
installingSkillName: String?,
) {
var showDetail by remember { mutableStateOf<HubSkill?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = hubQuery,
onValueChange = onHubQueryChange,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) },
trailingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (hubQuery.isNotEmpty()) {
IconButton(onClick = {
onHubQueryChange("")
onClearSearch()
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.action_clear),
)
}
}
IconButton(
onClick = { onSearch(hubQuery) },
enabled = hubQuery.isNotBlank(),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.skills_hub_search_button),
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
if (hubQuery.isNotBlank()) {
onSearch(hubQuery)
}
},
),
)
when {
state.isHubSearching -> {
SkeletonListState()
}
state.hubSearchError != null -> {
ErrorState(
message = state.hubSearchError,
onRetry = { onSearch(hubQuery) },
)
}
state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> {
EmptyState(
icon = Icons.Filled.Extension,
title = stringResource(R.string.skills_hub_empty_results),
actionLabel = stringResource(R.string.empty_action_retry_search),
onAction = { onSearch(hubQuery) },
)
}
state.hubResults.isNotEmpty() -> {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = listItemSpacing,
) {
itemsIndexed(
state.hubResults,
key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" },
) { _, hubSkill ->
HubSkillCard(
hubSkill = hubSkill,
onInstall = { onInstall(hubSkill.name) },
isInstalling = isInstalling && installingSkillName == hubSkill.name,
onClick = {
showDetail = hubSkill
hubSkill.identifier?.let { onPreviewHubSkill(it) }
},
)
}
}
}
else -> {
EmptyState(
icon = Icons.Filled.Search,
title = stringResource(R.string.skills_hub_search_hint),
)
}
}
}
showDetail?.let { hubSkill ->
val previewReady = state.hubPreviewIdentifier == hubSkill.identifier
val fullContent =
if (previewReady) {
state.hubPreviewContent ?: hubSkill.description
} else {
hubSkill.description
}
DetailDialog(
title = hubSkill.name,
rows =
listOf(
DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category),
DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source),
DetailRow(stringResource(R.string.detail_dialog_description), fullContent),
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
),
actions =
if (previewReady && state.isHubPreviewing) {
{ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) }
} else {
null
},
onDismiss = { showDetail = null },
)
}
}
@Composable
private fun HubSkillCard(
hubSkill: HubSkill,
onInstall: () -> Unit,
isInstalling: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
) {
// ── Top row: name + source badge ──
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = hubSkill.name,
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
hubSkill.source?.let { source ->
Spacer(modifier = Modifier.width(6.dp))
SourceBadge(source = source)
}
}
// ── Category ──
hubSkill.category?.let { cat ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = cat,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Description ──
hubSkill.description?.let { desc ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
// ── Install button ──
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(
onClick = onInstall,
enabled = !isInstalling,
) {
if (isInstalling) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Icon(
imageVector = Icons.Filled.CloudDownload,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.skills_hub_install))
}
}
}
}
}
}