Theme picker, drawer back button, mini numpad 15%, session drawer mode

Theme picker:
- EditConnection now shows "Default (<global theme>)" using the actual
  global theme from TerminalPreferences, not the hardcoded "Default Dark"
- Dropdown adds a Default entry at the top that clears the per-connection
  override (empty string)
- SessionTabBar theme picker replaced hardcoded 7-theme list with
  TerminalTheme.builtInThemes (all 20 themes), scrollable column, and a
  Default entry that clears the per-session override
- MainViewModel exposes globalThemeName StateFlow; threaded through
  SessionTabBar and SessionDrawerContent
- Fix setSessionTheme: blank name clears the override (was wrongly
  treating the literal "Default Dark" as "clear")

Drawer back button:
- Back in terminal pane closes the drawer first if drawer mode is active
  and the drawer is open; otherwise falls back to terminal → nav host
- Drawer state/scope declarations moved above BackHandler

Session drawer mode (already in progress pre-session):
- SessionDrawerBar (hamburger + active label + kebab) and
  SessionDrawerContent (session list with type-colored rows) composables
- New strings: switch_to_top_bar, switch_to_drawer, open_drawer,
  sessions_header (EN, ES, SV)

Mini numpad:
- widthPercent 10 → 15 in all three layout JSONs (EN, ES, SV)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-06 11:24:40 +02:00
parent bda8967ab1
commit f6f0e5e078
13 changed files with 534 additions and 26 deletions

View file

