Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,42 @@ package dev.plexus.shared.core.ui.common

import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
import kotlin.time.Instant

/**
* ISO-8601 文字列を相対時間文字列に変換する。
*
* 例: "just now", "5m ago", "3h ago", "2d ago"
* 30日以上前の場合は "MM/DD HH:MM" 形式にフォールバックする。
*/
@OptIn(ExperimentalTime::class)
internal fun String.toRelativeTimeString(): String = toRelativeTimeString(Clock.System.now())

/**
* 基準時刻を指定して相対時間文字列に変換する。
* テスト用途で [Clock] を注入したい場合に利用する。
*/
@OptIn(ExperimentalTime::class)
internal fun String.toRelativeTimeString(now: Instant): String =
runCatching {
val eventTime = Instant.parse(this)
val elapsed = now - eventTime

when {
elapsed < 60.seconds -> "just now"
elapsed < 60.minutes -> "${elapsed.inWholeMinutes}m ago"
elapsed < 24.hours -> "${elapsed.inWholeHours}h ago"
elapsed < 30.days -> "${elapsed.inWholeDays}d ago"
else -> this.toCompactIsoDateTime()
}
}.getOrElse { this }

@OptIn(ExperimentalTime::class)
internal fun String.toCompactIsoDateTime(): String =
runCatching {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package dev.plexus.shared.features.terminal.agentlist.components

import androidx.compose.foundation.Canvas
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
Expand All @@ -14,22 +13,30 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.plexus.shared.core.domain.model.terminal.Session
import dev.plexus.shared.core.ui.common.ListStateContent
import dev.plexus.shared.core.ui.theme.PlexusThemeTokens
Expand Down Expand Up @@ -74,24 +81,20 @@ fun SessionList(
.fillMaxWidth()
.padding(horizontal = dimens.space16, vertical = dimens.space12),
) {
// ヘッダー行: タイトル + Active バッジ | アイコンボタン
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(dimens.indicatorSizeSmall)
.background(
color = if (sessionCount > 0) extendedColors.success else MaterialTheme.colorScheme.outline,
shape = shapes.statusCircle,
),
TerminalIndicatorIcon(
tint = if (sessionCount > 0) extendedColors.success else MaterialTheme.colorScheme.outline,
modifier = Modifier.size(22.dp),
)

Spacer(modifier = Modifier.width(dimens.space8))
Spacer(modifier = Modifier.width(dimens.space10))

Text(
text = "TERMINAL SESSIONS",
text = "SESSIONS",
style =
MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
Expand All @@ -102,49 +105,46 @@ fun SessionList(
modifier = Modifier.weight(1f),
)

Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = onRefresh,
enabled = !isLoading,
shape = shapes.radiusXs,
contentPadding = PaddingValues(horizontal = dimens.space8),
modifier = Modifier.height(dimens.space28).widthIn(min = dimens.space48),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Sync",
modifier = Modifier.size(dimens.iconSizeSmall),
)
}

Spacer(modifier = Modifier.width(dimens.space6))
Spacer(modifier = Modifier.width(dimens.space12))

OutlinedButton(
onClick = onOpenGatewaySettings,
shape = shapes.radiusXs,
contentPadding = PaddingValues(horizontal = dimens.space8),
modifier = Modifier.height(dimens.space28).widthIn(min = dimens.space48),
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
modifier = Modifier.size(dimens.iconSizeSmall),
)
IconButton(
onClick = onRefresh,
enabled = !isLoading,
modifier = Modifier.size(dimens.space48),
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Sync",
modifier = Modifier.size(dimens.iconSizeMedium),
)
}

IconButton(
onClick = onOpenGatewaySettings,
modifier = Modifier.size(dimens.space48),
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
modifier = Modifier.size(dimens.iconSizeMedium),
)
}
}
}
}

Spacer(modifier = Modifier.height(dimens.space8))
Spacer(modifier = Modifier.height(dimens.space4))

Text(
text = "$sessionCount ACTIVE SESSIONS",
text = "$sessionCount Active",
style =
MaterialTheme.typography.monospaceLabelSmall.copy(fontWeight = FontWeight.Medium),
color = if (sessionCount > 0) extendedColors.success else MaterialTheme.colorScheme.outline,
modifier = Modifier.align(Alignment.End),
)
}

