v0.5.0: terminal themes, scroll control, quick commands (fix build issues)
This commit is contained in:
@@ -7,9 +7,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
@@ -136,7 +136,7 @@ fun SftpBrowserScreen(
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ArrowBack,
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "返回上级",
|
||||
tint = textPrimary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
@@ -220,7 +220,7 @@ fun SftpBrowserScreen(
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = accent
|
||||
),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(
|
||||
if (sftpReady) accent.copy(alpha = 0.5f) else border
|
||||
)
|
||||
@@ -243,7 +243,7 @@ fun SftpBrowserScreen(
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = accentBlue
|
||||
),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(
|
||||
if (sftpReady) accentBlue.copy(alpha = 0.5f) else border
|
||||
)
|
||||
@@ -399,7 +399,7 @@ private fun SftpFileItem(
|
||||
) {
|
||||
// 图标
|
||||
Icon(
|
||||
imageVector = if (fileInfo.isDir) Icons.Default.Folder else Icons.Default.InsertDriveFile,
|
||||
imageVector = if (fileInfo.isDir) Icons.Default.Folder else Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = if (fileInfo.isDir) Color(0xFFD29922) else accentBlue,
|
||||
modifier = Modifier.size(20.dp)
|
||||
|
||||
@@ -35,7 +35,6 @@ import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
// 深色终端主题色
|
||||
private val bg = Color(0xFF0D1117)
|
||||
private val surface = Color(0xFF161B22)
|
||||
private val surfaceAlt = Color(0xFF1C2333)
|
||||
@@ -49,31 +48,62 @@ private val danger = Color(0xFFF85149)
|
||||
|
||||
private const val PREFS_TERGENT_SSH = "tergent_ssh_configs"
|
||||
private const val KEY_SAVED_KEYS = "saved_private_keys"
|
||||
const val SSH_CONFIG_FILE_SAVE_KEY = "tergent_ssh_private_key"
|
||||
const val SSH_CONFIG_FILE_SAVE_KEY = "tergen_key"
|
||||
|
||||
/**
|
||||
* SSH 终端独立宿主页面。
|
||||
* 支持多个并行会话,Tab 切换。
|
||||
*/
|
||||
@Composable
|
||||
fun SshHost(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val configStorage = remember { SshConfigStorage(context) }
|
||||
|
||||
// 多会话支持:活跃会话列表
|
||||
val activeSessions = remember { mutableStateListOf<SshTerminalManager>() }
|
||||
var selectedTabIndex by remember { mutableIntStateOf(-1) }
|
||||
|
||||
var showConnectDialog by remember { mutableStateOf(false) }
|
||||
var pendingConfig by remember { mutableStateOf<SshConfig?>(null) }
|
||||
var pendingKeyConnect by remember { mutableStateOf<KeyAuthConnect?>(null) }
|
||||
var savedConfigs by remember { mutableStateOf(configStorage.loadConnections()) }
|
||||
var savedKeyPaths by remember { mutableStateOf(loadSavedKeyPaths(context)) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun openSessionKey(config: SshConfig, keyPath: String, passphrase: String?) {
|
||||
coroutineScope.launch {
|
||||
val mgr = SshTerminalManager(sessionId = "${config.host}:${config.port}")
|
||||
val r = mgr.connectWithKey(config, keyPath, passphrase)
|
||||
if (r) {
|
||||
configStorage.saveConnection(config)
|
||||
savedConfigs = configStorage.loadConnections()
|
||||
activeSessions.add(mgr)
|
||||
selectedTabIndex = activeSessions.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openSessionPassword(config: SshConfig) {
|
||||
coroutineScope.launch {
|
||||
val mgr = SshTerminalManager(sessionId = "${config.host}:${config.port}")
|
||||
val r = mgr.connect(config)
|
||||
if (r) {
|
||||
configStorage.saveConnection(config)
|
||||
savedConfigs = configStorage.loadConnections()
|
||||
activeSessions.add(mgr)
|
||||
selectedTabIndex = activeSessions.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSession(index: Int) {
|
||||
if (index < 0 || index >= activeSessions.size) return
|
||||
activeSessions[index].disconnect()
|
||||
activeSessions.removeAt(index)
|
||||
selectedTabIndex = when {
|
||||
activeSessions.isEmpty() -> -1
|
||||
selectedTabIndex >= activeSessions.size -> activeSessions.size - 1
|
||||
selectedTabIndex == index -> if (index >= activeSessions.size) activeSessions.size - 1 else index
|
||||
selectedTabIndex > index -> selectedTabIndex - 1
|
||||
else -> selectedTabIndex
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SSH 密钥文件选择器
|
||||
val keyFilePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
@@ -88,106 +118,43 @@ fun SshHost(
|
||||
if (savedPath != null) {
|
||||
saveKeyPath(context, savedPath)
|
||||
savedKeyPaths = loadSavedKeyPaths(context)
|
||||
doConnectWithKey(pending.config, savedPath, pending.passphrase)
|
||||
openSessionKey(pending.config, savedPath, pending.passphrase)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 密码认证连接
|
||||
LaunchedEffect(pendingConfig) {
|
||||
val config = pendingConfig ?: return@LaunchedEffect
|
||||
pendingConfig = null
|
||||
doConnectWithPassword(config)
|
||||
openSessionPassword(config)
|
||||
}
|
||||
|
||||
// 密钥认证连接(启动文件选择器)
|
||||
LaunchedEffect(pendingKeyConnect) {
|
||||
if (pendingKeyConnect != null) {
|
||||
keyFilePickerLauncher.launch(arrayOf("*/*"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 连接方法 ──
|
||||
fun doConnectWithPassword(config: SshConfig) {
|
||||
coroutineScope.launch {
|
||||
val manager = SshTerminalManager(sessionId = "${config.host}:${config.port}")
|
||||
val result = manager.connect(config)
|
||||
if (result) {
|
||||
configStorage.saveConnection(config)
|
||||
savedConfigs = configStorage.loadConnections()
|
||||
activeSessions.add(manager)
|
||||
selectedTabIndex = activeSessions.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun doConnectWithKey(config: SshConfig, keyPath: String, passphrase: String?) {
|
||||
coroutineScope.launch {
|
||||
val manager = SshTerminalManager(sessionId = "${config.host}:${config.port}")
|
||||
val result = manager.connectWithKey(config, keyPath, passphrase)
|
||||
if (result) {
|
||||
configStorage.saveConnection(config)
|
||||
savedConfigs = configStorage.loadConnections()
|
||||
activeSessions.add(manager)
|
||||
selectedTabIndex = activeSessions.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 关闭 Tab ──
|
||||
fun closeSession(index: Int) {
|
||||
if (index < 0 || index >= activeSessions.size) return
|
||||
activeSessions[index].disconnect()
|
||||
activeSessions.removeAt(index)
|
||||
// 调整选中索引
|
||||
selectedTabIndex = if (activeSessions.isEmpty()) {
|
||||
-1
|
||||
} else if (selectedTabIndex >= activeSessions.size) {
|
||||
activeSessions.size - 1
|
||||
} else if (selectedTabIndex == index) {
|
||||
if (index >= activeSessions.size) activeSessions.size - 1 else index
|
||||
} else if (selectedTabIndex > index) {
|
||||
selectedTabIndex - 1
|
||||
} else {
|
||||
selectedTabIndex
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(bg)
|
||||
modifier = modifier.fillMaxSize().background(bg)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── 标题栏 ──
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = surface,
|
||||
shadowElevation = 0.dp
|
||||
color = surface, shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "🔧 Tergent SSH",
|
||||
color = textPrimary,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text("🔧 Tergent SSH", color = textPrimary, fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
if (activeSessions.isNotEmpty()) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "(${activeSessions.size})",
|
||||
color = textSecondary,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
Text("(${activeSessions.size})", color = textSecondary, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
@@ -201,82 +168,52 @@ fun SshHost(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab 栏(有会话时显示) ──
|
||||
if (activeSessions.isNotEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color(0xFF0A0E14),
|
||||
shadowElevation = 0.dp
|
||||
color = Color(0xFF0A0E14), shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
activeSessions.forEachIndexed { index, manager ->
|
||||
val tabState by manager.connectionState.collectAsState()
|
||||
activeSessions.forEachIndexed { index, mgr ->
|
||||
val tabState by mgr.connectionState.collectAsState()
|
||||
val isSelected = index == selectedTabIndex
|
||||
|
||||
Surface(
|
||||
onClick = { selectedTabIndex = index },
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp),
|
||||
color = if (isSelected) surface else Color(0xFF0D1117),
|
||||
border = if (isSelected) null else null
|
||||
color = if (isSelected) surface else Color(0xFF0D1117)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp)
|
||||
modifier = Modifier.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp)
|
||||
) {
|
||||
// 连接状态指示点
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.background(
|
||||
if (tabState.connected) accent else danger,
|
||||
RoundedCornerShape(3.dp)
|
||||
)
|
||||
modifier = Modifier.size(6.dp).background(
|
||||
if (tabState.connected) accent else danger,
|
||||
RoundedCornerShape(3.dp)
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
// 主机名
|
||||
Text(
|
||||
text = tabState.displayName.ifEmpty { "未连接" },
|
||||
color = if (isSelected) textPrimary else textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 120.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
onClick = { closeSession(index) },
|
||||
modifier = Modifier.size(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = textTertiary,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
IconButton(onClick = { closeSession(index) }, modifier = Modifier.size(20.dp)) {
|
||||
Icon(Icons.Default.Close, contentDescription = "关闭", tint = textTertiary, modifier = Modifier.size(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新建 Tab 按钮
|
||||
Surface(
|
||||
onClick = { showConnectDialog = true },
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = Color(0xFF0D1117)
|
||||
shape = RoundedCornerShape(6.dp), color = Color(0xFF0D1117)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), contentAlignment = Alignment.Center) {
|
||||
Text("+", color = accentBlue, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
@@ -284,64 +221,41 @@ fun SshHost(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 主内容区 ──
|
||||
if (activeSessions.isEmpty()) {
|
||||
// 没有会话:显示连接列表
|
||||
if (selectedTabIndex < 0) {
|
||||
ConnectionListView(
|
||||
savedConfigs = savedConfigs,
|
||||
onConnect = { showConnectDialog = true },
|
||||
onDelete = { config ->
|
||||
configStorage.deleteConnection(config.host, config.port)
|
||||
savedConfigs = configStorage.loadConnections()
|
||||
},
|
||||
onQuickConnect = { config ->
|
||||
doConnectWithPassword(config)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (selectedTabIndex < 0 || activeSessions.isEmpty()) {
|
||||
ConnectionListView(
|
||||
savedConfigs = savedConfigs,
|
||||
onQuickConnect = { config -> openSessionPassword(config) },
|
||||
onDelete = { config ->
|
||||
configStorage.deleteConnection(config.host, config.port)
|
||||
savedConfigs = configStorage.loadConnections()
|
||||
}
|
||||
)
|
||||
} else if (selectedTabIndex >= 0 && selectedTabIndex < activeSessions.size) {
|
||||
val currentManager = activeSessions[selectedTabIndex]
|
||||
val currentMgr = activeSessions[selectedTabIndex]
|
||||
var showSftp by remember { mutableStateOf(false) }
|
||||
|
||||
if (showSftp) {
|
||||
// SFTP 文件浏览模式
|
||||
SftpBrowserScreen(
|
||||
manager = currentManager,
|
||||
manager = currentMgr,
|
||||
onSwitchToTerminal = { showSftp = false },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
)
|
||||
} else {
|
||||
// 终端模式
|
||||
SshTerminalScreen(
|
||||
manager = currentManager,
|
||||
onDisconnect = {
|
||||
closeSession(selectedTabIndex)
|
||||
},
|
||||
manager = currentMgr,
|
||||
onDisconnect = { closeSession(selectedTabIndex) },
|
||||
onShowConnectDialog = { showConnectDialog = true },
|
||||
onShowSftp = { showSftp = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 连接对话框 ──
|
||||
if (showConnectDialog) {
|
||||
SshConnectDialog(
|
||||
onDismiss = { showConnectDialog = false },
|
||||
onConnectPassword = { config ->
|
||||
showConnectDialog = false
|
||||
pendingConfig = config
|
||||
},
|
||||
onConnectKey = { config, passphrase ->
|
||||
showConnectDialog = false
|
||||
pendingKeyConnect = KeyAuthConnect(config, passphrase)
|
||||
},
|
||||
onConnectPassword = { config -> showConnectDialog = false; pendingConfig = config },
|
||||
onConnectKey = { config, passphrase -> showConnectDialog = false; pendingKeyConnect = KeyAuthConnect(config, passphrase) },
|
||||
savedConfigs = savedConfigs,
|
||||
onFillConfig = { }
|
||||
)
|
||||
@@ -349,23 +263,15 @@ fun SshHost(
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 连接列表视图(无活动会话时显示)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ConnectionListView(
|
||||
private fun ColumnScope.ConnectionListView(
|
||||
savedConfigs: List<SshConfig>,
|
||||
onConnect: () -> Unit,
|
||||
onDelete: (SshConfig) -> Unit,
|
||||
onQuickConnect: (SshConfig) -> Unit
|
||||
onQuickConnect: (SshConfig) -> Unit,
|
||||
onDelete: (SshConfig) -> Unit
|
||||
) {
|
||||
if (savedConfigs.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(savedConfigs, key = { "${it.host}:${it.port}" }) { config ->
|
||||
@@ -377,40 +283,21 @@ private fun ConnectionListView(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 空状态提示
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "没有已保存的连接",
|
||||
color = textTertiary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Text("没有已保存的连接", color = textTertiary, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "点击左上角「新建连接」开始",
|
||||
color = textTertiary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text("点击右上角「新建连接」开始", color = textTertiary, fontSize = 12.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "或点击 Tab 栏 + 新建会话",
|
||||
color = textTertiary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text("或点击 Tab 栏 + 新建会话", color = textTertiary, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 已保存连接列表项
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SavedConnectionItem(
|
||||
config: SshConfig,
|
||||
@@ -418,156 +305,69 @@ private fun SavedConnectionItem(
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onConnect() },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = surfaceAlt,
|
||||
tonalElevation = 0.dp
|
||||
modifier = Modifier.fillMaxWidth().clickable { onConnect() },
|
||||
shape = RoundedCornerShape(8.dp), color = surfaceAlt, tonalElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 图标
|
||||
Icon(
|
||||
Icons.Default.Computer,
|
||||
contentDescription = null,
|
||||
tint = accentBlue,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Computer, contentDescription = null, tint = accentBlue, modifier = Modifier.size(32.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
// 名称和主机信息
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = config.displayName.ifEmpty { config.host },
|
||||
color = textPrimary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(config.displayName.ifEmpty { config.host }, color = textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "${config.host}:${config.port}",
|
||||
color = textSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Text("${config.host}:${config.port}", color = textSecondary, fontSize = 12.sp, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilledTonalButton(
|
||||
onClick = onConnect,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = accent.copy(alpha = 0.15f),
|
||||
contentColor = accent
|
||||
),
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
|
||||
) {
|
||||
Text("连接", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { showDeleteConfirm = true },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "删除",
|
||||
tint = danger.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
colors = ButtonDefaults.filledTonalButtonColors(containerColor = accent.copy(alpha = 0.15f), contentColor = accent),
|
||||
modifier = Modifier.height(32.dp), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
|
||||
) { Text("连接", fontSize = 12.sp) }
|
||||
IconButton(onClick = { showDeleteConfirm = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "删除", tint = danger.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = false },
|
||||
containerColor = surface,
|
||||
titleContentColor = textPrimary,
|
||||
textContentColor = textSecondary,
|
||||
containerColor = surface, titleContentColor = textPrimary, textContentColor = textSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
title = {
|
||||
Text("删除连接", color = textPrimary)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"确定删除「${config.displayName.ifEmpty { config.host }}」?",
|
||||
color = textSecondary
|
||||
)
|
||||
},
|
||||
title = { Text("删除连接", color = textPrimary) },
|
||||
text = { Text("确定删除「${config.displayName.ifEmpty { config.host }}」?", color = textSecondary) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onDelete()
|
||||
showDeleteConfirm = false
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = danger)
|
||||
) {
|
||||
Button(onClick = { onDelete(); showDeleteConfirm = false }, colors = ButtonDefaults.buttonColors(containerColor = danger)) {
|
||||
Text("删除", color = Color.White)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||
Text("取消", color = textSecondary)
|
||||
}
|
||||
}
|
||||
dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("取消", color = textSecondary) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class KeyAuthConnect(
|
||||
val config: SshConfig,
|
||||
val passphrase: String?
|
||||
)
|
||||
private data class KeyAuthConnect(val config: SshConfig, val passphrase: String?)
|
||||
|
||||
internal fun readUriContent(context: android.content.Context, uri: Uri): String? {
|
||||
return try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
input.bufferedReader().use { it.readText() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
context.contentResolver.openInputStream(uri)?.use { input -> input.bufferedReader().use { it.readText() } }
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
internal fun savePrivateKeyToInternal(context: android.content.Context, fileName: String, content: String): String? {
|
||||
return try {
|
||||
val file = File(context.filesDir, fileName)
|
||||
FileOutputStream(file).use { it.write(content.toByteArray()) }
|
||||
file.absolutePath
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
val file = File(context.filesDir, fileName); FileOutputStream(file).use { it.write(content.toByteArray()) }; file.absolutePath
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载已保存的密钥文件路径列表。
|
||||
*/
|
||||
internal fun loadSavedKeyPaths(context: android.content.Context): List<String> {
|
||||
val prefs = context.getSharedPreferences("tergent_ssh_configs", android.content.Context.MODE_PRIVATE)
|
||||
val raw = prefs.getString(SSH_CONFIG_FILE_SAVE_KEY, null) ?: return emptyList()
|
||||
return raw.split("|").filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存一个密钥文件路径。
|
||||
*/
|
||||
internal fun saveKeyPath(context: android.content.Context, path: String) {
|
||||
val prefs = context.getSharedPreferences("tergent_ssh_configs", android.content.Context.MODE_PRIVATE)
|
||||
val existing = loadSavedKeyPaths(context).toMutableList()
|
||||
if (path !in existing) {
|
||||
existing.add(path)
|
||||
prefs.edit().putString(SSH_CONFIG_FILE_SAVE_KEY, existing.joinToString("|")).apply()
|
||||
}
|
||||
if (path !in existing) { existing.add(path); prefs.edit().putString(SSH_CONFIG_FILE_SAVE_KEY, existing.joinToString("|")).apply() }
|
||||
}
|
||||
|
||||
@@ -261,8 +261,8 @@ class SshTerminalManager(
|
||||
name = entry.filename,
|
||||
isDir = attrs.isDir,
|
||||
size = attrs.size,
|
||||
lastModified = attrs.mtime * 1000L, // JSch returns seconds
|
||||
permissions = attrs.permissionsToString()
|
||||
lastModified = attrs.mTime * 1000L, // JSch returns seconds
|
||||
permissions = sftpPermissionsToString(attrs)
|
||||
)
|
||||
}.sortedWith(compareByDescending<SftpFileInfo> { it.isDir }.thenBy { it.name.lowercase() })
|
||||
} catch (e: Exception) {
|
||||
@@ -328,3 +328,22 @@ class SshTerminalManager(
|
||||
|
||||
fun isConnected(): Boolean = _connectionState.value.connected
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSch SftpATTRS 权限 mask 转为 Unix 风格字符串(如 rwxr-xr-x)。
|
||||
*/
|
||||
internal fun sftpPermissionsToString(attrs: com.jcraft.jsch.SftpATTRS): String {
|
||||
val perm = attrs.permissions
|
||||
val sb = StringBuilder(10)
|
||||
sb.append(if (attrs.isDir) 'd' else if (attrs.isLink) 'l' else '-')
|
||||
sb.append(if (perm and 0x100 != 0) 'r' else '-')
|
||||
sb.append(if (perm and 0x080 != 0) 'w' else '-')
|
||||
sb.append(if (perm and 0x040 != 0) 'x' else '-')
|
||||
sb.append(if (perm and 0x020 != 0) 'r' else '-')
|
||||
sb.append(if (perm and 0x010 != 0) 'w' else '-')
|
||||
sb.append(if (perm and 0x008 != 0) 'x' else '-')
|
||||
sb.append(if (perm and 0x004 != 0) 'r' else '-')
|
||||
sb.append(if (perm and 0x002 != 0) 'w' else '-')
|
||||
sb.append(if (perm and 0x001 != 0) 'x' else '-')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user