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:
jima 2026-04-03 00:39:03 +02:00
parent 2fd8308056
commit 429ad179ec
16 changed files with 175 additions and 59 deletions

View file

@ -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"
}

View file

@ -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. */

View file

@ -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)
}

View file

@ -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) }
)
}

View file

@ -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()
}
}
}
}
}
}

View file

@ -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 }

View file

@ -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)
}
}
}
}

View file

@ -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)

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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`

View file

@ -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
}

View file

@ -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
}

View file

@ -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()