Expand Down Expand Up @@ -206,3 +206,47 @@ private fun SessionListContent(
}
}
}

/**
* Lucide Terminal 風のアイコン。
* 角丸矩形の中に `>_` プロンプトを描画する。
*/
@Composable
private fun TerminalIndicatorIcon(
tint: Color,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier) {
val strokeWidth = 1.4.dp.toPx()
val pad = size.width * 0.06f
val w = size.width - pad * 2
val h = size.height * 0.72f
val tl = Offset(pad, (size.height - h) / 2)

drawRoundRect(
color = tint,
topLeft = tl,
size = Size(w, h),
cornerRadius = CornerRadius(2.dp.toPx()),
style = Stroke(width = strokeWidth),
)

// > chevron
val cx = tl.x + w * 0.2f
val mx = tl.x + w * 0.4f
val ty = tl.y + h * 0.28f
val my = tl.y + h * 0.50f
val by = tl.y + h * 0.72f
drawLine(tint, Offset(cx, ty), Offset(mx, my), strokeWidth = strokeWidth, cap = StrokeCap.Round)
drawLine(tint, Offset(mx, my), Offset(cx, by), strokeWidth = strokeWidth, cap = StrokeCap.Round)

// _ cursor
drawLine(
tint,
Offset(tl.x + w * 0.52f, tl.y + h * 0.72f),
Offset(tl.x + w * 0.78f, tl.y + h * 0.72f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import dev.plexus.shared.core.domain.model.terminal.Session
import dev.plexus.shared.core.ui.common.testTagResourceId
import dev.plexus.shared.core.ui.common.toCompactIsoDateTime
import dev.plexus.shared.core.ui.common.toRelativeTimeString
import dev.plexus.shared.core.ui.theme.PlexusThemeTokens
import dev.plexus.shared.core.ui.theme.monospaceBody
import dev.plexus.shared.core.ui.theme.monospaceLabelSmall
Expand Down Expand Up @@ -156,7 +156,7 @@ fun SessionListItem(
}
Spacer(modifier = Modifier.width(dimens.space12))
Text(
text = session.lastActivity.toCompactIsoDateTime(),
text = session.lastActivity.toRelativeTimeString(),
style = MaterialTheme.typography.monospaceLabelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f),
)
Expand All @@ -170,63 +170,31 @@ fun SessionListItem(

Row(modifier = Modifier.fillMaxWidth()) {
if (headerTitle != null && headerPath != null) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = headerTitle,
style = MaterialTheme.typography.monospaceLabelSmall,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(dimens.space12))
Box(
FolderBadge(path = headerPath)
Spacer(modifier = Modifier.width(dimens.space8))
Text(
text = headerTitle,
style = MaterialTheme.typography.monospaceLabelSmall,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterEnd,
) {
Text(
text = headerPath,
style = MaterialTheme.typography.monospaceLabelSmall,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
)
} else if (headerPath != null) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterEnd,
) {
Text(
text = headerPath,
style = MaterialTheme.typography.monospaceLabelSmall,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
FolderBadge(path = headerPath)
} else if (headerTitle != null) {
Box(
Text(
text = headerTitle,
style = MaterialTheme.typography.monospaceLabelSmall,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = headerTitle,
style = MaterialTheme.typography.monospaceLabelSmall,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
)
}
}
if (headerTitle != null || headerPath != null) {
Expand Down Expand Up @@ -265,3 +233,38 @@ fun SessionListItem(
}
}
}

/** パス文字列から一番右側のディレクトリ名を抽出する。 */
private fun String.lastPathSegment(): String = trimEnd('/').substringAfterLast('/', this)

/**
* フォルダ風のバッジコンポーネント。
* パスの最後のセグメントだけをピル状のバッジで表示する。
*/
@Composable
private fun FolderBadge(
path: String,
modifier: Modifier = Modifier,
) {
val dimens = PlexusThemeTokens.dimens
val shapes = PlexusThemeTokens.shapes
val dirName = path.lastPathSegment()

Box(
modifier =
modifier
.clip(shapes.radiusXs)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(horizontal = dimens.space6, vertical = dimens.space2),
contentAlignment = Alignment.Center,
) {
Text(
text = dirName,
style = MaterialTheme.typography.monospaceLabelSmall,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Loading
Loading