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