Tab bar: type-specific colors, SFTP auto-close, connection list SFTP menu

- Tab colors preserve type identity in all states: teal (SSH), amber (SFTP),
  violet (Telnet). Disconnected tabs show dimmed type color + red dot instead
  of uniform red, so you can tell session types apart at a glance.
- SFTP tabs auto-close when parent SSH disconnects (fixes frozen SFTP tab
  after overnight disconnect).
- SFTP tab labels use connection alias without "SFTP" suffix (amber color
  already identifies type), with host/username fallback to avoid "Session N".
- New SFTP Session option in connection list context menu and session picker
  bottom sheet (amber folder icon, requires connected SSH session).
- i18n: new_sftp_session string in EN/ES/SV.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-02 10:46:50 +02:00
parent 34325f7119
commit 8cf34f7a8b
9 changed files with 136 additions and 16 deletions

View file

@ -498,6 +498,9 @@ class MainActivity : AppCompatActivity() {
onDuplicate = { connection, onCreated ->
mainViewModel.duplicateConnection(connection, onCreated)
},
onSftp = { sourceSessionId ->
mainViewModel.openSftpTab(sourceSessionId)
},
getSessionsForConnection = { savedConnectionId ->
mainViewModel.getSessionsForConnection(savedConnectionId)
},

View file

@ -130,6 +130,15 @@ class MainViewModel @Inject constructor(
if (goneIds.isNotEmpty()) FileLogger.log(TAG, "sessionObserver: GONE sessions $goneIds") // TRACE
val sftpTabIds = _sftpTabs.value.keys
currentOrder.removeAll { it in goneIds && it !in sftpTabIds }
// Auto-close SFTP tabs whose backend session was closed
if (goneIds.isNotEmpty()) {
_sftpTabs.value.entries.toList().forEach { (tabId, info) ->
val sftpId = info.sftpSessionId ?: return@forEach
if (svc.getSftpSession(sftpId) == null) {
closeSftpTab(tabId)
}
}
}
for (id in newIds) {
if (id !in currentOrder) currentOrder.add(id)
}
@ -215,6 +224,14 @@ class MainViewModel @Inject constructor(
// In-app disconnect event (for snackbar)
val label = _sessionLabels.value[sid] ?: strings.getString(R.string.session_fallback_label, sid)
_disconnectEvent.tryEmit(label)
// Auto-close SFTP tabs tied to the same connection
val connId = svc.getSession(sid)?.savedConnectionId ?: 0L
if (connId > 0L) {
_sftpTabs.value.entries
.filter { it.value.connectionId == connId }
.map { it.key }
.forEach { tabId -> closeSftpTab(tabId) }
}
}
}
@ -338,16 +355,17 @@ class MainViewModel @Inject constructor(
val tabId = svc.generateSessionId()
// Build label
val baseLabel = _sessionLabels.value[sourceSessionId] ?: strings.getString(R.string.session_fallback_label, sourceSessionId)
// Build label — use connection alias (amber color already identifies SFTP)
val baseLabel = _sessionLabels.value[sourceSessionId]
?: entry.host?.let { h -> entry.username?.let { u -> "$u@$h" } ?: h }
?: strings.getString(R.string.session_fallback_label, sourceSessionId)
val cleanLabel = baseLabel.replace(Regex(" \\(\\d+\\)$"), "")
val sftpLabel = "$cleanLabel SFTP"
val existingNums = _sftpTabs.value.values
.filter { it.connectionId == connId }
.mapNotNull { dupNumRegex.find(_sessionLabels.value[it.tabId] ?: "")?.groupValues?.get(1)?.toIntOrNull() }
val hasSftpForConn = _sftpTabs.value.values.any { it.connectionId == connId }
val finalLabel = if (!hasSftpForConn) sftpLabel
else "$sftpLabel (${(existingNums.maxOrNull() ?: 1) + 1})"
val finalLabel = if (!hasSftpForConn) cleanLabel
else "$cleanLabel (${(existingNums.maxOrNull() ?: 1) + 1})"
// Create tab immediately (shows loading state), connect SFTP async
val info = SftpTabInfo(tabId = tabId, connectionId = connId, sftpSessionId = null, label = finalLabel)

View file

@ -41,6 +41,7 @@ fun SshWorkbenchNavGraph(
onDisconnect: (sessionId: Long) -> Unit,
onDisconnectAll: (savedConnectionId: Long) -> Unit,
onDuplicate: (connection: SavedConnection, onCreated: (Long) -> Unit) -> Unit,
onSftp: (sourceSessionId: Long) -> Unit = {},
getSessionsForConnection: (savedConnectionId: Long) -> List<TerminalService.SessionEntry>,
onCopyLog: () -> Unit = {},
onClearLog: () -> Unit = {},
@ -70,6 +71,7 @@ fun SshWorkbenchNavGraph(
navController.navigate(Routes.editConnection(newId))
}
},
onSftp = onSftp,
getSessionsForConnection = getSessionsForConnection,
onNavigateToEdit = { id ->
navController.navigate(Routes.editConnection(id))
@ -152,6 +154,7 @@ fun SshWorkbenchNavGraph(
navController.navigate(Routes.editConnection(newId))
}
},
onSftp = onSftp,
getSessionsForConnection = getSessionsForConnection,
onNavigateToEdit = { id ->
navController.navigate(Routes.editConnection(id))

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
@ -92,6 +93,7 @@ fun ConnectionListScreen(
onDisconnect: (sessionId: Long) -> Unit,
onDisconnectAll: (savedConnectionId: Long) -> Unit,
onDuplicate: (connection: SavedConnection) -> Unit,
onSftp: (sourceSessionId: Long) -> Unit = {},
getSessionsForConnection: (savedConnectionId: Long) -> List<TerminalService.SessionEntry>,
onNavigateToEdit: (Long) -> Unit,
onNavigateToSettings: () -> Unit,
@ -419,6 +421,24 @@ fun ConnectionListScreen(
}
)
// "New SFTP Session" — requires a connected SSH session
val connectedSession = sessions.firstOrNull { entry ->
val state = activeSessions[entry.sessionId]
state is SessionState.Connected && entry.sshSession != null
}
if (connectedSession != null) {
BottomSheetMenuItem(
icon = Icons.Filled.Folder,
label = stringResource(R.string.new_sftp_session),
onClick = {
scope.launch { sheetState.hide() }
sessionPickerConnection = null
onSftp(connectedSession.sessionId)
},
tint = Color(0xFFFFB020) // Amber
)
}
if (activeCount >= 2) {
BottomSheetMenuItem(
icon = Icons.Filled.LinkOff,
@ -480,6 +500,22 @@ fun ConnectionListScreen(
tint = if (hasActiveSessions) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface
)
if (hasActiveSessions) {
val connectedSession = getSessionsForConnection(connection.id).firstOrNull { entry ->
val state = activeSessions[entry.sessionId]
state is SessionState.Connected && entry.sshSession != null
}
if (connectedSession != null) {
BottomSheetMenuItem(
icon = Icons.Filled.Folder,
label = stringResource(R.string.new_sftp_session),
onClick = {
scope.launch { sheetState.hide() }
selectedConnection = null
onSftp(connectedSession.sessionId)
},
tint = Color(0xFFFFB020) // Amber
)
}
BottomSheetMenuItem(
icon = Icons.Filled.LinkOff,
label = stringResource(R.string.disconnect_all),

View file

@ -8,6 +8,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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@ -54,13 +56,23 @@ private val AccentAmber = Color(0xFFFFB020)
private val AccentViolet = Color(0xFFB080E0)
private val TextPrimary = Color(0xFFE0E0E0)
private val TextHint = Color(0xFFBBBBBB)
private val AccentRed = Color(0xFFE05050)
private val ChipShape = RoundedCornerShape(6.dp)
// Active (selected, connected) backgrounds — saturated type tint
private val ChipActive = Color(0xFF1A2B3C)
private val ChipActiveSftp = Color(0xFF2B2518)
private val ChipActiveTelnet = Color(0xFF251A2B)
// Inactive (not selected, connected) backgrounds — subtle type tint
private val ChipInactive = Color(0xFF151D26)
private val AccentRed = Color(0xFFE05050)
private val ChipDisconnected = Color(0xFF2B1518)
private val ChipShape = RoundedCornerShape(6.dp)
private val ChipInactiveSftp = Color(0xFF1E1A12)
private val ChipInactiveTelnet = Color(0xFF1C1520)
// Disconnected active (selected) backgrounds — dimmed type tint
private val ChipDisconnectedTeal = Color(0xFF152228)
private val ChipDisconnectedAmber = Color(0xFF221C10)
private val ChipDisconnectedViolet = Color(0xFF1E1422)
private val THEME_NAMES = listOf(
"Default Dark", "Dracula", "Monokai", "Nord",
@ -174,20 +186,31 @@ private fun SessionChip(
val isSftp = tabType == com.roundingmobile.sshworkbench.ui.TabType.SFTP
val isTelnet = tabType == com.roundingmobile.sshworkbench.ui.TabType.TELNET
val accent = when {
!isConnected -> AccentRed
// Type-specific accent — always preserves identity, dimmed when disconnected
val typeAccent = when {
isSftp -> AccentAmber
isTelnet -> AccentViolet
else -> AccentTeal
}
val accent = if (isConnected) typeAccent else typeAccent.copy(alpha = 0.5f)
val chipBg = when {
!isConnected && isActive -> ChipDisconnected
!isConnected -> ChipDisconnected.copy(alpha = 0.6f)
!isConnected && isActive -> when {
isSftp -> ChipDisconnectedAmber
isTelnet -> ChipDisconnectedViolet
else -> ChipDisconnectedTeal
}
!isConnected -> when {
isSftp -> ChipInactiveSftp
isTelnet -> ChipInactiveTelnet
else -> ChipInactive
}
isActive -> when {
isSftp -> ChipActiveSftp
isTelnet -> ChipActiveTelnet
else -> ChipActive
}
isSftp -> ChipInactiveSftp
isTelnet -> ChipInactiveTelnet
else -> ChipInactive
}
@ -203,22 +226,43 @@ private fun SessionChip(
else Modifier
)
.clickable(onClick = onTap)
.padding(start = 10.dp, end = 2.dp),
.padding(start = 8.dp, end = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Red dot for disconnected tabs
if (!isConnected) {
Box(
modifier = Modifier
.size(6.dp)
.background(AccentRed, CircleShape)
)
Spacer(modifier = Modifier.width(4.dp))
}
val textColor = when {
isActive -> accent
isConnected && (isSftp || isTelnet) -> typeAccent.copy(alpha = 0.6f)
!isConnected -> typeAccent.copy(alpha = 0.35f)
else -> TextPrimary
}
Text(
text = label,
color = if (isActive) accent else TextPrimary,
color = textColor,
fontSize = 11.sp,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
val iconTint = when {
isActive -> accent
isConnected && (isSftp || isTelnet) -> typeAccent.copy(alpha = 0.4f)
!isConnected -> typeAccent.copy(alpha = 0.25f)
else -> TextHint
}
Icon(
Icons.Filled.MoreVert,
contentDescription = null,
tint = if (isActive) accent else TextHint,
tint = iconTint,
modifier = Modifier
.size(18.dp)
.clickable { showMenu = true }

View file

@ -20,6 +20,7 @@
<!-- Connection List Screen -->
<string name="settings">Ajustes</string>
<string name="new_session">Nueva sesión</string>
<string name="new_sftp_session">Nueva sesión SFTP</string>
<string name="disconnect">Desconectar</string>
<string name="disconnect_all">Desconectar todo</string>
<string name="no_saved_connections">Sin conexiones guardadas</string>

View file

@ -20,6 +20,7 @@
<!-- Connection List Screen -->
<string name="settings">Inställningar</string>
<string name="new_session">Ny session</string>
<string name="new_sftp_session">Ny SFTP-session</string>
<string name="disconnect">Koppla från</string>
<string name="disconnect_all">Koppla från alla</string>
<string name="no_saved_connections">Inga sparade anslutningar</string>

View file

@ -19,6 +19,7 @@
<!-- Connection List Screen -->
<string name="settings">Settings</string>
<string name="new_session">New Session</string>
<string name="new_sftp_session">New SFTP Session</string>
<string name="disconnect">Disconnect</string>
<string name="disconnect_all">Disconnect All</string>
<string name="no_saved_connections">No saved connections</string>

View file

@ -232,12 +232,25 @@ Compose wrapper for the terminal surface + quick bar + custom keyboard:
- Sets localized strings on TerminalSurfaceView (copied-lines text, new-output format, toolbar labels)
#### `SessionTabBar`
Pure Compose tab bar: 41dp height, 135dp chips, teal accent for terminal tabs, amber accent for SFTP tabs, + button for new sessions. 3-dot overflow menu with Duplicate, SFTP, Rename, Theme, Close actions. Auto-scrolls to active tab.
Pure Compose tab bar: 41dp height, 135dp chips, + button for new sessions. 3-dot overflow menu with Duplicate, SFTP, Rename, Theme, Close actions. Auto-scrolls to active tab.
**Tab color scheme** — type-specific colors preserved across all states:
| State | SSH | SFTP | Telnet |
|-------|-----|------|--------|
| Active (selected) | Teal bg + teal text/border | Amber bg + amber text/border | Violet bg + violet text/border |
| Inactive (not selected) | Subtle teal bg + gray text | Subtle amber bg + amber 60% text | Subtle violet bg + violet 60% text |
| Disconnected active | Dimmed teal bg + teal 50% + red dot | Dimmed amber bg + amber 50% + red dot | Dimmed violet bg + violet 50% + red dot |
| Disconnected inactive | Neutral bg + teal 35% + red dot | Subtle amber bg + amber 35% + red dot | Subtle violet bg + violet 35% + red dot |
Disconnected tabs show a 6dp red dot before the label as a universal disconnect indicator, while preserving type identity through color.
#### `ConnectionListScreen`
Connection list with:
- Quick-connect parser (`user@host:port`)
- Session count badges per connection
- Long-press context menu: New Session, **New SFTP Session** (amber, folder icon, requires connected SSH session), Disconnect All, Edit, Duplicate, Delete
- Session picker bottom sheet (2+ sessions): session list with state/elapsed, Open new session, **New SFTP Session**, Disconnect All
- Context menu: **"New Session"** (green, with + icon) when connection has active sessions, **"Connect"** otherwise. Also: Disconnect All, Edit, Duplicate, Delete
- Color-coded connection cards (rendered by `ConnectionItemCard.kt`)
- Time formatting utilities in `TimeFormatUtils.kt` (formatTimestamp, formatDuration, formatRelativeTime, etc.)