@ -30,8 +30,8 @@ android {
defaultConfig {
minSdk = 27
targetSdk = 36
versionCode = 11
versionName = "0.0.11"
versionCode = 20
versionName = "0.0.20"
applicationId = "com.roundingmobile.sshworkbench"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View file

@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
// Auto-generated — do not edit
object BuildTimestamp {
const val TIME = "2026-04-06 01:54:13"
const val TIME = "2026-04-06 11:23:39"
}

View file

@ -19,10 +19,16 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.statusBars
import androidx.compose.ui.platform.LocalConfiguration
@ -43,6 +49,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
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
@ -74,6 +81,8 @@ import com.roundingmobile.sshworkbench.terminal.TerminalService
import com.roundingmobile.sshworkbench.ui.navigation.Routes
import com.roundingmobile.sshworkbench.ui.navigation.SshWorkbenchNavGraph
import com.roundingmobile.sshworkbench.ui.screens.BiometricLockScreen
import com.roundingmobile.sshworkbench.ui.screens.SessionDrawerBar
import com.roundingmobile.sshworkbench.ui.screens.SessionDrawerContent
import com.roundingmobile.sshworkbench.ui.screens.SessionTabBar
import com.roundingmobile.sshworkbench.ui.screens.SftpScreen
import com.roundingmobile.sshworkbench.ui.screens.TerminalPane
@ -274,6 +283,7 @@ class MainActivity : AppCompatActivity() {
val sessionCounts by mainViewModel.sessionCounts.collectAsStateWithLifecycle()
val sessionLabels by mainViewModel.sessionLabels.collectAsStateWithLifecycle()
val sessionThemes by mainViewModel.sessionThemes.collectAsStateWithLifecycle()
val globalThemeName by mainViewModel.globalThemeName.collectAsStateWithLifecycle()
val pendingQrKey by mainViewModel.pendingQrKey.collectAsStateWithLifecycle()
val dialogRequest by mainViewModel.dialogRequest.collectAsStateWithLifecycle()
val navController = rememberNavController()
@ -311,6 +321,10 @@ class MainActivity : AppCompatActivity() {
val qbColorCustom = if (isCustomKeyboard) cqbColorCustom else aqbColorCustom
val isVerticalQb = qbPosition == "vertical_left" || qbPosition == "vertical_right"
val quickBarSize = rawQbSize
// Session navigation style preference
val sessionNavStyle by mainViewModel.terminalPrefs.sessionNavStyle.collectAsStateWithLifecycle("top_bar")
val isDrawerMode = sessionNavStyle == "drawer"
// Temporary hide state — tapping terminal or navigating to terminal resets it
var ckbHidden by remember { mutableStateOf(false) }
// Keyboard settings dialog state (opened from CKB gear menu)
@ -440,10 +454,19 @@ class MainActivity : AppCompatActivity() {
}
}
// Back handler: terminal → connection list
// Drawer state for session drawer mode (declared early so BackHandler can reference it)
val drawerState = rememberDrawerState(DrawerValue.Closed)
val drawerScope = rememberCoroutineScope()
// Back handler: drawer open → close drawer; else terminal → connection list
BackHandler(enabled = appState.currentPane == Pane.TERMINAL) {
FileLogger.log(TAG, "BackHandler: TERMINAL → NAV_HOST, activeSession=${appState.activeSessionId}") // TRACE
mainViewModel.switchToNavHost()
if (isDrawerMode && drawerState.isOpen) {
FileLogger.log(TAG, "BackHandler: closing drawer") // TRACE
drawerScope.launch { drawerState.close() }
} else {
FileLogger.log(TAG, "BackHandler: TERMINAL → NAV_HOST, activeSession=${appState.activeSessionId}") // TRACE
mainViewModel.switchToNavHost()
}
}
// In-app disconnect snackbar
@ -547,19 +570,49 @@ class MainActivity : AppCompatActivity() {
)
}
// Shared lambdas for session navigation (used by tab bar, drawer, and drawer overlay)
val onNewSession = {
mainViewModel.switchToNavHost()
navController.navigate(Routes.PICK_HOST) { launchSingleTop = true }
}
val onSwitchNavStyle = { style: String ->
lifecycleScope.launch { mainViewModel.terminalPrefs.setSessionNavStyle(style) }
Unit
}
Box(Modifier.fillMaxSize()) {
// --- Layer 1: Terminal surfaces + tab bar + shared keyboard ---
// Always in the tree — visibility toggled. Views survive navigation.
if (tabOrder.isNotEmpty()) {
val showTerminal = appState.currentPane == Pane.TERMINAL
// Terminal content Column (reused in both modes)
@Composable fun TerminalColumn() {
Column(
modifier = if (showTerminal) Modifier.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars)
else Modifier.fillMaxSize().alpha(0f)
.windowInsetsPadding(WindowInsets.navigationBars)
) {
// Session tab bar
// Session tab bar or drawer bar
if (showTerminal) {
if (isDrawerMode) {
SessionDrawerBar(
activeLabel = sessionLabels[appState.activeSessionId] ?: "",
tabType = tabTypes[appState.activeSessionId] ?: TabType.TERMINAL,
onHamburger = { drawerScope.launch { drawerState.open() } },
onPlusTap = onNewSession,
onKbSettings = {
if (isCustomKeyboard) showKbSettings = true
else showAqbSettings = true
},
kbSettingsLabel = if (isCustomKeyboard)
getString(R.string.keyboard_settings_title)
else getString(R.string.quick_bar_settings),
ckbVisible = isCustomKeyboard && !ckbHidden,
onToggleCkb = { ckbHidden = !ckbHidden }
)
} else {
SessionTabBar(
sessions = activeSessions,
tabOrder = tabOrder,
@ -567,10 +620,7 @@ class MainActivity : AppCompatActivity() {
activeSessionId = appState.activeSessionId,
sessionLabels = sessionLabels,
onSessionTap = { sid -> mainViewModel.switchToTerminal(sid) },
onPlusTap = {
mainViewModel.switchToNavHost()
navController.navigate(Routes.PICK_HOST) { launchSingleTop = true }
},
onPlusTap = onNewSession,
onDuplicate = { sid -> mainViewModel.duplicateSession(sid) },
onSftp = { sid ->
val connId = mainViewModel.terminalService?.getSession(sid)?.savedConnectionId ?: return@SessionTabBar
@ -582,6 +632,7 @@ class MainActivity : AppCompatActivity() {
onTheme = { sid, theme -> mainViewModel.setSessionTheme(sid, theme) },
sftpCapable = mainViewModel.getSftpCapableSessions(),
sessionThemes = sessionThemes,
globalThemeName = globalThemeName,
onKbSettings = {
if (isCustomKeyboard) showKbSettings = true
else showAqbSettings = true
@ -590,8 +641,10 @@ class MainActivity : AppCompatActivity() {
getString(R.string.keyboard_settings_title)
else getString(R.string.quick_bar_settings),
ckbVisible = isCustomKeyboard && !ckbHidden,
onToggleCkb = { ckbHidden = !ckbHidden }
onToggleCkb = { ckbHidden = !ckbHidden },
onSwitchToDrawer = { onSwitchNavStyle("drawer") }
)
}
}
// --- QB composable (reused in different positions) ---
@ -785,6 +838,10 @@ class MainActivity : AppCompatActivity() {
)
} // key(keyboardLanguage, numberRowMode)
}
} // end TerminalColumn
// Render terminal content — drawer overlay managed separately (no swipe gesture)
TerminalColumn()
}
// --- SFTP screens — rendered at Box level for proper edge-to-edge insets ---
@ -821,6 +878,72 @@ class MainActivity : AppCompatActivity() {
}
}
// --- Session drawer overlay (no swipe gesture — hamburger only) ---
if (isDrawerMode && tabOrder.isNotEmpty()) {
val showTerminal = appState.currentPane == Pane.TERMINAL
val drawerOpen = drawerState.isOpen
// Scrim
if (showTerminal && drawerOpen) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.zIndex(3f)
.clickable { drawerScope.launch { drawerState.close() } }
)
}
// Drawer panel
AnimatedVisibility(
visible = showTerminal && drawerOpen,
enter = slideInHorizontally { -it },
exit = slideOutHorizontally { -it },
modifier = Modifier.zIndex(4f)
) {
SessionDrawerContent(
sessions = activeSessions,
tabOrder = tabOrder,
tabTypes = tabTypes,
activeSessionId = appState.activeSessionId,
sessionLabels = sessionLabels,
onSessionTap = { sid ->
mainViewModel.switchToTerminal(sid)
drawerScope.launch { drawerState.close() }
},
onPlusTap = {
drawerScope.launch { drawerState.close() }
onNewSession()
},
onSwitchToTopBar = {
drawerScope.launch { drawerState.close() }
onSwitchNavStyle("top_bar")
},
onDuplicate = { sid ->
drawerScope.launch { drawerState.close() }
mainViewModel.duplicateSession(sid)
},
onSftp = { sid ->
drawerScope.launch { drawerState.close() }
val connId = mainViewModel.terminalService?.getSession(sid)?.savedConnectionId ?: return@SessionDrawerContent
mainViewModel.openSftpTab(connId)
},
onConnectTerminal = { sid ->
drawerScope.launch { drawerState.close() }
mainViewModel.connectTerminalFromSftp(sid)
},
onClose = { sid -> mainViewModel.disconnectSession(sid) },
onRename = { sid, name ->
mainViewModel.renameSession(sid, name)
},
onTheme = { sid, theme ->
mainViewModel.setSessionTheme(sid, theme)
},
sftpCapable = mainViewModel.getSftpCapableSessions(),
sessionThemes = sessionThemes,
globalThemeName = globalThemeName
)
}
}
// --- Layer 2: NavHost — visible when not in terminal ---
AnimatedVisibility(
visible = appState.currentPane == Pane.NAV_HOST,

View file

@ -157,6 +157,10 @@ class MainViewModel @Inject constructor(
private val _sessionThemes = MutableStateFlow<Map<Long, String>>(emptyMap())
val sessionThemes: StateFlow<Map<Long, String>> = _sessionThemes.asStateFlow()
/** Global default theme name from Settings — used as the fallback label when no override is set. */
val globalThemeName: StateFlow<String> = terminalPrefs.themeName
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Default Dark")
// --- Per-session font size (seeded from Room into SessionEntry on load) ---
private val dupNumRegex = Regex("\\((\\d+)\\)$")
@ -675,14 +679,14 @@ class MainViewModel @Inject constructor(
}
}
/** Set per-session theme override. Persisted in service entry. */
/** Set per-session theme override. Empty string clears the override (use global default). */
fun setSessionTheme(sessionId: Long, themeName: String) {
FileLogger.log(TAG, "setSessionTheme: $sessionId → '$themeName'") // TRACE
val theme = if (themeName == "Default Dark") null else themeName
val theme = themeName.ifBlank { null }
terminalService?.getSession(sessionId)?.customThemeName = theme
_sessionThemes.update {
if (theme == null) it - sessionId
else it + (sessionId to themeName)
else it + (sessionId to theme)
}
}

View file

@ -705,12 +705,14 @@ fun EditConnectionScreen(
)
var themeDropdownExpanded by remember { mutableStateOf(false) }
val globalThemeName by viewModel.globalThemeName.collectAsStateWithLifecycle()
val defaultLabel = stringResource(R.string.default_theme_label, globalThemeName)
ExposedDropdownMenuBox(
expanded = themeDropdownExpanded,
onExpandedChange = { themeDropdownExpanded = it }
) {
OutlinedTextField(
value = viewModel.themeName.ifBlank { stringResource(R.string.default_theme_label, "Default Dark") },
value = viewModel.themeName.ifBlank { defaultLabel },
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.theme)) },
@ -725,6 +727,13 @@ fun EditConnectionScreen(
expanded = themeDropdownExpanded,
onDismissRequest = { themeDropdownExpanded = false }
) {
DropdownMenuItem(
text = { Text(defaultLabel) },
onClick = {
viewModel.themeName = ""
themeDropdownExpanded = false
}
)
TERMINAL_THEMES.forEach { theme ->
DropdownMenuItem(
text = { Text(theme) },

View file

@ -10,6 +10,7 @@ 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -20,15 +21,18 @@ 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.KeyboardHide
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
@ -77,10 +81,8 @@ 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",
"Solarized Dark", "Solarized Light", "Gruvbox"
)
private val THEME_NAMES: List<String> =
com.roundingmobile.terminalview.TerminalTheme.builtInThemes.map { it.name }
/**
* Shared Compose session tab bar used in Layer 3 of the single-Activity architecture.
@ -110,6 +112,9 @@ fun SessionTabBar(
sftpCapable: Set<Long> = emptySet(),
/** Current per-session theme overrides (sessionId -> themeName) */
sessionThemes: Map<Long, String> = emptyMap(),
/** Global default theme name (shown as "Default (…)" in the picker when a session has no override) */
globalThemeName: String = "Default Dark",
onSwitchToDrawer: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Row(
@ -152,6 +157,7 @@ fun SessionTabBar(
isConnected = isConnected,
sftpCapable = !isSftp && sftpCapable.contains(sid),
currentTheme = sessionThemes[sid] ?: "",
globalThemeName = globalThemeName,
onTap = { onSessionTap(sid) },
onDuplicate = { onDuplicate(sid) },
onSftp = { onSftp(sid) },
@ -200,6 +206,13 @@ fun SessionTabBar(
onClick = { showBarMenu = false; onKbSettings() },
leadingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) }
)
if (onSwitchToDrawer != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.switch_to_drawer)) },
onClick = { showBarMenu = false; onSwitchToDrawer() },
leadingIcon = { Icon(Icons.Filled.Menu, contentDescription = null) }
)
}
}
}
}
@ -213,6 +226,7 @@ private fun SessionChip(
isConnected: Boolean,
sftpCapable: Boolean,
currentTheme: String,
globalThemeName: String,
onTap: () -> Unit,
onDuplicate: () -> Unit,
onSftp: () -> Unit,
@ -365,6 +379,7 @@ private fun SessionChip(
if (showThemeDialog) {
ThemePickerDialog(
currentTheme = currentTheme,
globalThemeName = globalThemeName,
onSelect = { theme -> showThemeDialog = false; onTheme(theme) },
onDismiss = { showThemeDialog = false }
)
@ -407,17 +422,30 @@ private fun RenameDialog(
@Composable
private fun ThemePickerDialog(
currentTheme: String,
globalThemeName: String,
onSelect: (String) -> Unit,
onDismiss: () -> Unit
) {
val defaultLabel = stringResource(R.string.default_theme_label, globalThemeName)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.session_theme_title)) },
text = {
Column {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// "Default" entry — clears the per-session override
val defaultSelected = currentTheme.isBlank()
Text(
text = defaultLabel,
color = if (defaultSelected) AccentTeal else TextPrimary,
fontWeight = if (defaultSelected) FontWeight.Bold else FontWeight.Normal,
fontSize = 15.sp,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect("") }
.padding(vertical = 10.dp, horizontal = 4.dp)
)
for (theme in THEME_NAMES) {
val isSelected = theme == currentTheme ||
(currentTheme.isBlank() && theme == "Default Dark")
val isSelected = theme == currentTheme
Text(
text = theme,
color = if (isSelected) AccentTeal else TextPrimary,
@ -434,3 +462,331 @@ private fun ThemePickerDialog(
confirmButton = {}
)
}
// ========================================================================
// Drawer mode — hamburger bar + drawer content panel
// ========================================================================
private val DrawerBg = Color(0xFF0D1117)
private val DrawerWidth = 260.dp
private val DrawerSectionHeader = Color(0xFF888888)
/**
* Minimal header bar replacing SessionTabBar when drawer mode is active.
* Shows hamburger icon + active session label + kebab menu.
*/
@Composable
fun SessionDrawerBar(
activeLabel: String,
tabType: com.roundingmobile.sshworkbench.ui.TabType = com.roundingmobile.sshworkbench.ui.TabType.TERMINAL,
onHamburger: () -> Unit,
onPlusTap: () -> Unit,
onKbSettings: () -> Unit = {},
kbSettingsLabel: String = "",
ckbVisible: Boolean = false,
onToggleCkb: () -> Unit = {},
modifier: Modifier = Modifier
) {
val typeAccent = when (tabType) {
com.roundingmobile.sshworkbench.ui.TabType.SFTP -> AccentAmber
com.roundingmobile.sshworkbench.ui.TabType.TELNET -> AccentViolet
else -> AccentTeal
}
Row(
modifier = modifier
.fillMaxWidth()
.background(TabBarBg)
.windowInsetsPadding(WindowInsets.statusBars)
.height(41.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onHamburger) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.open_drawer), tint = AccentTeal)
}
Text(
text = activeLabel,
color = typeAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
)
// Kebab menu (same items as tab bar)
var showBarMenu by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { showBarMenu = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options), tint = AccentTeal)
}
DropdownMenu(expanded = showBarMenu, onDismissRequest = { showBarMenu = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.new_session)) },
onClick = { showBarMenu = false; onPlusTap() },
leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null, tint = AccentTeal) }
)
if (ckbVisible) {
DropdownMenuItem(
text = { Text(stringResource(R.string.hide_keyboard)) },
onClick = { showBarMenu = false; onToggleCkb() },
leadingIcon = { Icon(Icons.Filled.KeyboardHide, contentDescription = null) }
)
} else {
DropdownMenuItem(
text = { Text(stringResource(R.string.show_keyboard)) },
onClick = { showBarMenu = false; onToggleCkb() },
leadingIcon = { Icon(Icons.Filled.Keyboard, contentDescription = null) }
)
}
DropdownMenuItem(
text = { Text(kbSettingsLabel.ifEmpty { stringResource(R.string.quick_bar_settings) }) },
onClick = { showBarMenu = false; onKbSettings() },
leadingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) }
)
}
}
}
}
/**
* Drawer content panel session list with type-colored rows,
* per-session overflow menus, new session action, and switch-to-top-bar footer.
*/
@Composable
fun SessionDrawerContent(
sessions: Map<Long, SessionState>,
tabOrder: List<Long>,
tabTypes: Map<Long, com.roundingmobile.sshworkbench.ui.TabType>,
activeSessionId: Long,
sessionLabels: Map<Long, String>,
onSessionTap: (Long) -> Unit,
onPlusTap: () -> Unit,
onSwitchToTopBar: () -> Unit,
onDuplicate: (Long) -> Unit = {},
onSftp: (Long) -> Unit = {},
onConnectTerminal: (Long) -> Unit = {},
onClose: (Long) -> Unit = {},
onRename: (Long, String) -> Unit = { _, _ -> },
onTheme: (Long, String) -> Unit = { _, _ -> },
sftpCapable: Set<Long> = emptySet(),
sessionThemes: Map<Long, String> = emptyMap(),
globalThemeName: String = "Default Dark",
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.width(DrawerWidth)
.fillMaxHeight()
.background(DrawerBg)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 16.dp)
) {
// Header
Text(
text = "SSH Workbench",
color = AccentTeal,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
// Sessions header
Text(
text = stringResource(R.string.sessions_header),
color = DrawerSectionHeader,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
// Session list
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
for (sid in tabOrder) {
val tabType = tabTypes[sid] ?: com.roundingmobile.sshworkbench.ui.TabType.TERMINAL
val isSftp = tabType == com.roundingmobile.sshworkbench.ui.TabType.SFTP
if (!isSftp && sid !in sessions) continue
DrawerSessionRow(
sid = sid,
label = sessionLabels[sid] ?: stringResource(R.string.session_fallback_label, sid),
tabType = tabType,
isActive = sid == activeSessionId,
isConnected = isSftp || sessions[sid] is SessionState.Connected,
sftpCapable = !isSftp && sftpCapable.contains(sid),
currentTheme = sessionThemes[sid] ?: "",
globalThemeName = globalThemeName,
onTap = { onSessionTap(sid) },
onDuplicate = { onDuplicate(sid) },
onSftp = { onSftp(sid) },
onConnectTerminal = { onConnectTerminal(sid) },
onRename = { name -> onRename(sid, name) },
onTheme = { theme -> onTheme(sid, theme) },
onClose = { onClose(sid) }
)
}
// + New session
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onPlusTap)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.Add, contentDescription = null, tint = AccentTeal, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(
text = stringResource(R.string.new_session_label),
color = AccentTeal,
fontSize = 14.sp
)
}
}
// Footer divider + switch to top bar
HorizontalDivider(color = Color(0xFF2A2A2A))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSwitchToTopBar)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.switch_to_top_bar),
color = DrawerSectionHeader,
fontSize = 13.sp
)
}
}
}
@Composable
private fun DrawerSessionRow(
sid: Long,
label: String,
tabType: com.roundingmobile.sshworkbench.ui.TabType,
isActive: Boolean,
isConnected: Boolean,
sftpCapable: Boolean,
currentTheme: String,
globalThemeName: String,
onTap: () -> Unit,
onDuplicate: () -> Unit,
onSftp: () -> Unit,
onConnectTerminal: () -> Unit,
onRename: (String) -> Unit,
onTheme: (String) -> Unit,
onClose: () -> Unit
) {
val isSftp = tabType == com.roundingmobile.sshworkbench.ui.TabType.SFTP
val typeAccent = when (tabType) {
com.roundingmobile.sshworkbench.ui.TabType.SFTP -> AccentAmber
com.roundingmobile.sshworkbench.ui.TabType.TELNET -> AccentViolet
else -> AccentTeal
}
val rowBg = if (isActive) Color(0xFF1A2530) else Color.Transparent
var showMenu by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showThemeDialog by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.fillMaxWidth()
.background(rowBg)
.clickable(onClick = onTap)
.padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Connected dot or red dot
Box(
modifier = Modifier
.size(8.dp)
.background(
if (isConnected) typeAccent else AccentRed,
CircleShape
)
)
Spacer(Modifier.width(10.dp))
Text(
text = label,
color = if (isActive) typeAccent else TextPrimary,
fontSize = 14.sp,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// 3-dot overflow menu
Icon(
Icons.Filled.MoreVert,
contentDescription = null,
tint = DrawerSectionHeader,
modifier = Modifier
.size(20.dp)
.clickable { showMenu = true }
.padding(start = 4.dp)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (isConnected && !isSftp) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_duplicate)) },
onClick = { showMenu = false; onDuplicate() }
)
}
if (isConnected && sftpCapable) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_connect_sftp)) },
onClick = { showMenu = false; onSftp() }
)
}
if (isSftp) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_connect_terminal)) },
onClick = { showMenu = false; onConnectTerminal() }
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.session_rename)) },
onClick = { showMenu = false; showRenameDialog = true }
)
if (!isSftp) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_theme)) },
onClick = { showMenu = false; showThemeDialog = true }
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.session_close)) },
onClick = { showMenu = false; onClose() }
)
}
}
if (showRenameDialog) {
RenameDialog(
currentName = label,
onConfirm = { newName -> showRenameDialog = false; onRename(newName) },
onDismiss = { showRenameDialog = false }
)
}
if (showThemeDialog) {
ThemePickerDialog(
currentTheme = currentTheme,
globalThemeName = globalThemeName,
onSelect = { theme -> showThemeDialog = false; onTheme(theme) },
onDismiss = { showThemeDialog = false }
)
}
}

