v0.5.0: terminal themes, scroll control, quick commands (fix build issues)

This commit is contained in:
茂之钳
2026-05-23 15:02:24 +00:00
parent a4eef63c14
commit 71c24af854
3 changed files with 136 additions and 317 deletions
@@ -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()
}