diff --git a/build.gradle b/build.gradle
index 70235bd..d654f6c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270"
minSdk 24
targetSdk 34
- versionCode 7
- versionName "1.2.2"
+ versionCode 8
+ versionName "1.2.3"
}
buildTypes {
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 47ae0a9..64eee79 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -1,7 +1,7 @@
+ android:versionCode="8"
+ android:versionName="1.2.3">
@@ -37,6 +37,16 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="3270 Terminal TCP socket connection persistence" />
+
+
+
+
diff --git a/src/main/java/haus/nightmare/a3270/MainActivity.kt b/src/main/java/haus/nightmare/a3270/MainActivity.kt
index ab75fed..fe6f3a6 100644
--- a/src/main/java/haus/nightmare/a3270/MainActivity.kt
+++ b/src/main/java/haus/nightmare/a3270/MainActivity.kt
@@ -29,6 +29,8 @@ import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage
+import haus.nightmare.a3270.ui.ConnectChoice
+import haus.nightmare.a3270.ui.ConnectChoiceDialog
import haus.nightmare.a3270.ui.ConnectDialog
import haus.nightmare.a3270.ui.FileTransferDialog
import haus.nightmare.a3270.ui.OiaStatusBar
@@ -576,7 +578,13 @@ fun MainScreen(
fieldStatus == "NUM"
}
+ val activity = context as? ComponentActivity
+ val openConnectFromIntent = remember {
+ activity?.intent?.getBooleanExtra("OPEN_CONNECT_DIALOG", false) == true
+ }
+
var showConnectDialog by remember { mutableStateOf(false) }
+ var showConnectChoiceDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) }
var showFieldInspectorDialog by remember { mutableStateOf(false) }
@@ -621,30 +629,17 @@ fun MainScreen(
}
LaunchedEffect(Unit) {
+ if (openConnectFromIntent) {
+ activity?.intent?.removeExtra("OPEN_CONNECT_DIALOG")
+ showConnectDialog = true
+ return@LaunchedEffect
+ }
val startupBehavior = AppSettings.getStartupBehavior(context)
when (startupBehavior) {
AppSettings.StartupBehavior.AUTO_CONNECT -> {
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) {
- viewModel.connect(
- autoHost.host,
- autoHost.port,
- autoHost.model,
- autoHost.dynamicRows,
- autoHost.dynamicCols,
- autoHost.luName,
- autoHost.hostType,
- autoHost.useTls,
- autoHost.tlsVerifyCert,
- autoHost.tn3270e,
- autoHost.graphicsMode,
- autoHost.codePage,
- autoHost.proxyType,
- autoHost.proxyHost,
- autoHost.proxyPort,
- autoHost.proxyUsername,
- autoHost.proxyPassword
- )
+ viewModel.connect(autoHost.toConnectionConfig())
}
}
AppSettings.StartupBehavior.SHOW_CONNECT -> {
@@ -782,7 +777,11 @@ fun MainScreen(
onClearShift = onClearShift,
onConnectClick = {
terminalInputViewRef?.hideSoftKeyboard()
- showConnectDialog = true
+ if (connectionState.isConnected()) {
+ showConnectChoiceDialog = true
+ } else {
+ showConnectDialog = true
+ }
},
onDisconnectClick = { viewModel.disconnect() },
onFtClick = {
@@ -915,15 +914,65 @@ fun MainScreen(
}
}
+ if (showConnectChoiceDialog) {
+ ConnectChoiceDialog(
+ currentTarget = viewModel.getConnectedTarget(),
+ onChoice = { choice ->
+ showConnectChoiceDialog = false
+ when (choice) {
+ ConnectChoice.NEW_WINDOW -> {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
+ putExtra("OPEN_CONNECT_DIALOG", true)
+ }
+ context.startActivity(intent)
+ }
+ ConnectChoice.ABORT_CURRENT -> {
+ viewModel.disconnect()
+ showConnectDialog = true
+ }
+ ConnectChoice.CANCEL -> {
+ terminalInputViewRef?.showSoftKeyboard()
+ }
+ }
+ },
+ onDismiss = {
+ showConnectChoiceDialog = false
+ terminalInputViewRef?.showSoftKeyboard()
+ }
+ )
+ }
+
if (showConnectDialog) {
ConnectDialog(
onDismiss = {
showConnectDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
- onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass ->
+ onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass, savedConn, savedProfId, savedProfName ->
showConnectDialog = false
- viewModel.connect(host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass)
+ viewModel.connect(
+ host = host,
+ port = port,
+ modelNum = model,
+ dynamicRows = dynamicRows,
+ dynamicCols = dynamicCols,
+ luName = luName,
+ hostType = hostType,
+ useTls = useTls,
+ tlsVerifyCert = tlsVerifyCert,
+ tn3270e = tn3270e,
+ graphicsModeStr = graphicsMode,
+ codePageStr = cp,
+ proxyTypeStr = pType,
+ proxyHost = pHost,
+ proxyPort = pPort,
+ proxyUser = pUser,
+ proxyPass = pPass,
+ savedConnection = savedConn,
+ savedProfileId = savedProfId,
+ savedProfileName = savedProfName
+ )
terminalInputViewRef?.showSoftKeyboard()
}
)
@@ -985,13 +1034,14 @@ fun MainScreen(
initialFourColorOverride = fourColorOverride,
initialNumericFieldLock = AppSettings.getNumericFieldLock(context),
initialAutoSkipEnabled = AppSettings.getAutoSkipEnabled(context),
+ initialDefaultHostType = AppSettings.getDefaultHostType(context),
onDismiss = {
showSettingsDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst,
sizingHandles, remainAfterEdit, wordBreak, hsEnabled, hsUrl, hsPf, hsChoices,
- startup, dynRows, dynCols, doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk ->
+ startup, dynRows, dynCols, doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk, defHostType ->
showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
viewModel.setUiTheme(theme)
@@ -1020,6 +1070,7 @@ fun MainScreen(
AppSettings.setFourColorOverride(context, fourCol)
AppSettings.setNumericFieldLock(context, numLk)
AppSettings.setAutoSkipEnabled(context, autoSk)
+ AppSettings.setDefaultHostType(context, defHostType)
viewModel.reloadSettings()
terminalInputViewRef?.showSoftKeyboard()
}
diff --git a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt
index 05ffda3..629f8d9 100644
--- a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt
+++ b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt
@@ -26,6 +26,7 @@ import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.ui.HotspotEngine
import java.io.File
+import java.util.Locale
import java.util.concurrent.Executors
import java.util.logging.Logger
@@ -442,9 +443,21 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private var client: Telnet3270Client? = null
var currentHost: String = ""
private set
+ var currentPort: Int = 23
+ private set
+ var currentProfileName: String? = null
+ private set
var activeHostType: String = "TSO"
private set
+ fun getConnectedTarget(): String {
+ val pName = currentProfileName
+ if (!pName.isNullOrBlank()) return pName
+ val h = currentHost
+ val p = currentPort
+ return if (h.isNotBlank()) "$h:$p" else "the host"
+ }
+
fun getClient(): Telnet3270Client? = client
fun getScreenHistoryBuffer(): ScreenHistoryBuffer? = client?.screenBuffer?.historyBuffer
@@ -891,9 +904,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
proxyHost: String = "",
proxyPort: Int = 0,
proxyUser: String = "",
- proxyPass: String = ""
+ proxyPass: String = "",
+ savedConnection: Boolean = false,
+ savedProfileId: String? = null,
+ savedProfileName: String? = null
) {
currentHost = host
+ currentPort = port
+ currentProfileName = savedProfileName
activeHostType = hostType
lastScreenContentHash = 0
hasInitialScreenLoaded = false
@@ -902,6 +920,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_codePage.value = codePageStr
clearSearchHighlight()
+ if (savedConnection) {
+ val lastState = haus.nightmare.a3270.ui.getLastTransferState()
+ if (lastState != null) {
+ lastState.hostType = when (hostType.uppercase(Locale.US)) {
+ "CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
+ "CICS" -> FTConfig.HostType.CICS
+ else -> FTConfig.HostType.TSO
+ }
+ }
+ }
+ AppSettings.setDefaultHostType(getApplication(), hostType)
+
viewModelScope.launch(Dispatchers.IO) {
try {
// Always disconnect any existing client before establishing a new connection
@@ -939,6 +969,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
(if (effectiveTls) " [TLS]" else "") + "..."
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
+ this.hostType = hostType
+ this.isSavedConnection = savedConnection
+ if (savedProfileId != null) this.savedProfileId = savedProfileId
+ if (savedProfileName != null) this.savedProfileName = savedProfileName
+
if (model == TerminalModel.IBM_DYNAMIC) {
setDynamicDimensions(dynamicRows, dynamicCols)
}
@@ -1114,6 +1149,38 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
}
}
+ fun connect(config: ConnectionConfig) {
+ val modelNum = when (config.model) {
+ TerminalModel.IBM_DYNAMIC -> 0
+ TerminalModel.IBM_3279_3, TerminalModel.IBM_3278_3 -> 3
+ TerminalModel.IBM_3279_4, TerminalModel.IBM_3278_4 -> 4
+ TerminalModel.IBM_3279_5, TerminalModel.IBM_3278_5 -> 5
+ else -> 2
+ }
+ connect(
+ host = config.host,
+ port = config.port,
+ modelNum = modelNum,
+ dynamicRows = config.dynamicRows,
+ dynamicCols = config.dynamicCols,
+ luName = config.luName ?: "",
+ hostType = config.hostType ?: "TSO",
+ useTls = config.isUseTls,
+ tlsVerifyCert = config.isTlsVerifyCert,
+ tn3270e = config.isTn3270eEnabled,
+ graphicsModeStr = config.graphicsMode?.name ?: "BOTH",
+ codePageStr = config.codePage ?: "037",
+ proxyTypeStr = config.proxyType?.name ?: "NONE",
+ proxyHost = config.proxyHost ?: "",
+ proxyPort = config.proxyPort,
+ proxyUser = config.proxyUsername ?: "",
+ proxyPass = config.proxyPassword ?: "",
+ savedConnection = config.isSavedConnection,
+ savedProfileId = config.savedProfileId,
+ savedProfileName = config.savedProfileName
+ )
+ }
+
fun disconnect() {
viewModelScope.launch(Dispatchers.IO) {
try {
diff --git a/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt b/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt
index 90b7ce3..bdc1039 100644
--- a/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt
+++ b/src/main/java/haus/nightmare/a3270/storage/AppSettings.kt
@@ -1,8 +1,10 @@
package haus.nightmare.a3270.storage
import android.content.Context
+import android.content.Intent
import android.content.SharedPreferences
import java.io.BufferedReader
+import java.io.File
import java.io.PrintWriter
import java.io.Reader
import java.io.Writer
@@ -19,6 +21,7 @@ object AppSettings {
private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page"
+ private const val KEY_DEFAULT_HOST_TYPE = "default_host_type"
// New settings aligned with j3270
private const val KEY_UI_THEME = "ui_theme"
@@ -112,6 +115,15 @@ object AppSettings {
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply()
}
+ fun getDefaultHostType(context: Context): String {
+ return getPrefs(context).getString(KEY_DEFAULT_HOST_TYPE, "TSO") ?: "TSO"
+ }
+
+ fun setDefaultHostType(context: Context, hostType: String) {
+ val normalized = if (hostType.isNotBlank()) hostType.trim().uppercase(Locale.US) else "TSO"
+ getPrefs(context).edit().putString(KEY_DEFAULT_HOST_TYPE, normalized).apply()
+ }
+
// ========== UI Theme ==========
fun getUiTheme(context: Context): String {
return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK"
@@ -378,6 +390,7 @@ object AppSettings {
p.println("autoConnectTn3270e = ${autoHost.tn3270e}")
}
p.println("codePage = ${getDefaultCodePage(context)}")
+ p.println("hostType = ${getDefaultHostType(context)}")
p.println("dynamicRows = ${getDynamicRows(context)}")
p.println("dynamicCols = ${getDynamicCols(context)}")
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
@@ -418,6 +431,30 @@ object AppSettings {
p.flush()
}
+ fun shareSettingsIni(context: Context) {
+ try {
+ val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
+ val file = File(cacheDir, "a3270_settings.ini")
+ file.bufferedWriter().use { writer ->
+ exportToIni(context, writer)
+ }
+ val uri = androidx.core.content.FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file
+ )
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_SUBJECT, "a3270 Terminal Settings")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Share Terminal Settings"))
+ } catch (e: Exception) {
+ android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
+ }
+ }
+
fun parseIni(reader: Reader, callback: (section: String, key: String, value: String) -> Unit) {
val bufferedReader = BufferedReader(reader)
var currentSection = ""
@@ -462,6 +499,7 @@ object AppSettings {
} catch (_: Exception) {}
}
"codepage", "charset" -> setDefaultCodePage(context, value)
+ "hosttype", "host_type" -> setDefaultHostType(context, value)
"dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) }
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean())
diff --git a/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt b/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt
index ca5b7b7..cd25829 100644
--- a/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt
+++ b/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt
@@ -1,9 +1,21 @@
package haus.nightmare.a3270.storage
import android.content.Context
+import android.content.Intent
import android.content.SharedPreferences
+import haus.nightmare.lib3270j.ConnectionConfig
+import haus.nightmare.lib3270j.TerminalModel
+import haus.nightmare.lib3270j.graphics.GraphicsMode
import org.json.JSONArray
import org.json.JSONObject
+import java.io.BufferedReader
+import java.io.File
+import java.io.PrintWriter
+import java.io.Reader
+import java.io.Writer
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
import java.util.UUID
data class SavedHost(
@@ -28,6 +40,47 @@ data class SavedHost(
val proxyUsername: String = "",
val proxyPassword: String = ""
) {
+ fun toConnectionConfig(): ConnectionConfig {
+ val tm = when (model) {
+ 3 -> TerminalModel.IBM_3279_3
+ 4 -> TerminalModel.IBM_3279_4
+ 5 -> TerminalModel.IBM_3279_5
+ 0 -> TerminalModel.IBM_DYNAMIC
+ else -> TerminalModel.IBM_3279_2
+ }
+
+ val config = ConnectionConfig(host, port, tm, useTls)
+ config.isSavedConnection = true
+ config.savedProfileId = this.id
+ config.savedProfileName = this.name
+ config.hostType = if (this.hostType.isNotBlank()) this.hostType else "TSO"
+ if (model == 0 || tm.isDynamic) {
+ config.setDynamicDimensions(dynamicRows, dynamicCols)
+ }
+ if (luName.isNotBlank()) {
+ config.luName = luName.trim()
+ }
+ config.isTlsVerifyCert = tlsVerifyCert
+ config.isTn3270eEnabled = tn3270e
+ config.graphicsMode = GraphicsMode.fromString(graphicsMode)
+ if (codePage.isNotBlank()) {
+ config.codePage = codePage
+ }
+ if (!proxyType.equals("NONE", ignoreCase = true) && proxyHost.isNotBlank()) {
+ try {
+ val pt = ConnectionConfig.ProxyType.valueOf(proxyType.uppercase())
+ config.setProxy(
+ pt,
+ proxyHost,
+ if (proxyPort > 0) proxyPort else (if (pt == ConnectionConfig.ProxyType.HTTP) 8080 else 1080),
+ proxyUsername.ifBlank { null },
+ proxyPassword.ifBlank { null }
+ )
+ } catch (_: Exception) {}
+ }
+ return config
+ }
+
fun toJson(): JSONObject {
return JSONObject().apply {
put("id", id)
@@ -196,4 +249,156 @@ object HostStorage {
hosts.forEach { array.put(it.toJson()) }
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
}
+
+ // ================= INI Export / Import =================
+
+ fun exportToIni(hosts: List, writer: Writer) {
+ val p = PrintWriter(writer)
+ val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
+ p.println("; a3270 Saved Connections Configuration File")
+ p.println("; Exported on $timeStamp")
+ p.println("; Connections count: ${hosts.size}")
+ p.println()
+
+ for (h in hosts) {
+ val section = h.name.trim().ifBlank { h.host.trim().ifBlank { "Connection" } }
+ p.println("[$section]")
+ if (h.id.isNotBlank()) {
+ p.println("id = ${h.id}")
+ }
+ p.println("name = ${h.name}")
+ p.println("host = ${h.host}")
+ p.println("port = ${h.port}")
+ p.println("model = ${h.model}")
+ p.println("dynamicRows = ${h.dynamicRows}")
+ p.println("dynamicCols = ${h.dynamicCols}")
+ p.println("luName = ${h.luName}")
+ p.println("autoConnect = ${h.autoConnect}")
+ p.println("hostType = ${h.hostType.ifBlank { "TSO" }}")
+ p.println("useTls = ${h.useTls}")
+ p.println("tlsVerifyCert = ${h.tlsVerifyCert}")
+ p.println("tn3270e = ${h.tn3270e}")
+ p.println("graphicsMode = ${h.graphicsMode.ifBlank { "BOTH" }}")
+ p.println("codePage = ${h.codePage.ifBlank { "037" }}")
+ p.println("proxyType = ${h.proxyType.ifBlank { "NONE" }}")
+ p.println("proxyHost = ${h.proxyHost}")
+ p.println("proxyPort = ${h.proxyPort}")
+ p.println("proxyUsername = ${h.proxyUsername}")
+ p.println("proxyPassword = ${h.proxyPassword}")
+ p.println()
+ }
+ p.flush()
+ }
+
+ fun loadFromIni(reader: Reader): List {
+ val bufferedReader = BufferedReader(reader)
+ val hosts = mutableListOf()
+ var current: SavedHost? = null
+
+ bufferedReader.forEachLine { rawLine ->
+ val line = rawLine.trim()
+ if (line.isNotEmpty() && !line.startsWith(";") && !line.startsWith("#")) {
+ if (line.startsWith("[") && line.endsWith("]")) {
+ current?.let { hosts.add(it) }
+ var section = line.substring(1, line.length - 1).trim()
+ if (section.startsWith("connection:", ignoreCase = true)) {
+ section = section.substring("connection:".length).trim()
+ } else if (section.startsWith("host:", ignoreCase = true)) {
+ section = section.substring("host:".length).trim()
+ }
+ current = SavedHost(id = UUID.randomUUID().toString(), name = section, host = "")
+ } else {
+ val eq = line.indexOf('=')
+ if (eq >= 0) {
+ val key = line.substring(0, eq).trim().lowercase(Locale.US)
+ val value = line.substring(eq + 1).trim()
+ if (current == null) {
+ current = SavedHost(id = UUID.randomUUID().toString(), name = "Default", host = "")
+ }
+ current = applyIniProperty(current!!, key, value)
+ }
+ }
+ }
+ }
+ current?.let { hosts.add(it) }
+ return hosts
+ }
+
+ private fun applyIniProperty(h: SavedHost, key: String, value: String): SavedHost {
+ return when (key) {
+ "id" -> if (value.isNotBlank()) h.copy(id = value) else h
+ "name", "profilename", "profile" -> if (value.isNotBlank()) h.copy(name = value) else h
+ "host", "hostname", "address" -> h.copy(host = value)
+ "port" -> h.copy(port = value.toIntOrNull() ?: 23)
+ "model", "terminalmodel" -> h.copy(model = value.toIntOrNull() ?: 2)
+ "dynamicrows", "dynamic_rows", "rows" -> h.copy(dynamicRows = value.toIntOrNull() ?: 62)
+ "dynamiccols", "dynamic_cols", "cols" -> h.copy(dynamicCols = value.toIntOrNull() ?: 160)
+ "luname", "lu_name", "lu" -> h.copy(luName = value)
+ "autoconnect", "auto_connect" -> h.copy(autoConnect = value.toBoolean())
+ "hosttype", "host_type", "type" -> h.copy(hostType = value.uppercase(Locale.US))
+ "usetls", "use_tls", "tls", "ssl" -> h.copy(useTls = value.toBoolean())
+ "tlsverifycert", "verifycert", "tls_verify_cert" -> h.copy(tlsVerifyCert = value.toBoolean())
+ "tn3270e", "enabletn3270e" -> h.copy(tn3270e = value.toBoolean())
+ "graphicsmode", "graphics_mode", "graphics" -> h.copy(graphicsMode = value.uppercase(Locale.US))
+ "codepage", "code_page", "charset" -> h.copy(codePage = value)
+ "proxytype", "proxy_type" -> h.copy(proxyType = value.uppercase(Locale.US))
+ "proxyhost", "proxy_host" -> h.copy(proxyHost = value)
+ "proxyport", "proxy_port" -> h.copy(proxyPort = value.toIntOrNull() ?: 0)
+ "proxyusername", "proxy_username", "proxyuser" -> h.copy(proxyUsername = value)
+ "proxypassword", "proxy_password", "proxypass" -> h.copy(proxyPassword = value)
+ else -> h
+ }
+ }
+
+ fun importFromIni(context: Context, reader: Reader): List {
+ val imported = loadFromIni(reader)
+ if (imported.isEmpty()) return getSavedHosts(context)
+
+ val current = getSavedHosts(context).toMutableList()
+ for (host in imported) {
+ val index = current.indexOfFirst {
+ (it.id.isNotBlank() && it.id == host.id) ||
+ (it.name.isNotBlank() && it.name.equals(host.name, ignoreCase = true))
+ }
+ if (host.autoConnect) {
+ current.indices.forEach { i ->
+ current[i] = current[i].copy(autoConnect = false)
+ }
+ }
+ if (index >= 0) {
+ val existingId = current[index].id
+ val toSet = if (host.id.isBlank()) host.copy(id = existingId) else host
+ current[index] = toSet
+ } else {
+ current.add(host)
+ }
+ }
+ saveAll(context, current)
+ return current
+ }
+
+ fun shareConnectionsIni(context: Context) {
+ try {
+ val hosts = getSavedHosts(context)
+ val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
+ val file = File(cacheDir, "a3270_connections.ini")
+ file.bufferedWriter().use { writer ->
+ exportToIni(hosts, writer)
+ }
+ val uri = androidx.core.content.FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file
+ )
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_SUBJECT, "a3270 Saved Connections")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Share Saved Connections"))
+ } catch (e: Exception) {
+ android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
+ }
+ }
}
diff --git a/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt
index 7e1f6de..fdbf3b1 100644
--- a/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt
+++ b/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt
@@ -55,12 +55,16 @@ fun ConnectDialog(
proxyHost: String,
proxyPort: Int,
proxyUser: String,
- proxyPass: String
+ proxyPass: String,
+ savedConnection: Boolean,
+ savedProfileId: String?,
+ savedProfileName: String?
) -> Unit
) {
val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf(null) }
+ var showImportConnectionsDialog by remember { mutableStateOf(false) }
var profileName by remember { mutableStateOf("") }
var host by remember { mutableStateOf("") }
@@ -70,7 +74,7 @@ fun ConnectDialog(
var dynamicColsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()) }
var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) }
- var hostType by remember { mutableStateOf("TSO") }
+ var hostType by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)) }
var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e by remember { mutableStateOf(true) }
@@ -117,7 +121,7 @@ fun ConnectDialog(
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
luName = ""
autoConnect = false
- hostType = "TSO"
+ hostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
useTls = false
tlsVerifyCert = true
tn3270e = true
@@ -231,13 +235,36 @@ fun ConnectDialog(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Saved Profiles List Section
- if (savedHosts.isNotEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
Text(
- "Saved Host Profiles:",
+ if (savedHosts.isNotEmpty()) "Saved Host Profiles (${savedHosts.size}):" else "Saved Host Profiles:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ if (savedHosts.isNotEmpty()) {
+ TextButton(
+ onClick = { HostStorage.shareConnectionsIni(context) },
+ contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text("Export (Share)", fontSize = 11.sp, color = Color(0xFF339AF0))
+ }
+ }
+ TextButton(
+ onClick = { showImportConnectionsDialog = true },
+ contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text("Import", fontSize = 11.sp, color = Color(0xFF339AF0))
+ }
+ }
+ }
+
+ if (savedHosts.isNotEmpty()) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -301,7 +328,10 @@ fun ConnectDialog(
hostToConn.proxyHost,
hostToConn.proxyPort,
hostToConn.proxyUsername,
- hostToConn.proxyPassword
+ hostToConn.proxyPassword,
+ true,
+ hostToConn.id,
+ hostToConn.name
)
},
modifier = Modifier.size(32.dp)
@@ -804,7 +834,10 @@ fun ConnectDialog(
saved.proxyHost,
saved.proxyPort,
saved.proxyUsername,
- saved.proxyPassword
+ saved.proxyPassword,
+ true,
+ saved.id,
+ saved.name
)
}
},
@@ -816,4 +849,64 @@ fun ConnectDialog(
}
}
}
+
+ if (showImportConnectionsDialog) {
+ var importText by remember { mutableStateOf("") }
+ val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
+ AlertDialog(
+ onDismissRequest = { showImportConnectionsDialog = false },
+ title = { Text("Import Host Connections") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Paste j3270 / a3270 INI connections below:", fontSize = 12.sp)
+ OutlinedTextField(
+ value = importText,
+ onValueChange = { importText = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(180.dp),
+ maxLines = 10
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = {
+ val clip = clipboardManager.getText()?.text ?: ""
+ if (clip.isNotBlank()) {
+ importText = clip
+ }
+ }) {
+ Text("Paste Clipboard", fontSize = 11.sp)
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ showImportConnectionsDialog = false
+ if (importText.isNotBlank()) {
+ try {
+ val countBefore = savedHosts.size
+ val merged = HostStorage.importFromIni(context, java.io.StringReader(importText))
+ savedHosts = merged
+ val countAfter = merged.size
+ val diff = countAfter - countBefore
+ val msg = if (diff > 0) "Imported $diff new connection(s)" else "Updated ${merged.size} connection(s)"
+ android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
+ } catch (e: Exception) {
+ android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
+ }
+ }
+ }) {
+ Text("Import")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showImportConnectionsDialog = false }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
}
diff --git a/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt b/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt
index e59cc21..1a19d29 100644
--- a/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt
+++ b/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt
@@ -38,19 +38,24 @@ private val TerminalKeyboardOptions = KeyboardOptions(
)
data class LastTransferState(
- val hostFile: String = "",
- val localFile: String = "",
- val isReceive: Boolean = true,
- val isAscii: Boolean = true,
- val hostType: FTConfig.HostType = FTConfig.HostType.TSO,
- val recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
- val lreclStr: String = "",
- val blksizeStr: String = "",
- val spaceStr: String = "",
- val cmsOptions: String = ""
+ var hostFile: String = "",
+ var localFile: String = "",
+ var isReceive: Boolean = true,
+ var isAscii: Boolean = true,
+ var hostType: FTConfig.HostType = FTConfig.HostType.TSO,
+ var recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
+ var lreclStr: String = "",
+ var blksizeStr: String = "",
+ var units: FTConfig.AllocationUnit = FTConfig.AllocationUnit.TRACKS,
+ var spaceStr: String = "",
+ var dirBlocks: String = "",
+ var cmsOptions: String = ""
)
private var lastTransferState: LastTransferState? = null
+fun getLastTransferState(): LastTransferState? = lastTransferState
+fun resetSessionState() { lastTransferState = null }
+
fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String {
if (hostType == FTConfig.HostType.CMS) {
val lastDot = localName.lastIndexOf('.')
@@ -104,16 +109,26 @@ fun FileTransferDialog(
else -> FTConfig.HostType.TSO
}
+ val clientConfig = client?.config
+ val configuredFtType = clientConfig?.ftHostType
+ val effectiveHostType = if (configuredFtType != null && (clientConfig.isSavedConnection || lastTransferState == null)) {
+ configuredFtType
+ } else {
+ lastTransferState?.hostType ?: initialTypeEnum
+ }
+
var hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") }
var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") }
var localDisplayName by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) }
var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: true) }
- var hostType by remember { mutableStateOf(lastTransferState?.hostType ?: initialTypeEnum) }
+ var hostType by remember { mutableStateOf(effectiveHostType) }
var recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") }
var blksizeStr by remember { mutableStateOf(lastTransferState?.blksizeStr ?: "") }
+ var units by remember { mutableStateOf(lastTransferState?.units ?: FTConfig.AllocationUnit.TRACKS) }
var spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") }
+ var dirBlocks by remember { mutableStateOf(lastTransferState?.dirBlocks ?: "") }
var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") }
var overwrite by remember { mutableStateOf(true) }
var showBrowseDialog by remember { mutableStateOf(false) }
@@ -186,15 +201,34 @@ fun FileTransferDialog(
}
if (showBrowseDialog) {
+ val initialQuery = resolveDefaultQuery(hostType, hostFile)
HostDirectoryDialog(
client = client,
initialHostType = hostType,
- initialQuery = hostFile,
+ initialQuery = initialQuery,
onDismiss = { showBrowseDialog = false },
- onSelectDataset = { selected ->
- hostFile = selected
- if (localFile.isBlank()) {
- val safeName = formatLocalFilenameFromHost(selected, hostType)
+ onSelectEntry = { selectedName, selectedEntry ->
+ hostFile = selectedName
+ if (selectedEntry != null) {
+ val r = selectedEntry.recfm
+ if (!r.isNullOrBlank()) {
+ val mappedRecfm = when (r.trim().uppercase(Locale.US)) {
+ "F", "FB", "FA", "FBA", "FBM" -> FTConfig.RecordFormat.FIXED
+ "V", "VB", "VA", "VBA", "VBM" -> FTConfig.RecordFormat.VARIABLE
+ else -> FTConfig.RecordFormat.DEFAULT
+ }
+ recfm = mappedRecfm
+ }
+ if (selectedEntry.lrecl > 0) {
+ lreclStr = selectedEntry.lrecl.toString()
+ }
+ if (selectedEntry.blksize > 0) {
+ blksizeStr = selectedEntry.blksize.toString()
+ }
+ }
+ val shouldAutofill = localFile.isBlank() || (lastTransferState != null && localFile == lastTransferState?.localFile)
+ if (shouldAutofill) {
+ val safeName = formatLocalFilenameFromHost(selectedName, hostType)
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, safeName)
localFile = tempFile.absolutePath
@@ -521,16 +555,52 @@ fun FileTransferDialog(
)
}
- Spacer(modifier = Modifier.height(4.dp))
- OutlinedTextField(
- value = spaceStr,
- onValueChange = { spaceStr = it },
- label = { Text("SPACE (Allocation)", fontSize = 11.sp) },
- placeholder = { Text("e.g. (1,1) cyl or 5,2") },
- singleLine = true,
- keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
- modifier = Modifier.fillMaxWidth()
- )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Units:", fontSize = 11.sp)
+ Row {
+ listOf(
+ FTConfig.AllocationUnit.TRACKS to "Tracks",
+ FTConfig.AllocationUnit.CYLINDERS to "Cylinders",
+ FTConfig.AllocationUnit.AVBLOCK to "Avblock",
+ FTConfig.AllocationUnit.DEFAULT to "Default"
+ ).forEach { (u, label) ->
+ FilterChip(
+ selected = (units == u),
+ onClick = { units = u },
+ label = { Text(label, fontSize = 11.sp) }
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedTextField(
+ value = spaceStr,
+ onValueChange = { spaceStr = it },
+ label = { Text("SPACE (Pri, Sec)", fontSize = 11.sp) },
+ placeholder = { Text("e.g. 5,2 or 1,1") },
+ singleLine = true,
+ keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
+ modifier = Modifier.weight(0.62f)
+ )
+ OutlinedTextField(
+ value = dirBlocks,
+ onValueChange = { dirBlocks = it.filter { c -> c.isDigit() } },
+ label = { Text("Dir (PDS)", fontSize = 11.sp) },
+ placeholder = { Text("e.g. 10") },
+ singleLine = true,
+ keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
+ modifier = Modifier.weight(0.38f)
+ )
+ }
}
// CMS Options
@@ -571,7 +641,9 @@ fun FileTransferDialog(
setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
+ setUnits(units)
if (spaceStr.isNotBlank()) setSpace(spaceStr.trim())
+ if (dirBlocks.isNotBlank()) setDirectoryBlocks(dirBlocks.trim())
}
if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) {
setOptions(cmsOptions.trim())
@@ -586,7 +658,9 @@ fun FileTransferDialog(
recfm = recfm,
lreclStr = lreclStr,
blksizeStr = blksizeStr,
+ units = units,
spaceStr = spaceStr,
+ dirBlocks = dirBlocks,
cmsOptions = cmsOptions
)
onStartTransfer(config)
diff --git a/src/main/java/haus/nightmare/a3270/ui/HostDirectoryDialog.kt b/src/main/java/haus/nightmare/a3270/ui/HostDirectoryDialog.kt
index 7ad2b84..bbe6eb0 100644
--- a/src/main/java/haus/nightmare/a3270/ui/HostDirectoryDialog.kt
+++ b/src/main/java/haus/nightmare/a3270/ui/HostDirectoryDialog.kt
@@ -1,5 +1,8 @@
package haus.nightmare.a3270.ui
+import android.content.ClipDescription
+import android.content.ClipboardManager
+import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -15,6 +18,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -28,8 +32,139 @@ import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
+import haus.nightmare.lib3270j.ft.dir.CMSDirectoryParser
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
+import haus.nightmare.lib3270j.ft.dir.TSODirectoryParser
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.Locale
+
+fun resolveDefaultQuery(hostType: FTConfig.HostType, initialQuery: String?): String {
+ val defaultQuery = initialQuery?.trim() ?: ""
+ if (hostType == FTConfig.HostType.CMS && (defaultQuery.isEmpty() || defaultQuery == "* * *" || defaultQuery == "*")) {
+ return "* * A"
+ }
+ return defaultQuery
+}
+
+fun formatCMSFilter(pattern: String?): String {
+ if (pattern.isNullOrBlank()) {
+ return "* * A"
+ }
+ val clean = pattern.replace("'", "").replace("\"", "").trim()
+ if (clean == "* * *" || clean == "*") {
+ return "* * A"
+ } else if (clean.contains('.')) {
+ val dot = clean.indexOf('.')
+ return clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " A"
+ } else {
+ val parts = clean.split(Regex("\\s+"))
+ return when (parts.size) {
+ 1 -> "${parts[0]} * A"
+ 2 -> "${parts[0]} ${parts[1]} A"
+ else -> "${parts[0]} ${parts[1]} ${parts[2]}"
+ }
+ }
+}
+
+fun matchesFilter(entry: HostDirectoryEntry, filter: String?): Boolean {
+ if (filter.isNullOrBlank()) return true
+ val cleanFilter = filter.replace("'", "").replace("\"", "").trim()
+ if (cleanFilter.isEmpty() || cleanFilter == "*" || cleanFilter == "* * *" || cleanFilter == "*.*") {
+ return true
+ }
+ return when (entry) {
+ is CMSDirectoryEntry -> matchesCmsFilter(entry, cleanFilter)
+ is TSODirectoryEntry -> matchesTsoFilter(entry, cleanFilter)
+ else -> matchesWildcard(entry.name, cleanFilter)
+ }
+}
+
+fun matchesCmsFilter(cms: CMSDirectoryEntry, filter: String): Boolean {
+ val fn = cms.filename?.trim() ?: ""
+ val ft = cms.filetype?.trim() ?: ""
+ val fm = cms.filemode?.trim() ?: ""
+
+ val tokens = filter.split(Regex("\\s+"))
+ return if (tokens.size >= 3) {
+ matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1]) && matchesCmsFilemode(fm, tokens[2])
+ } else if (tokens.size == 2) {
+ matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1])
+ } else {
+ val tok = tokens[0]
+ if (tok.contains('.')) {
+ val dot = tok.indexOf('.')
+ matchesWildcard(fn, tok.substring(0, dot)) && matchesWildcard(ft, tok.substring(dot + 1))
+ } else if (matchesWildcard(fn, tok) || matchesWildcard(ft, tok) || matchesCmsFilemode(fm, tok)) {
+ true
+ } else {
+ matchesWildcard(cms.name, tok)
+ }
+ }
+}
+
+fun matchesCmsFilemode(actualFm: String?, fmPattern: String?): Boolean {
+ if (fmPattern.isNullOrEmpty() || fmPattern == "*") return true
+ if (actualFm.isNullOrEmpty()) return false
+ if (fmPattern.length == 1 && fmPattern[0].isLetter()) {
+ return actualFm.startsWith(fmPattern, ignoreCase = true)
+ }
+ return matchesWildcard(actualFm, fmPattern)
+}
+
+fun matchesTsoFilter(tso: TSODirectoryEntry, filter: String): Boolean {
+ val dsn = tso.datasetName?.trim() ?: ""
+ if (filter == "*" || filter == "* * *" || filter == "*.*") return true
+ if (filter.contains('*') || filter.contains('?')) {
+ return matchesWildcard(dsn, filter)
+ }
+ return dsn.contains(filter, ignoreCase = true)
+}
+
+fun matchesWildcard(text: String?, pattern: String?): Boolean {
+ val t = text ?: ""
+ if (pattern.isNullOrEmpty() || pattern == "*") return true
+
+ val regex = buildString {
+ append("^")
+ for (c in pattern) {
+ when (c) {
+ '*' -> append(".*")
+ '?' -> append(".")
+ in "\\.[]{}()+^$|" -> append("\\").append(c)
+ else -> append(Regex.escape(c.uppercaseChar().toString()))
+ }
+ }
+ append("$")
+ }
+ return t.uppercase(Locale.US).matches(Regex(regex))
+}
+
+private fun waitForKeyboardUnlock(client: Telnet3270Client?, timeoutMs: Long): Boolean {
+ if (client == null) return false
+ val deadline = System.currentTimeMillis() + timeoutMs
+ while (System.currentTimeMillis() < deadline) {
+ var locked = false
+ if (client.inputProcessor != null && client.inputProcessor.isKeyboardLocked) {
+ locked = true
+ }
+ if (client.oia != null && (client.oia.isKeyLocked || client.oia.isXSystem)) {
+ locked = true
+ }
+ if (!locked) {
+ return true
+ }
+ try {
+ Thread.sleep(40)
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ return false
+ }
+ }
+ return false
+}
@Composable
fun HostDirectoryDialog(
@@ -37,71 +172,475 @@ fun HostDirectoryDialog(
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
initialQuery: String = "",
onDismiss: () -> Unit,
- onSelectDataset: (String) -> Unit
+ onSelectDataset: (String) -> Unit = {},
+ onSelectEntry: ((String, HostDirectoryEntry?) -> Unit)? = null
) {
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+
var hostType by remember { mutableStateOf(initialHostType) }
- var query by remember { mutableStateOf(initialQuery) }
- var entries by remember { mutableStateOf>(emptyList()) }
+ var query by remember { mutableStateOf(resolveDefaultQuery(initialHostType, initialQuery)) }
+ var allEntries by remember { mutableStateOf>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var statusMessage by remember { mutableStateOf("") }
+ var isLoading by remember { mutableStateOf(false) }
- fun runQuery() {
- val c = client
- if (c == null || !c.connectionState.isFullSession) {
- statusMessage = "Terminal not connected."
- return
- }
- val xfer = c.xfer
- if (xfer == null) {
- statusMessage = "ECL transfer interface not available."
- return
- }
+ var inspectionEntry by remember { mutableStateOf(null) }
+ var showInspectionDialog by remember { mutableStateOf(false) }
- val q = query.trim()
- if (hostType == FTConfig.HostType.CMS) {
- val list = xfer.getCmsDirectory(q)
- entries = list ?: emptyList()
+ // Client-side filtering
+ val currentEntries by remember(allEntries, query, hostType) {
+ derivedStateOf {
+ val filter = query.trim()
+ if (filter.isEmpty()) {
+ allEntries
+ } else {
+ allEntries.filter { matchesFilter(it, filter) }
+ }
+ }
+ }
+
+ fun parseTextData(text: String, source: String) {
+ allEntries = if (hostType == FTConfig.HostType.CMS) {
+ CMSDirectoryParser.parse(text)
} else {
- val list = xfer.getTsoDirectory(q)
- entries = list ?: emptyList()
+ TSODirectoryParser.parse(text)
}
selectedIndex = -1
- statusMessage = "${entries.size} entries found."
+ statusMessage = if (allEntries.isEmpty()) {
+ "0 entries found from $source. Use 'Query Host' or 'Paste Clipboard'."
+ } else {
+ "Parsed ${allEntries.size} entries from $source."
+ }
}
fun parseScreen() {
val c = client
- if (c == null || !c.connectionState.isFullSession) {
- statusMessage = "No active screen to parse."
+ if (c == null || !c.connectionState.isFullSession || c.ps == null) {
+ statusMessage = "No active session to parse."
return
}
- val xfer = c.xfer
- if (xfer == null) {
- statusMessage = "ECL transfer interface not available."
+ val rows = c.ps.rows
+ val cols = c.ps.cols
+ val screenText = c.ps.copyString(0, 0, rows - 1, cols - 1)
+ parseTextData(screenText, "presentation space")
+ }
+
+ fun parseClipboard() {
+ try {
+ val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
+ if (cm != null && cm.hasPrimaryClip()) {
+ val clip = cm.primaryClip
+ if (clip != null && clip.itemCount > 0) {
+ val clipText = clip.getItemAt(0).coerceToText(context)?.toString()
+ if (!clipText.isNullOrBlank()) {
+ parseTextData(clipText, "clipboard")
+ return
+ }
+ }
+ }
+ statusMessage = "Clipboard contains no text."
+ } catch (ex: Exception) {
+ statusMessage = "Error reading clipboard: ${ex.message}"
+ }
+ }
+
+ fun queryHost() {
+ val c = client
+ if (c == null || !c.connectionState.isFullSession || c.ps == null) {
+ statusMessage = "No active session to query."
return
}
- val ps = c.ps
- val screenText = ps.getString(0, ps.size)
- if (hostType == FTConfig.HostType.CMS) {
- val list = xfer.getCmsDirectory(screenText)
- entries = list ?: emptyList()
- } else {
- val list = xfer.getTsoDirectory(screenText)
- entries = list ?: emptyList()
+ val pattern = query.trim()
+ val curHostType = hostType
+ statusMessage = "Querying host ($curHostType)..."
+ isLoading = true
+
+ coroutineScope.launch(Dispatchers.IO) {
+ try {
+ val allPages = StringBuilder()
+ val maxPages = 500
+
+ if (curHostType == FTConfig.HostType.CMS) {
+ c.ps.SendKeys("[clear]")
+ waitForKeyboardUnlock(c, 3000)
+
+ val cmsFilter = formatCMSFilter(pattern)
+ c.ps.SendKeys("LISTFILE $cmsFilter ( nohead alloc date[enter]")
+ Thread.sleep(100)
+
+ for (page in 0 until maxPages) {
+ val startWait = System.currentTimeMillis()
+ val timeoutMs = 10000L
+ var screen = ""
+ var isMore = false
+
+ while (System.currentTimeMillis() - startWait < timeoutMs) {
+ waitForKeyboardUnlock(c, 500)
+ val rows = c.ps.rows
+ val cols = c.ps.cols
+ screen = c.ps.copyString(0, 0, rows - 1, cols - 1)
+ val upper = screen.uppercase(Locale.US)
+
+ if (upper.contains("MORE...") || upper.contains("HOLDING")) {
+ isMore = true
+ break
+ }
+ if (upper.contains("READY;") || upper.contains("READY(")) {
+ break
+ }
+ Thread.sleep(150)
+ }
+
+ allPages.append(screen).append("\n")
+
+ if (isMore) {
+ c.ps.SendKeys("[clear]")
+ Thread.sleep(100)
+ waitForKeyboardUnlock(c, 3000)
+ } else {
+ break
+ }
+ }
+
+ if (c.ps != null) {
+ c.ps.SendKeys("[clear]")
+ waitForKeyboardUnlock(c, 3000)
+ }
+ } else {
+ val rows = c.ps.rows
+ val cols = c.ps.cols
+ val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
+ val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
+ val hasCmdArrow = initialScreen.contains("===>")
+ val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
+
+ var tsoCmd = "TSO LISTCAT ALL"
+ if (pattern.isNotEmpty()) {
+ var dsnPattern = pattern.trim()
+ if (dsnPattern.startsWith("'") && dsnPattern.endsWith("'") && dsnPattern.length > 2) {
+ dsnPattern = dsnPattern.substring(1, dsnPattern.length - 1)
+ }
+ val paren = dsnPattern.indexOf('(')
+ if (paren > 0) {
+ dsnPattern = dsnPattern.substring(0, paren)
+ }
+ if (dsnPattern == "*" || dsnPattern == "* * *" || dsnPattern == "*.*") {
+ tsoCmd = "TSO LISTCAT ALL"
+ } else {
+ var level = dsnPattern
+ if (level.endsWith(".*")) {
+ level = level.substring(0, level.length - 2)
+ } else if (level.endsWith("*")) {
+ level = level.substring(0, level.length - 1)
+ }
+ tsoCmd = if (level.isEmpty()) "TSO LISTCAT ALL" else "TSO LISTCAT LEVEL($level) ALL"
+ }
+ }
+
+ if (isIspf) {
+ val arrowPos = initialScreen.indexOf("===>")
+ if (arrowPos >= 0 && c.screenBuffer != null) {
+ val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
+ if (inputAddr > 0) {
+ c.screenBuffer.cursorAddress = inputAddr
+ c.screenBuffer.updateDisplaySnapshot()
+ } else {
+ c.ps.SendKeys("[home]")
+ }
+ } else {
+ c.ps.SendKeys("[home]")
+ }
+ c.ps.SendKeys("[erase_eof]")
+ c.ps.SendKeys("$tsoCmd[enter]")
+ } else {
+ c.ps.SendKeys("[clear]")
+ waitForKeyboardUnlock(c, 3000)
+ c.ps.SendKeys("$tsoCmd[enter]")
+ }
+
+ Thread.sleep(100)
+
+ val startCmd = System.currentTimeMillis()
+ while (System.currentTimeMillis() - startCmd < 4000) {
+ waitForKeyboardUnlock(c, 200)
+ val r = c.ps.rows
+ val col = c.ps.cols
+ val s = c.ps.copyString(0, 0, r - 1, col - 1)
+ val u = s.uppercase(Locale.US)
+ if (u.contains("NONVSAM") || u.contains("CLUSTER") || u.contains("GDG")
+ || u.contains("ALIAS") || u.contains("IN-CAT") || u.contains("IDCAMS")
+ || u.contains("***") || u.contains("READY") || u.contains("NOT FOUND")
+ || u.contains("NO ENTRY") || s.contains(" --- ")
+ || u.contains("--RECFM-") || u.contains("--VOLUMES--")) {
+ break
+ }
+ Thread.sleep(80)
+ }
+
+ var prevScreen = ""
+ for (page in 0 until maxPages) {
+ waitForKeyboardUnlock(c, 300)
+ val r = c.ps.rows
+ val col = c.ps.cols
+ val screen = c.ps.copyString(0, 0, r - 1, col - 1)
+ val upper = screen.uppercase(Locale.US)
+
+ if (screen == prevScreen || screen.isBlank()) {
+ break
+ }
+ prevScreen = screen
+ allPages.append(screen).append("\n")
+
+ if (upper.contains("***")) {
+ c.ps.SendKeys("[enter]")
+ Thread.sleep(100)
+ waitForKeyboardUnlock(c, 2000)
+ } else if (upper.contains("NONVSAM") || upper.contains("CLUSTER") || upper.contains("GDG")
+ || upper.contains("ALIAS") || upper.contains("IDCAMS") || screen.contains(" --- ")
+ || upper.contains("--RECFM-") || upper.contains("--VOLUMES--")) {
+ c.ps.SendKeys("[enter]")
+ Thread.sleep(100)
+ waitForKeyboardUnlock(c, 1000)
+
+ val r2 = c.ps.rows
+ val col2 = c.ps.cols
+ val s2 = c.ps.copyString(0, 0, r2 - 1, col2 - 1)
+ if (s2.uppercase(Locale.US).contains("***")) {
+ prevScreen = s2
+ allPages.append(s2).append("\n")
+ c.ps.SendKeys("[enter]")
+ Thread.sleep(100)
+ waitForKeyboardUnlock(c, 2000)
+ continue
+ } else {
+ if (s2 != screen && s2.isNotBlank()) {
+ allPages.append(s2).append("\n")
+ }
+ break
+ }
+ } else {
+ break
+ }
+ }
+
+ if (c.ps != null) {
+ if (isIspf) {
+ c.ps.SendKeys("[enter]")
+ Thread.sleep(150)
+ waitForKeyboardUnlock(c, 1500)
+
+ val r = c.ps.rows
+ val col = c.ps.cols
+ val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
+ if (currentScr.uppercase(Locale.US).contains("NONVSAM") || currentScr.uppercase(Locale.US).contains("IDCAMS")
+ || currentScr.contains(" --- ") || currentScr.uppercase(Locale.US).contains("--RECFM-")
+ || (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
+ c.ps.SendKeys("[clear]")
+ waitForKeyboardUnlock(c, 2000)
+ }
+ } else {
+ c.ps.SendKeys("[clear]")
+ waitForKeyboardUnlock(c, 2000)
+ }
+ }
+ }
+
+ val collectedText = allPages.toString()
+ withContext(Dispatchers.Main) {
+ parseTextData(collectedText, "host query")
+ isLoading = false
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ statusMessage = "Host query failed: ${e.message}"
+ isLoading = false
+ }
+ }
+ }
+ }
+
+ fun inspectSelectedDataset() {
+ val selected = currentEntries.getOrNull(selectedIndex)
+ if (selected == null || selected !is TSODirectoryEntry) {
+ statusMessage = "Please select a TSO dataset entry to inspect."
+ return
+ }
+
+ val c = client
+ if (c == null || !c.connectionState.isFullSession || c.ps == null) {
+ statusMessage = "No active session to query host."
+ return
+ }
+
+ val dsn = selected.datasetName
+ if (dsn.isNullOrBlank()) return
+
+ statusMessage = "Inspecting $dsn via TSO LISTDS..."
+ isLoading = true
+
+ coroutineScope.launch(Dispatchers.IO) {
+ try {
+ val output = StringBuilder()
+ val rows = c.ps.rows
+ val cols = c.ps.cols
+ val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
+ val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
+ val hasCmdArrow = initialScreen.contains("===>")
+ val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
+
+ val tsoCmd = "TSO LISTDS '$dsn' STATUS"
+
+ if (isIspf) {
+ val arrowPos = initialScreen.indexOf("===>")
+ if (arrowPos >= 0 && c.screenBuffer != null) {
+ val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
+ if (inputAddr > 0) {
+ c.screenBuffer.cursorAddress = inputAddr
+ c.screenBuffer.updateDisplaySnapshot()
+ } else {
+ c.ps.SendKeys("[home]")
+ }
+ } else {
+ c.ps.SendKeys("[home]")
+ }
+ c.ps.SendKeys("[erase_eof]")
+ c.ps.SendKeys("$tsoCmd[enter]")
+ } else {
+ c.ps.SendKeys("[clear]")
+ waitForKeyboardUnlock(c, 3000)
+ c.ps.SendKeys("$tsoCmd[enter]")
+ }
+
+ Thread.sleep(100)
+
+ val startCmd = System.currentTimeMillis()
+ while (System.currentTimeMillis() - startCmd < 4000) {
+ waitForKeyboardUnlock(c, 200)
+ val r = c.ps.rows
+ val col = c.ps.cols
+ val s = c.ps.copyString(0, 0, r - 1, col - 1)
+ val u = s.uppercase(Locale.US)
+ if (u.contains("--RECFM-") || u.contains("--VOLUMES--") || u.contains("***")
+ || u.contains("READY") || u.contains("NOT IN CATALOG") || u.contains("NOT FOUND")) {
+ break
+ }
+ Thread.sleep(80)
+ }
+
+ var prevScreen = ""
+ for (page in 0 until 5) {
+ waitForKeyboardUnlock(c, 300)
+ val r = c.ps.rows
+ val col = c.ps.cols
+ val screen = c.ps.copyString(0, 0, r - 1, col - 1)
+ val upper = screen.uppercase(Locale.US)
+
+ if (screen == prevScreen || screen.isBlank()) {
+ break
+ }
+ prevScreen = screen
+ output.append(screen).append("\n")
+
+ if (upper.contains("***")) {
+ c.ps.SendKeys("[enter]")
+ Thread.sleep(100)
+ waitForKeyboardUnlock(c, 2000)
+ } else {
+ break
+ }
+ }
+
+ if (c.ps != null) {
+ if (isIspf) {
+ c.ps.SendKeys("[enter]")
+ Thread.sleep(150)
+ waitForKeyboardUnlock(c, 1500)
+
+ val r = c.ps.rows
+ val col = c.ps.cols
+ val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
+ if (currentScr.uppercase(Locale.US).contains("--RECFM-") || (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
+ c.ps.SendKeys("[clear]")
+ waitForKeyboardUnlock(c, 2000)
+ }
+ } else {
+ c.ps.SendKeys("[clear]")
+ waitForKeyboardUnlock(c, 2000)
+ }
+ }
+
+ val outText = output.toString()
+ val parsed = TSODirectoryParser.parse(outText)
+ withContext(Dispatchers.Main) {
+ isLoading = false
+ if (parsed.isNotEmpty()) {
+ val updated = parsed[0]
+ if (updated.recfm != null) selected.recfm = updated.recfm
+ if (updated.lrecl > 0) selected.lrecl = updated.lrecl
+ if (updated.blksize > 0) selected.blksize = updated.blksize
+ if (updated.dsorg != null) selected.dsorg = updated.dsorg
+ if (updated.volume != null && (selected.volume == null || selected.volume == "******" || selected.volume.isEmpty())) {
+ selected.volume = updated.volume
+ }
+ if (updated.tracksAllocated > 0) selected.tracksAllocated = updated.tracksAllocated
+ if (updated.extents > 0) selected.extents = updated.extents
+
+ statusMessage = "Attributes for $dsn: RECFM=${selected.recfm}, LRECL=${selected.lrecl}, BLKSIZE=${selected.blksize}, VOL=${selected.volume}"
+ inspectionEntry = selected
+ showInspectionDialog = true
+ } else {
+ statusMessage = "No VTOC attributes found for $dsn"
+ }
+ }
+ } catch (ex: Exception) {
+ withContext(Dispatchers.Main) {
+ isLoading = false
+ statusMessage = "Inspection failed: ${ex.message}"
+ }
+ }
}
- selectedIndex = -1
- statusMessage = "Parsed ${entries.size} entries from screen."
}
LaunchedEffect(Unit) {
- if (initialQuery.isNotBlank()) {
- runQuery()
+ if (client != null && client.connectionState.isFullSession) {
+ queryHost()
} else {
parseScreen()
}
}
+ if (showInspectionDialog && inspectionEntry != null) {
+ val entry = inspectionEntry!!
+ AlertDialog(
+ onDismissRequest = { showInspectionDialog = false },
+ title = { Text("Dataset Attributes (LISTDS)", fontWeight = FontWeight.Bold, fontSize = 16.sp) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text("Dataset: ${entry.datasetName}", fontWeight = FontWeight.Bold, fontSize = 13.sp, color = Color.White)
+ HorizontalDivider(color = Color(0xFF373A40))
+ Text("Volume: ${entry.volume ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
+ Text("Record Format (RECFM): ${entry.recfm ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
+ Text("Logical Record Length (LRECL): ${if (entry.lrecl > 0) entry.lrecl.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
+ Text("Block Size (BLKSIZE): ${if (entry.blksize > 0) entry.blksize.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
+ Text("Dataset Organization (DSORG): ${entry.dsorg ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
+ if (entry.tracksAllocated > 0) {
+ Text("Allocated Tracks: ${entry.tracksAllocated}", fontSize = 12.sp, color = Color.LightGray)
+ }
+ if (entry.extents > 0) {
+ Text("Extents: ${entry.extents}", fontSize = 12.sp, color = Color.LightGray)
+ }
+ }
+ },
+ confirmButton = {
+ Button(onClick = { showInspectionDialog = false }) {
+ Text("OK")
+ }
+ }
+ )
+ }
+
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -135,13 +674,33 @@ fun HostDirectoryDialog(
Row {
FilterChip(
selected = (hostType == FTConfig.HostType.TSO),
- onClick = { hostType = FTConfig.HostType.TSO },
+ onClick = {
+ hostType = FTConfig.HostType.TSO
+ if (query == "* * A" || query == "* * *") {
+ query = ""
+ }
+ if (client != null && client.connectionState.isFullSession) {
+ queryHost()
+ } else {
+ parseScreen()
+ }
+ },
label = { Text("TSO", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (hostType == FTConfig.HostType.CMS),
- onClick = { hostType = FTConfig.HostType.CMS },
+ onClick = {
+ hostType = FTConfig.HostType.CMS
+ if (query.isBlank() || query == "* * *" || query == "*") {
+ query = "* * A"
+ }
+ if (client != null && client.connectionState.isFullSession) {
+ queryHost()
+ } else {
+ parseScreen()
+ }
+ },
label = { Text("CMS", fontSize = 11.sp) }
)
}
@@ -155,8 +714,8 @@ fun HostDirectoryDialog(
OutlinedTextField(
value = query,
onValueChange = { query = it },
- label = { Text("Query Pattern") },
- placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* EXEC *") },
+ label = { Text("Filter / Pattern") },
+ placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* * A") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -164,14 +723,29 @@ fun HostDirectoryDialog(
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
- keyboardActions = KeyboardActions(onSearch = { runQuery() }),
+ keyboardActions = KeyboardActions(onSearch = {
+ if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
+ || query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
+ parseTextData(query, "query text")
+ } else if (allEntries.isEmpty() && client != null && client.connectionState.isFullSession) {
+ queryHost()
+ }
+ }),
modifier = Modifier.weight(1f)
)
Button(
- onClick = { runQuery() },
+ onClick = {
+ if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
+ || query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
+ parseTextData(query, "query text")
+ } else {
+ queryHost()
+ }
+ },
+ enabled = !isLoading,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
- Text("Query")
+ Text(if (isLoading) "Querying..." else "Query Host")
}
}
@@ -181,26 +755,36 @@ fun HostDirectoryDialog(
verticalAlignment = Alignment.CenterVertically
) {
Text(
- text = statusMessage.ifBlank { "Enter query or parse screen." },
+ text = if (statusMessage.isNotBlank()) statusMessage else "${currentEntries.size} entries shown (${allEntries.size} total).",
fontSize = 11.sp,
- color = Color.Gray
+ color = Color.Gray,
+ modifier = Modifier.weight(1f)
)
- TextButton(onClick = { parseScreen() }) {
- Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
+ Row {
+ TextButton(onClick = { parseClipboard() }, enabled = !isLoading) {
+ Text("Paste Clipboard", fontSize = 11.sp, color = Color(0xFF339AF0))
+ }
+ TextButton(onClick = { parseScreen() }, enabled = !isLoading) {
+ Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
+ }
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Table View
- if (entries.isEmpty()) {
+ if (currentEntries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
- Text("No entries found.", color = Color.Gray, fontSize = 12.sp)
+ Text(
+ if (isLoading) "Loading entries from host..." else "No entries found.",
+ color = Color.Gray,
+ fontSize = 12.sp
+ )
}
} else {
val horizScroll = rememberScrollState()
@@ -227,7 +811,7 @@ fun HostDirectoryDialog(
Text("Records", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp))
} else {
- Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp))
+ Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(240.dp))
Text("Volume", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(70.dp))
Text("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
@@ -242,7 +826,7 @@ fun HostDirectoryDialog(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
- itemsIndexed(entries) { idx, item ->
+ itemsIndexed(currentEntries) { idx, item ->
val isSelected = (idx == selectedIndex)
Surface(
@@ -257,21 +841,21 @@ fun HostDirectoryDialog(
verticalAlignment = Alignment.CenterVertically
) {
if (item is CMSDirectoryEntry) {
- Text(item.filename, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
- Text(item.filetype, color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp))
- Text(item.filemode, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
- Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
- Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
- Text("${item.numRecords}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
- Text("${item.date} ${item.time}", color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
+ Text(item.filename ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
+ Text(item.filetype ?: "", color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp))
+ Text(item.filemode ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
+ Text(item.recfm ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
+ Text(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
+ Text(if (item.numRecords > 0) "${item.numRecords}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
+ Text("${item.date ?: ""} ${item.time ?: ""}".trim(), color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
} else if (item is TSODirectoryEntry) {
- Text(item.datasetName, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(220.dp))
- Text(item.volume, color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp))
- Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
- Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
- Text("${item.blksize}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
- Text("${item.tracksAllocated}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
- Text("${item.extents}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
+ Text(item.datasetName ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(240.dp))
+ Text(item.volume ?: "", color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp))
+ Text(item.recfm ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
+ Text(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
+ Text(if (item.blksize > 0) "${item.blksize}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
+ Text(if (item.tracksAllocated > 0) "${item.tracksAllocated}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
+ Text(if (item.extents > 0) "${item.extents}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
}
@@ -283,25 +867,41 @@ fun HostDirectoryDialog(
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End,
+ horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- TextButton(onClick = onDismiss) {
- Text("Cancel", color = Color.LightGray)
- }
- Spacer(modifier = Modifier.width(6.dp))
- Button(
- onClick = {
- if (selectedIndex in entries.indices) {
- val selectedName = entries[selectedIndex].name
- onSelectDataset(selectedName)
- onDismiss()
+ Row {
+ if (hostType == FTConfig.HostType.TSO) {
+ OutlinedButton(
+ onClick = { inspectSelectedDataset() },
+ enabled = selectedIndex in currentEntries.indices && !isLoading,
+ contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)
+ ) {
+ Text("Inspect (LISTDS)", fontSize = 11.sp)
}
- },
- enabled = selectedIndex in entries.indices,
- colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
- ) {
- Text("Select Dataset")
+ }
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel", color = Color.LightGray)
+ }
+ Spacer(modifier = Modifier.width(6.dp))
+ Button(
+ onClick = {
+ if (selectedIndex in currentEntries.indices) {
+ val entry = currentEntries[selectedIndex]
+ val selectedName = entry.name
+ onSelectEntry?.invoke(selectedName, entry)
+ onSelectDataset(selectedName)
+ onDismiss()
+ }
+ },
+ enabled = selectedIndex in currentEntries.indices,
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
+ ) {
+ Text("Select Dataset")
+ }
}
}
}
diff --git a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt
index 5fc69ed..1d069c6 100644
--- a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt
+++ b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt
@@ -52,6 +52,7 @@ fun SettingsDialog(
initialFourColorOverride: Boolean = false,
initialNumericFieldLock: Boolean = true,
initialAutoSkipEnabled: Boolean = true,
+ initialDefaultHostType: String = "TSO",
onDismiss: () -> Unit,
onSave: (
maskHiddenInput: Boolean,
@@ -85,7 +86,8 @@ fun SettingsDialog(
insertOffOnAid: Boolean,
fourColorOverride: Boolean,
numericFieldLock: Boolean,
- autoSkipEnabled: Boolean
+ autoSkipEnabled: Boolean,
+ defaultHostType: String
) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
@@ -129,6 +131,7 @@ fun SettingsDialog(
var fourColorOverride by remember { mutableStateOf(initialFourColorOverride) }
var numericFieldLock by remember { mutableStateOf(initialNumericFieldLock) }
var autoSkipEnabled by remember { mutableStateOf(initialAutoSkipEnabled) }
+ var defaultHostType by remember { mutableStateOf(initialDefaultHostType) }
var showImportDialog by remember { mutableStateOf(false) }
@@ -658,6 +661,29 @@ fun SettingsDialog(
Spacer(modifier = Modifier.height(12.dp))
+ // Default Host Type
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("Default Host Type", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
+ Text("Target system environment for IND\$FILE transfers and directory queries.", fontSize = 12.sp, color = Color.LightGray)
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ listOf("TSO", "CMS", "CICS").forEach { hostTypeKey ->
+ val isSelected = defaultHostType.equals(hostTypeKey, ignoreCase = true)
+ FilterChip(
+ selected = isSelected,
+ onClick = { defaultHostType = hostTypeKey },
+ label = { Text(hostTypeKey, fontSize = 10.sp) },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
// Verify TLS Certs
Row(
modifier = Modifier.fillMaxWidth(),
@@ -887,6 +913,15 @@ fun SettingsDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
+ Button(
+ onClick = {
+ haus.nightmare.a3270.storage.AppSettings.shareSettingsIni(context)
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1971C2)),
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Share INI", fontSize = 12.sp)
+ }
OutlinedButton(
onClick = {
val sw = java.io.StringWriter()
@@ -897,13 +932,13 @@ fun SettingsDialog(
},
modifier = Modifier.weight(1f)
) {
- Text("Export to INI", fontSize = 12.sp)
+ Text("Copy", fontSize = 12.sp)
}
OutlinedButton(
onClick = { showImportDialog = true },
modifier = Modifier.weight(1f)
) {
- Text("Import from INI", fontSize = 12.sp)
+ Text("Import", fontSize = 12.sp)
}
}
@@ -957,7 +992,8 @@ fun SettingsDialog(
insertOffOnAid,
fourColorOverride,
numericFieldLock,
- autoSkipEnabled
+ autoSkipEnabled,
+ defaultHostType
)
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true)) {
@@ -1033,6 +1069,7 @@ fun SettingsDialog(
fourColorOverride = haus.nightmare.a3270.storage.AppSettings.getFourColorOverride(context)
numericFieldLock = haus.nightmare.a3270.storage.AppSettings.getNumericFieldLock(context)
autoSkipEnabled = haus.nightmare.a3270.storage.AppSettings.getAutoSkipEnabled(context)
+ defaultHostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
android.widget.Toast.makeText(context, "Settings imported from INI", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()