View file

@ -97,6 +97,10 @@ class EditConnectionViewModel @Inject constructor(
val savedConnections: StateFlow<List<SavedConnection>> = connectionDao.getAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
/** Global default theme name from Settings — used as the fallback label when a connection has no override. */
val globalThemeName: StateFlow<String> = terminalPreferences.themeName
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Default Dark")
private val _connectSignal = MutableSharedFlow<Long>()
val connectSignal: SharedFlow<Long> = _connectSignal.asSharedFlow()

View file

@ -365,6 +365,10 @@
<string name="session_nav_top_bar">Barra superior</string>
<string name="session_nav_drawer">Panel lateral</string>
<string name="show_session_tab_bar">Mostrar barra de sesiones</string>
<string name="switch_to_top_bar">Cambiar a barra superior</string>
<string name="switch_to_drawer">Cambiar a panel lateral</string>
<string name="open_drawer">Abrir panel</string>
<string name="sessions_header">SESIONES</string>
<string name="new_session_label">Nueva sesión</string>
<string name="session_duplicate">Duplicar</string>
<string name="session_close">Cerrar</string>

View file

@ -364,6 +364,10 @@
<string name="session_nav_top_bar">Övre fält</string>
<string name="session_nav_drawer">Sidopanel</string>
<string name="show_session_tab_bar">Visa sessionsfält</string>
<string name="switch_to_top_bar">Byt till övre fält</string>
<string name="switch_to_drawer">Byt till sidopanel</string>
<string name="open_drawer">Öppna panel</string>
<string name="sessions_header">SESSIONER</string>
<string name="new_session_label">Ny session</string>
<string name="session_duplicate">Duplicera</string>
<string name="session_close">Stäng</string>

View file

@ -385,6 +385,10 @@
<string name="session_nav_top_bar">Top bar</string>
<string name="session_nav_drawer">Drawer</string>
<string name="show_session_tab_bar">Show session tab bar</string>
<string name="switch_to_top_bar">Switch to top bar</string>
<string name="switch_to_drawer">Switch to drawer</string>
<string name="open_drawer">Open drawer</string>
<string name="sessions_header">SESSIONS</string>
<string name="new_session_label">New session</string>
<string name="session_duplicate">Duplicate</string>
<string name="session_close">Close</string>

View file

@ -202,7 +202,7 @@
],
"mini": {
"widthPercent": 10,
"widthPercent": 15,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },

View file

@ -203,7 +203,7 @@
],
"mini": {
"widthPercent": 10,
"widthPercent": 15,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },

View file

@ -205,7 +205,7 @@
],
"mini": {
"widthPercent": 10,
"widthPercent": 15,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },