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:
parent
34325f7119
commit
8cf34f7a8b
9 changed files with 136 additions and 16 deletions
|
|
@ -498,6 +498,9 @@ class MainActivity : AppCompatActivity() {
|
|||
onDuplicate = { connection, onCreated ->
|
||||
mainViewModel.duplicateConnection(connection, onCreated)
|
||||
},
|
||||
onSftp = { sourceSessionId ->
|
||||
mainViewModel.openSftpTab(sourceSessionId)
|
||||
},
|
||||
getSessionsForConnection = { savedConnectionId ->
|
||||
mainViewModel.getSessionsForConnection(savedConnectionId)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue