SFTP: ProxyJump, keepalive, disconnect UI, Connect to Terminal; fling fix, callback audit
SFTP standalone sessions now use shared buildJumpChain for ProxyJump support, start keepalive monitor for zombie detection, and have full disconnect/reconnect UI with SSH state monitoring. Tab overflow menu adds "Connect to Terminal". Fling scroll removes hard distance cap (flingMaxRows) so momentum decays naturally. Selection toolbar Google button wired. Mouse reporting gated by pro feature. Dead code (onSaveSnippet) removed. Write logging for paste debug. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2fd8308056
commit
429ad179ec
16 changed files with 175 additions and 59 deletions
|
|
@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
|
|||
|
||||
// Auto-generated — do not edit
|
||||
object BuildTimestamp {
|
||||
const val TIME = "2026-04-02 01:05:40"
|
||||
const val TIME = "2026-04-03 00:29:22"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ class SftpSessionManager {
|
|||
|
||||
private val sftpSessions = ConcurrentHashMap<String, SftpSession>()
|
||||
private val sshSessions = ConcurrentHashMap<String, SSHSession>()
|
||||
/** Jump host sessions kept alive for each SFTP session's tunnel. */
|
||||
private val jumpSessions = ConcurrentHashMap<String, List<SSHSession>>()
|
||||
|
||||
/**
|
||||
* Open a standalone SFTP session using the provided [sshSession].
|
||||
|
|
@ -25,7 +27,8 @@ class SftpSessionManager {
|
|||
*/
|
||||
suspend fun open(
|
||||
connectionId: Long,
|
||||
sshSession: SSHSession
|
||||
sshSession: SSHSession,
|
||||
jumpHostSessions: List<SSHSession> = emptyList()
|
||||
): String? {
|
||||
val client = sshSession.underlyingClient ?: return null
|
||||
val sftpId = "sftp_${connectionId}_${System.currentTimeMillis()}"
|
||||
|
|
@ -36,18 +39,24 @@ class SftpSessionManager {
|
|||
}
|
||||
sftpSessions[sftpId] = sftpSession
|
||||
sshSessions[sftpId] = sshSession
|
||||
FileLogger.log(TAG, "[SFTP] session $sftpId opened for connection $connectionId")
|
||||
if (jumpHostSessions.isNotEmpty()) {
|
||||
jumpSessions[sftpId] = jumpHostSessions
|
||||
}
|
||||
FileLogger.log(TAG, "[SFTP] session $sftpId opened for connection $connectionId (jumps=${jumpHostSessions.size})")
|
||||
sftpId
|
||||
} catch (e: Exception) {
|
||||
FileLogger.log(TAG, "[SFTP] failed to open session: ${e.message}")
|
||||
sftpSession.close()
|
||||
try { sshSession.disconnect() } catch (_: Exception) {}
|
||||
jumpHostSessions.forEach { try { it.disconnect() } catch (_: Exception) {} }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun get(sftpSessionId: String): SftpSession? = sftpSessions[sftpSessionId]
|
||||
|
||||
fun getSshSession(sftpSessionId: String): SSHSession? = sshSessions[sftpSessionId]
|
||||
|
||||
fun hasActiveSessions(): Boolean = sftpSessions.isNotEmpty()
|
||||
|
||||
fun close(sftpSessionId: String) {
|
||||
|
|
@ -55,6 +64,9 @@ class SftpSessionManager {
|
|||
FileLogger.log(TAG, "[SFTP] closing session $sftpSessionId")
|
||||
try { session.close() } catch (_: Exception) {}
|
||||
try { sshSessions.remove(sftpSessionId)?.disconnect() } catch (_: Exception) {}
|
||||
jumpSessions.remove(sftpSessionId)?.forEach {
|
||||
try { it.disconnect() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close all SFTP sessions (and their SSH connections) for cleanup. */
|
||||
|
|
|
|||
|
|
@ -785,7 +785,15 @@ class TerminalService : Service() {
|
|||
* Each hop connects and authenticates, then the next hop tunnels through it.
|
||||
* Returns null if no jump host is needed (jumpHostId is null).
|
||||
*/
|
||||
private suspend fun buildJumpChain(jumpHostId: Long?, sessionId: Long = -1): net.schmizz.sshj.SSHClient? {
|
||||
/**
|
||||
* @param outJumpSessions collects the created jump SSHSessions so the caller
|
||||
* can track them for monitoring/cleanup.
|
||||
*/
|
||||
private suspend fun buildJumpChain(
|
||||
jumpHostId: Long?,
|
||||
sessionId: Long = -1,
|
||||
outJumpSessions: MutableList<SSHSession>? = null
|
||||
): net.schmizz.sshj.SSHClient? {
|
||||
FileLogger.log(TAG, "buildJumpChain: jumpHostId=$jumpHostId")
|
||||
if (jumpHostId == null) return null
|
||||
|
||||
|
|
@ -833,6 +841,7 @@ class TerminalService : Service() {
|
|||
if (hopState is SessionState.Error) {
|
||||
throw Exception("Jump host ${hop.host}:${hop.port} auth failed: ${hopState.message}")
|
||||
}
|
||||
outJumpSessions?.add(hopSession)
|
||||
sessions[sessionId]?.jumpHostSessions?.add(hopSession)
|
||||
val client = hopSession.underlyingClient
|
||||
?: throw Exception("Jump host ${hop.host}:${hop.port} connected but SSH client is null")
|
||||
|
|
@ -1040,6 +1049,9 @@ class TerminalService : Service() {
|
|||
FileLogger.log(TAG, "writeInput[$sessionId]: entry NOT FOUND, dropping ${bytes.size} bytes (sessions=${sessions.keys})")
|
||||
return
|
||||
}
|
||||
if (bytes.size > 4) {
|
||||
FileLogger.log(TAG, "writeInput[$sessionId]: ${bytes.size} bytes")
|
||||
}
|
||||
entry.sshSession?.write(bytes)
|
||||
entry.localShellSession?.write(bytes)
|
||||
entry.telnetSession?.write(bytes)
|
||||
|
|
@ -1440,16 +1452,20 @@ class TerminalService : Service() {
|
|||
val helper = sshHelper
|
||||
val auth = helper.buildAuth(saved.keyId, connectionId)
|
||||
val sshSession = helper.createSession("SFTP")
|
||||
val jumpSessions = mutableListOf<SSHSession>()
|
||||
|
||||
return try {
|
||||
val proxyClient = buildJumpChain(saved.jumpHostId, outJumpSessions = jumpSessions)
|
||||
val config = SSHConnectionConfig(
|
||||
host = saved.host, port = saved.port, username = saved.username,
|
||||
auth = auth, tunnelOnly = true
|
||||
auth = auth, tunnelOnly = true, proxyClient = proxyClient
|
||||
)
|
||||
sshSession.connect(config)
|
||||
sftpSessionManager.open(connectionId, sshSession)
|
||||
sftpSessionManager.open(connectionId, sshSession, jumpSessions)
|
||||
} catch (e: Exception) {
|
||||
FileLogger.log(TAG, "[SFTP] standalone connect failed: ${e.message}")
|
||||
// Clean up jump sessions on failure
|
||||
jumpSessions.forEach { try { it.disconnect() } catch (_: Exception) {} }
|
||||
try { sshSession.disconnect() } catch (_: Exception) {}
|
||||
null
|
||||
}
|
||||
|
|
@ -1458,6 +1474,9 @@ class TerminalService : Service() {
|
|||
fun getSftpSession(sftpSessionId: String): com.roundingmobile.ssh.SftpSession? =
|
||||
sftpSessionManager.get(sftpSessionId)
|
||||
|
||||
fun getSftpSshSession(sftpSessionId: String): SSHSession? =
|
||||
sftpSessionManager.getSshSession(sftpSessionId)
|
||||
|
||||
fun closeSftpSession(sftpSessionId: String) = sftpSessionManager.close(sftpSessionId)
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -359,6 +359,7 @@ class MainActivity : AppCompatActivity() {
|
|||
val connId = mainViewModel.terminalService?.getSession(sid)?.savedConnectionId ?: return@SessionTabBar
|
||||
mainViewModel.openSftpTab(connId)
|
||||
},
|
||||
onConnectTerminal = { sid -> mainViewModel.connectTerminalFromSftp(sid) },
|
||||
onClose = { sid -> mainViewModel.disconnectSession(sid) },
|
||||
onRename = { sid, name -> mainViewModel.renameSession(sid, name) },
|
||||
onTheme = { sid, theme -> mainViewModel.setSessionTheme(sid, theme) },
|
||||
|
|
@ -386,7 +387,9 @@ class MainActivity : AppCompatActivity() {
|
|||
connectionId = info.connectionId,
|
||||
sftpSessionId = info.sftpSessionId,
|
||||
label = sessionLabels[sid] ?: "SFTP",
|
||||
tabError = info.error,
|
||||
terminalService = mainViewModel.terminalService,
|
||||
onReconnect = { mainViewModel.reconnectSftp(sid) },
|
||||
onBack = { mainViewModel.disconnectSession(sid) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -359,6 +359,18 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/** Opens a new SSH terminal session for the same connection as an SFTP tab. */
|
||||
fun connectTerminalFromSftp(sftpTabId: Long) {
|
||||
val info = _sftpTabs.value[sftpTabId] ?: return
|
||||
val connectionId = info.connectionId
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val saved = connectionDao.getById(connectionId) ?: return@launch
|
||||
launch(Dispatchers.Main) {
|
||||
connect(saved.id, saved.host, saved.port, saved.username, "", saved.keyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SFTP tabs ---
|
||||
|
||||
fun openSftpTab(connectionId: Long) {
|
||||
|
|
@ -398,6 +410,29 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
_sftpTabs.value = _sftpTabs.value + (tabId to info.copy(sftpSessionId = sftpSessionId))
|
||||
refreshSessionCounts()
|
||||
|
||||
// Monitor the SSH session behind this SFTP tab — update tab state on disconnect
|
||||
val sshSession = svc.getSftpSshSession(sftpSessionId)
|
||||
if (sshSession != null) {
|
||||
launch {
|
||||
sshSession.state.collect { state ->
|
||||
if (state is com.roundingmobile.ssh.SessionState.Disconnected ||
|
||||
state is com.roundingmobile.ssh.SessionState.Error) {
|
||||
val reason = when (state) {
|
||||
is com.roundingmobile.ssh.SessionState.Disconnected -> state.reason
|
||||
is com.roundingmobile.ssh.SessionState.Error -> state.message
|
||||
else -> null
|
||||
}
|
||||
val current = _sftpTabs.value[tabId] ?: return@collect
|
||||
_sftpTabs.value = _sftpTabs.value + (tabId to current.copy(
|
||||
error = reason ?: strings.getString(R.string.sftp_disconnected),
|
||||
sftpSessionId = null
|
||||
))
|
||||
refreshSessionCounts()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -416,6 +451,29 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
_sftpTabs.value = _sftpTabs.value + (tabId to info.copy(sftpSessionId = sftpSessionId, error = null))
|
||||
refreshSessionCounts()
|
||||
|
||||
// Monitor SSH state for disconnect detection
|
||||
val sshSession = svc.getSftpSshSession(sftpSessionId)
|
||||
if (sshSession != null) {
|
||||
launch {
|
||||
sshSession.state.collect { state ->
|
||||
if (state is com.roundingmobile.ssh.SessionState.Disconnected ||
|
||||
state is com.roundingmobile.ssh.SessionState.Error) {
|
||||
val reason = when (state) {
|
||||
is com.roundingmobile.ssh.SessionState.Disconnected -> state.reason
|
||||
is com.roundingmobile.ssh.SessionState.Error -> state.message
|
||||
else -> null
|
||||
}
|
||||
val current = _sftpTabs.value[tabId] ?: return@collect
|
||||
_sftpTabs.value = _sftpTabs.value + (tabId to current.copy(
|
||||
error = reason ?: strings.getString(R.string.sftp_disconnected),
|
||||
sftpSessionId = null
|
||||
))
|
||||
refreshSessionCounts()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ fun SessionTabBar(
|
|||
onPlusTap: () -> Unit,
|
||||
onDuplicate: (Long) -> Unit = {},
|
||||
onSftp: (Long) -> Unit = {},
|
||||
onConnectTerminal: (Long) -> Unit = {},
|
||||
onClose: (Long) -> Unit = {},
|
||||
onRename: (Long, String) -> Unit = { _, _ -> },
|
||||
onTheme: (Long, String) -> Unit = { _, _ -> },
|
||||
|
|
@ -147,6 +148,7 @@ fun SessionTabBar(
|
|||
onTap = { onSessionTap(sid) },
|
||||
onDuplicate = { onDuplicate(sid) },
|
||||
onSftp = { onSftp(sid) },
|
||||
onConnectTerminal = { onConnectTerminal(sid) },
|
||||
onRename = { newName -> onRename(sid, newName) },
|
||||
onTheme = { themeName -> onTheme(sid, themeName) },
|
||||
onClose = { onClose(sid) }
|
||||
|
|
@ -176,6 +178,7 @@ private fun SessionChip(
|
|||
onTap: () -> Unit,
|
||||
onDuplicate: () -> Unit,
|
||||
onSftp: () -> Unit,
|
||||
onConnectTerminal: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onTheme: (String) -> Unit,
|
||||
onClose: () -> Unit
|
||||
|
|
@ -286,6 +289,12 @@ private fun SessionChip(
|
|||
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 }
|
||||
|
|
|
|||
|
|
@ -100,8 +100,10 @@ fun SftpScreen(
|
|||
connectionId: Long,
|
||||
sftpSessionId: String?,
|
||||
label: String = "SFTP",
|
||||
tabError: String? = null,
|
||||
terminalService: TerminalService? = null,
|
||||
viewModel: SftpViewModel = hiltViewModel(),
|
||||
onReconnect: () -> Unit = {},
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -119,10 +121,7 @@ fun SftpScreen(
|
|||
val svc = terminalService ?: return@LaunchedEffect
|
||||
val id = sftpSessionId ?: return@LaunchedEffect
|
||||
val session = svc.getSftpSession(id)
|
||||
if (session == null) {
|
||||
onBack()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (session == null) return@LaunchedEffect
|
||||
viewModel.sftpSession = session
|
||||
viewModel.loadInitialDirectory()
|
||||
}
|
||||
|
|
@ -229,6 +228,27 @@ fun SftpScreen(
|
|||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Tab-level connection error (SSH disconnected)
|
||||
if (tabError != null || (sftpSessionId == null && !isLoading)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
tabError ?: stringResource(R.string.sftp_disconnected),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(onClick = onReconnect) {
|
||||
Text(stringResource(R.string.sftp_reconnect), color = AccentTeal)
|
||||
}
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
// Path breadcrumbs
|
||||
PathBreadcrumbs(
|
||||
path = currentPath,
|
||||
|
|
@ -242,6 +262,7 @@ fun SftpScreen(
|
|||
|
||||
// Error state
|
||||
error?.let { msg ->
|
||||
val sessionDead = viewModel.sftpSession == null || sftpSessionId == null
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -250,11 +271,20 @@ fun SftpScreen(
|
|||
) {
|
||||
Text(msg, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
TextButton(onClick = {
|
||||
viewModel.dismissError()
|
||||
viewModel.loadDirectory(currentPath)
|
||||
}) {
|
||||
Text(stringResource(R.string.sftp_retry), color = AccentTeal)
|
||||
if (sessionDead) {
|
||||
TextButton(onClick = {
|
||||
viewModel.dismissError()
|
||||
onReconnect()
|
||||
}) {
|
||||
Text(stringResource(R.string.sftp_reconnect), color = AccentTeal)
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = {
|
||||
viewModel.dismissError()
|
||||
viewModel.loadDirectory(currentPath)
|
||||
}) {
|
||||
Text(stringResource(R.string.sftp_retry), color = AccentTeal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,7 +151,14 @@ fun TerminalPane(
|
|||
terminalService?.resizePty(sessionId, cols, rows)
|
||||
}
|
||||
this.onUrlTapped = { url, _, _ -> onUrlTapped(url) }
|
||||
onSearchText = { text ->
|
||||
val encoded = java.net.URLEncoder.encode(text, "UTF-8")
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse("https://www.google.com/search?q=$encoded"))
|
||||
ctx.startActivity(intent)
|
||||
}
|
||||
onTapShowKeyboard = onShowKeyboard
|
||||
mouseReportingEnabled = proFeatures.mouseSupport
|
||||
softInputEnabled = !isCustomKeyboard
|
||||
// Localized strings for the library view
|
||||
setToolbarLabels(toolbarCopy, toolbarPaste, toolbarSelectAll, toolbarSearch)
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@
|
|||
<string name="session_rename">Renombrar</string>
|
||||
<string name="session_theme">Tema</string>
|
||||
<string name="session_connect_sftp">Conectar por SFTP</string>
|
||||
<string name="session_connect_terminal">Conectar a Terminal</string>
|
||||
<string name="session_rename_title">Renombrar sesión</string>
|
||||
<string name="session_rename_hint">Nombre de sesión</string>
|
||||
<string name="session_theme_title">Tema de sesión</string>
|
||||
|
|
@ -340,6 +341,7 @@
|
|||
<string name="sftp_refresh">Actualizar</string>
|
||||
<string name="sftp_empty_directory">Directorio vacío</string>
|
||||
<string name="sftp_retry">Reintentar</string>
|
||||
<string name="sftp_disconnected">Desconectado</string>
|
||||
<string name="sftp_connect_failed">Conexión fallida</string>
|
||||
<string name="sftp_reconnect">Reconectar</string>
|
||||
<string name="sftp_no_download_folder">Selecciona una carpeta de descarga primero</string>
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@
|
|||
<string name="session_rename">Byt namn</string>
|
||||
<string name="session_theme">Tema</string>
|
||||
<string name="session_connect_sftp">Anslut via SFTP</string>
|
||||
<string name="session_connect_terminal">Anslut till Terminal</string>
|
||||
<string name="session_rename_title">Byt namn på session</string>
|
||||
<string name="session_rename_hint">Sessionsnamn</string>
|
||||
<string name="session_theme_title">Sessionstema</string>
|
||||
|
|
@ -340,6 +341,7 @@
|
|||
<string name="sftp_refresh">Uppdatera</string>
|
||||
<string name="sftp_empty_directory">Tom katalog</string>
|
||||
<string name="sftp_retry">Försök igen</string>
|
||||
<string name="sftp_disconnected">Frånkopplad</string>
|
||||
<string name="sftp_connect_failed">Anslutningen misslyckades</string>
|
||||
<string name="sftp_reconnect">Återanslut</string>
|
||||
<string name="sftp_no_download_folder">Välj en nedladdningsmapp först</string>
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@
|
|||
<string name="session_rename">Rename</string>
|
||||
<string name="session_theme">Theme</string>
|
||||
<string name="session_connect_sftp">Connect via SFTP</string>
|
||||
<string name="session_connect_terminal">Connect to Terminal</string>
|
||||
<string name="session_rename_title">Rename session</string>
|
||||
<string name="session_rename_hint">Session name</string>
|
||||
<string name="session_theme_title">Session theme</string>
|
||||
|
|
@ -345,6 +346,7 @@
|
|||
<string name="sftp_refresh">Refresh</string>
|
||||
<string name="sftp_empty_directory">Empty directory</string>
|
||||
<string name="sftp_retry">Retry</string>
|
||||
<string name="sftp_disconnected">Disconnected</string>
|
||||
<string name="sftp_connect_failed">Connection failed</string>
|
||||
<string name="sftp_reconnect">Reconnect</string>
|
||||
<string name="sftp_no_download_folder">Select a download folder first</string>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# SSH Workbench — Future Features
|
||||
|
||||
> Ideas and placeholders for future versions. Not planned for v1.0.
|
||||
> Updated: 2026-04-02
|
||||
> Updated: 2026-04-03
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -12,15 +12,16 @@
|
|||
- **Mosh support** — UDP-based mobile shell for high-latency connections.
|
||||
- **Macro editor UI** — visual editor for keyboard macros.
|
||||
- **Additional language packs** — beyond EN/ES/SV.
|
||||
- **Remote file push / APK install** — TCP file receiver on localhost, exposed via remote port forward through SSH. Dev machine pushes files (e.g., APKs) to `duero:<port>` → phone receives, saves, and triggers install intent. Eliminates ADB dependency for remote deployment over mobile data. Simple binary protocol (filename + size + data + install flag), ~20-line Linux push script.
|
||||
|
||||
## Deferred from 2026-04-02 SFTP Standalone
|
||||
|
||||
- **SFTP disconnected bar UI** — SftpScreen should show Reconnect/Close bar when `SftpTabInfo.error` is set. Backend (`reconnectSftp`) is ready, UI not yet implemented.
|
||||
- ~~**SFTP disconnected bar UI**~~ — Done. SftpScreen shows error + Reconnect button; SSH state monitored for mid-session disconnects.
|
||||
- **SFTP auto-reconnect** — use connection's `autoReconnect` setting to auto-retry SFTP when its SSH drops.
|
||||
- **SFTP tab isConnected in tab bar** — `SessionTabBar` hardcodes `isConnected = true` for SFTP tabs. Should reflect actual SFTP connection state for proper disconnected colors.
|
||||
- **SFTP "Connect to Terminal"** — SFTP tab overflow menu should have option to open a terminal session for the same connection.
|
||||
- ~~**SFTP "Connect to Terminal"**~~ — Done. SFTP tab overflow menu opens terminal session for same connection.
|
||||
- **SFTP remember folder on reconnect** — after reconnect, navigate back to the folder the user was in.
|
||||
- **SFTP jump host support** — standalone SFTP should support ProxyJump chains.
|
||||
- ~~**SFTP jump host support**~~ — Done. `openSftpSession` uses `buildJumpChain`, jump sessions tracked in `SftpSessionManager`.
|
||||
- ~~**Extract TerminalService**~~ — Done. `SshConnectionHelper` extracted (auth, TOFU, session factory). TerminalService 1523→1384 lines.
|
||||
|
||||
## Deferred from 2026-04-02 Audit
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ 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, + 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, Connect to Terminal (SFTP tabs only), Rename, Theme, Close actions. Auto-scrolls to active tab.
|
||||
|
||||
**Tab color scheme** — type-specific colors preserved across all states:
|
||||
|
||||
|
|
@ -336,7 +336,7 @@ All `@HiltViewModel` with `viewModelScope`. ViewModels that need localized strin
|
|||
Owns all active sessions. Survives Activity backgrounding. Multiple simultaneous sessions keyed by `sessionId`. Uses `@AndroidEntryPoint` with Hilt-injected DAOs (SavedConnectionDao, PortForwardDao).
|
||||
|
||||
**Extracted managers** (delegated from TerminalService):
|
||||
- `SftpSessionManager` — Standalone SFTP session lifecycle. Owns both `sftpSessions` and `sshSessions` maps (each SFTP tab has its own SSH connection). `closeAll()` called in `onDestroy()`
|
||||
- `SftpSessionManager` — Standalone SFTP session lifecycle. Owns `sftpSessions`, `sshSessions`, and `jumpSessions` maps (each SFTP tab has its own SSH connection + optional jump chain). `closeAll()` called in `onDestroy()`
|
||||
- `PortForwardManager` — Opens/closes SSH port forwards (local, remote, dynamic/SOCKS5). Takes `Context` parameter for localized error messages
|
||||
- `StartupCommandRunner` — Executes startup commands after connect with silence detection
|
||||
- `SshConnectionHelper` — Shared auth lookup (`buildAuth`), TOFU host key verification, and `SSHSession` factory (`createSession`). Eliminates duplication between `connectSSH`, `buildJumpChain`, and `openSftpSession`
|
||||
|
|
@ -431,7 +431,8 @@ Full SFTP file browser rendered as an independent tab in Layer 1 (not a NavHost
|
|||
- ViewModel: `SftpViewModel` (`_allEntries` is `@Volatile` for thread-safe access)
|
||||
- SFTP tabs are independent from SSH sessions (own tab ID via `generateSessionId()`)
|
||||
- Keyboard and quick bar auto-hidden when SFTP tab is active (tab type check in MainActivity)
|
||||
- Supports duplicate, rename, close via tab overflow menu (no theme picker for SFTP tabs)
|
||||
- Supports duplicate, rename, close, **Connect to Terminal** via tab overflow menu (no theme picker for SFTP tabs)
|
||||
- Disconnected state: shows error message + Reconnect button (tab-level `tabError` or dead session detection); operation errors on dead sessions also route to Reconnect instead of Retry
|
||||
- **Critical**: `SftpSession.close()` must NOT call `SFTPClient.close()` — it sends a channel EOF that kills the parent SSH transport ("Broken transport; encountered EOF"). Just null out the reference.
|
||||
|
||||
#### `LocalShellSession`
|
||||
|
|
@ -565,7 +566,7 @@ SFTP file operations wrapping SSHJ's `SFTPClient` with a coroutines API. `sftpCl
|
|||
|
||||
**`SftpEntry`**: `name`, `path`, `isDirectory`, `size`, `modifiedTime`, `permissions` (Unix string like `drwxr-xr-x`)
|
||||
|
||||
**Standalone SFTP**: Each SFTP session owns its own SSH connection (`tunnelOnly=true` — auth + keepalive, no PTY/shell). `TerminalService.openSftpSession(connectionId)` looks up the connection from Room, builds auth, creates an `SSHSession`, connects, then opens the SFTP subsystem. `SftpSessionManager` tracks both SSH and SFTP sessions per tab; `closeAll()` called in `onDestroy()`. SFTP tabs are independent from terminal sessions — disconnecting terminal SSH does not affect SFTP.
|
||||
**Standalone SFTP**: Each SFTP session owns its own SSH connection (`tunnelOnly=true` — auth + keepalive + zombie monitor, no PTY/shell). `TerminalService.openSftpSession(connectionId)` looks up the connection from Room, builds auth via `SshConnectionHelper`, builds jump chain via `buildJumpChain` (ProxyJump support), creates an `SSHSession`, connects, then opens the SFTP subsystem. `SftpSessionManager` tracks SSH, SFTP, and jump sessions per tab; `closeAll()` called in `onDestroy()`. SFTP tabs are independent from terminal sessions — disconnecting terminal SSH does not affect SFTP. `MainViewModel` monitors each SFTP tab's SSH session state — on disconnect/error, updates `SftpTabInfo` to show the disconnect UI with Reconnect button.
|
||||
|
||||
### `HostKeyVerifyCallback`
|
||||
TOFU interface: `verifyHostKey(host, port, keyType, fingerprint) → HostKeyAction.ACCEPT/REJECT`
|
||||
|
|
|
|||
|
|
@ -234,8 +234,13 @@ class SSHSession {
|
|||
// Tunnel-only mode: just authenticate, don't allocate PTY or shell
|
||||
if (config.tunnelOnly) {
|
||||
sshClient = client
|
||||
lastReadTime.set(System.currentTimeMillis())
|
||||
_state.value = SessionState.Connected
|
||||
log("connect: tunnel-only mode — authenticated, no shell")
|
||||
// Monitor keepalive for zombie detection (same as shell sessions)
|
||||
keepaliveMonitorJob = scope.launch {
|
||||
monitorConnection()
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,14 +74,10 @@ class TerminalGestureHandler(
|
|||
private var flingVelocity = 0f // rows per second (positive = scroll into history)
|
||||
private var flingLastTime = 0L
|
||||
private var flingRowAccumulator = 0f
|
||||
private var flingTotalRows = 0 // total rows scrolled in this fling
|
||||
private var flingMaxRows = 0 // cap for this fling
|
||||
|
||||
// Fling tuning
|
||||
private val flingFriction = 2f // deceleration rate — lower = longer coast
|
||||
private val flingVelocityDamping = 0.75f // multiply raw velocity before starting fling (was 0.3)
|
||||
private val minFlingVelocityPx: Float // below this, no fling
|
||||
private val maxFlingScreens = 8 // cap at N screens of content (was 3)
|
||||
|
||||
init {
|
||||
val vc = ViewConfiguration.get(context)
|
||||
|
|
@ -142,14 +138,10 @@ class TerminalGestureHandler(
|
|||
val rawRowsPerSec = boostedVelocity / charHeight
|
||||
val dampedRowsPerSec = rawRowsPerSec * flingVelocityDamping
|
||||
|
||||
// Cap max distance: N screens worth of rows
|
||||
flingMaxRows = visibleRows * maxFlingScreens
|
||||
|
||||
// Positive velocityY = finger moved down = scroll into history = positive rows
|
||||
flingVelocity = dampedRowsPerSec
|
||||
flingLastTime = System.nanoTime()
|
||||
flingRowAccumulator = 0f
|
||||
flingTotalRows = 0
|
||||
isFling = true
|
||||
listener.onRequestRender()
|
||||
return true
|
||||
|
|
@ -297,26 +289,11 @@ class TerminalGestureHandler(
|
|||
return false
|
||||
}
|
||||
|
||||
// Distance cap reached — stop
|
||||
if (flingTotalRows >= flingMaxRows) {
|
||||
isFling = false
|
||||
return false
|
||||
}
|
||||
|
||||
// Accumulate fractional rows
|
||||
flingRowAccumulator += flingVelocity * dt
|
||||
val linesToScroll = flingRowAccumulator.toInt()
|
||||
if (linesToScroll != 0) {
|
||||
val capped = if (Math.abs(flingTotalRows + Math.abs(linesToScroll)) > flingMaxRows) {
|
||||
val remaining = flingMaxRows - flingTotalRows
|
||||
if (linesToScroll > 0) remaining else -remaining
|
||||
} else {
|
||||
linesToScroll
|
||||
}
|
||||
if (capped != 0) {
|
||||
dispatchScroll(capped)
|
||||
flingTotalRows += Math.abs(capped)
|
||||
}
|
||||
dispatchScroll(linesToScroll)
|
||||
flingRowAccumulator -= linesToScroll
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,9 +69,6 @@ class TerminalSurfaceView @JvmOverloads constructor(
|
|||
/** Callback invoked when a URL is tapped. Receives the URL string and screen coordinates. */
|
||||
var onUrlTapped: ((url: String, screenX: Float, screenY: Float) -> Unit)? = null
|
||||
|
||||
/** Callback invoked when "Save Snippet" is tapped from the selection toolbar. Receives the selected text. */
|
||||
var onSaveSnippet: ((text: String) -> Unit)? = null
|
||||
|
||||
/** Callback invoked when "Google" is tapped from the selection toolbar. Receives the selected text to search. */
|
||||
var onSearchText: ((text: String) -> Unit)? = null
|
||||
|
||||
|
|
@ -574,15 +571,6 @@ class TerminalSurfaceView @JvmOverloads constructor(
|
|||
clearSelection()
|
||||
}
|
||||
|
||||
private fun saveSelectionAsSnippet() {
|
||||
val screen = screenBuffer ?: return
|
||||
val text = selection.getSelectedText(screen)
|
||||
if (text.isNotEmpty()) {
|
||||
onSaveSnippet?.invoke(text)
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
private fun searchSelection() {
|
||||
val screen = screenBuffer ?: return
|
||||
val text = selection.getSelectedText(screen).trim()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue