Connection hardening and FileTransfer UI changes
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 1m20s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 1m32s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 2m7s

This commit is contained in:
2026-09-24 04:15:36 +00:00
parent 8286af19e6
commit 1f0e36c561
24 changed files with 2578 additions and 178 deletions
@@ -27,6 +27,30 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
private static final Logger log = Logger.getLogger(J3270App.class.getName()); private static final Logger log = Logger.getLogger(J3270App.class.getName());
// Active open windows tracking
private static final java.util.List<J3270App> openWindows = new java.util.concurrent.CopyOnWriteArrayList<>();
public static java.util.List<J3270App> getOpenWindows() {
return java.util.Collections.unmodifiableList(openWindows);
}
@FunctionalInterface
public interface ConnectChoiceHandler {
ConnectChoiceDialog.Choice askChoice(Window owner, String currentTarget);
}
private ConnectChoiceHandler connectChoiceHandler = null;
public void setConnectChoiceHandler(ConnectChoiceHandler handler) {
this.connectChoiceHandler = handler;
}
public ConnectChoiceHandler getConnectChoiceHandler() {
return connectChoiceHandler;
}
private final java.util.function.Consumer<UITheme> themeChangeListener = this::onThemeChanged;
private Telnet3270Client client; private Telnet3270Client client;
private TerminalPanel terminalPanel; private TerminalPanel terminalPanel;
private StatusBar statusBar; private StatusBar statusBar;
@@ -60,14 +84,16 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
public J3270App() { public J3270App() {
super("j3270 — Java TN3270 Terminal Emulator"); super("j3270 — Java TN3270 Terminal Emulator");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
setBackground(Color.BLACK); setBackground(Color.BLACK);
AppIcon.applyTo(this); AppIcon.applyTo(this);
openWindows.add(this);
buildUI(); buildUI();
buildMenuBar(); buildMenuBar();
ThemeManager.addThemeChangeListener(this::onThemeChanged); ThemeManager.addThemeChangeListener(themeChangeListener);
pack(); pack();
setLocationRelativeTo(null); setLocationRelativeTo(null);
@@ -92,16 +118,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
addWindowListener(new WindowAdapter() { addWindowListener(new WindowAdapter() {
@Override @Override
public void windowClosing(WindowEvent e) { public void windowClosing(WindowEvent e) {
disconnect(); closeWindow();
if (refreshTimer != null)
refreshTimer.stop();
if (printerSessionDialog != null) {
printerSessionDialog.dispose();
}
if (screenHistoryDialog != null) {
screenHistoryDialog.dispose();
}
terminalPanel.dispose();
} }
@Override @Override
@@ -202,6 +219,8 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
fileMenu.add(createMenuItem("Connect...", KeyEvent.VK_N, this::showConnectDialog)); fileMenu.add(createMenuItem("Connect...", KeyEvent.VK_N, this::showConnectDialog));
fileMenu.add(createMenuItem("Disconnect", KeyEvent.VK_D, this::disconnect)); fileMenu.add(createMenuItem("Disconnect", KeyEvent.VK_D, this::disconnect));
fileMenu.addSeparator(); fileMenu.addSeparator();
fileMenu.add(createMenuItem("Close Window", KeyEvent.VK_W, this::closeWindow));
fileMenu.addSeparator();
fileMenu.add(createMenuItem("Save Screen As...", -1, () -> ScreenExporter.showExportDialog(this, client, terminalPanel))); fileMenu.add(createMenuItem("Save Screen As...", -1, () -> ScreenExporter.showExportDialog(this, client, terminalPanel)));
fileMenu.add(createMenuItem("3287 Printer Session...", -1, this::showPrinterSessionDialog)); fileMenu.add(createMenuItem("3287 Printer Session...", -1, this::showPrinterSessionDialog));
fileMenu.add(createMenuItem("Print Screen...", KeyEvent.VK_P, this::printScreen)); fileMenu.add(createMenuItem("Print Screen...", KeyEvent.VK_P, this::printScreen));
@@ -209,12 +228,49 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
fileMenu.add(createMenuItem("Settings...", -1, this::showSettingsDialog)); fileMenu.add(createMenuItem("Settings...", -1, this::showSettingsDialog));
fileMenu.addSeparator(); fileMenu.addSeparator();
fileMenu.add(createMenuItem("Quit", KeyEvent.VK_Q, () -> { fileMenu.add(createMenuItem("Quit", KeyEvent.VK_Q, () -> {
disconnect(); for (J3270App win : new java.util.ArrayList<>(openWindows)) {
win.disconnect();
win.dispose();
}
System.exit(0); System.exit(0);
})); }));
menuBar.add(fileMenu); menuBar.add(fileMenu);
// 2. Edit menu // 2. Connection menu
JMenu connectionMenu = createMenu("Connection");
connectionMenu.add(createMenuItem("Connect...", KeyEvent.VK_N, this::showConnectDialog));
connectionMenu.add(createMenuItem("Disconnect", KeyEvent.VK_D, this::disconnect));
connectionMenu.addSeparator();
// Host Type Submenu (CMS/TSO/CICS)
JMenu hostTypeMenu = createMenu("Host Type");
ButtonGroup hostTypeGroup = new ButtonGroup();
for (haus.nightmare.lib3270j.ft.FTConfig.HostType ht : haus.nightmare.lib3270j.ft.FTConfig.HostType.values()) {
JRadioButtonMenuItem htItem = new JRadioButtonMenuItem(ht.name());
ThemeManager.styleMenuItem(htItem);
hostTypeGroup.add(htItem);
htItem.addActionListener(e -> setConnectionHostType(ht));
hostTypeMenu.add(htItem);
}
connectionMenu.add(hostTypeMenu);
connectionMenu.addSeparator();
// Saved Connections Submenu
JMenu savedHostsMenu = createMenu("Saved Connections");
connectionMenu.add(savedHostsMenu);
connectionMenu.addMenuListener(new javax.swing.event.MenuListener() {
@Override
public void menuSelected(javax.swing.event.MenuEvent e) {
updateConnectionMenu(hostTypeMenu, hostTypeGroup, savedHostsMenu);
}
@Override public void menuDeselected(javax.swing.event.MenuEvent e) {}
@Override public void menuCanceled(javax.swing.event.MenuEvent e) {}
});
menuBar.add(connectionMenu);
// 3. Edit menu
JMenu editMenu = createMenu("Edit"); JMenu editMenu = createMenu("Edit");
editMenu.add(createMenuItem("Cut", KeyEvent.VK_X, () -> terminalPanel.cutSelection())); editMenu.add(createMenuItem("Cut", KeyEvent.VK_X, () -> terminalPanel.cutSelection()));
editMenu.add(createMenuItem("Copy", KeyEvent.VK_C, () -> terminalPanel.copySelection())); editMenu.add(createMenuItem("Copy", KeyEvent.VK_C, () -> terminalPanel.copySelection()));
@@ -743,7 +799,20 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
// ========== Connection management ========== // ========== Connection management ==========
private void showConnectDialog() { public void showConnectDialog() {
boolean wasConnected = isConnected();
if (wasConnected && (!GraphicsEnvironment.isHeadless() || connectChoiceHandler != null)) {
ConnectChoiceDialog.Choice choice = promptConnectConflict();
if (choice == ConnectChoiceDialog.Choice.NEW_WINDOW) {
J3270App newWindow = createNewWindow(null);
SwingUtilities.invokeLater(newWindow::showConnectDialog);
return;
} else if (choice == ConnectChoiceDialog.Choice.CANCEL) {
return;
}
// choice == ABORT_CURRENT: proceed to showConnectDialog in this window
}
ConnectDialog dialog = new ConnectDialog(this); ConnectDialog dialog = new ConnectDialog(this);
String initialHost = lastHost != null && !lastHost.isEmpty() ? lastHost : haus.nightmare.j3270.config.Settings.getAutoConnectHost(); String initialHost = lastHost != null && !lastHost.isEmpty() ? lastHost : haus.nightmare.j3270.config.Settings.getAutoConnectHost();
int initialPort = lastPort > 0 ? lastPort : haus.nightmare.j3270.config.Settings.getAutoConnectPort(); int initialPort = lastPort > 0 ? lastPort : haus.nightmare.j3270.config.Settings.getAutoConnectPort();
@@ -753,13 +822,76 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
dialog.setInitialVerifyCert(haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert()); dialog.setInitialVerifyCert(haus.nightmare.j3270.config.Settings.getAutoConnectVerifyCert());
dialog.setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage()); dialog.setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage());
dialog.setInitialGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode()); dialog.setInitialGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode());
dialog.setInitialHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
dialog.setVisible(true); dialog.setVisible(true);
if (dialog.isConfirmed()) { if (dialog.isConfirmed()) {
ConnectionConfig config = dialog.getConnectionConfig(); ConnectionConfig config = dialog.getConnectionConfig();
lastHost = config.getHost(); lastHost = config.getHost();
lastPort = config.getPort(); lastPort = config.getPort();
connect(config); connect(config, !wasConnected);
}
}
public void setConnectionHostType(haus.nightmare.lib3270j.ft.FTConfig.HostType ht) {
if (ht == null) return;
haus.nightmare.j3270.config.Settings.setDefaultHostType(ht.name());
if (client != null && client.getConfig() != null) {
client.getConfig().setFtHostType(ht);
String profileId = client.getConfig().getSavedProfileId();
if (profileId != null) {
haus.nightmare.j3270.storage.SavedHost saved = haus.nightmare.j3270.storage.HostStorage.findById(profileId);
if (saved != null) {
saved.setHostType(ht.name());
haus.nightmare.j3270.storage.HostStorage.saveHost(saved);
}
}
}
if (FileTransferDialog.getLastTransferState() != null) {
FileTransferDialog.getLastTransferState().hostType = ht;
}
}
private void updateConnectionMenu(JMenu hostTypeMenu, ButtonGroup hostTypeGroup, JMenu savedHostsMenu) {
haus.nightmare.lib3270j.ft.FTConfig.HostType currentHt = haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO;
if (client != null && client.getConfig() != null) {
currentHt = client.getConfig().getFtHostType();
} else {
String def = haus.nightmare.j3270.config.Settings.getDefaultHostType();
try {
if (def != null) currentHt = haus.nightmare.lib3270j.ft.FTConfig.HostType.valueOf(def.trim().toUpperCase());
} catch (Exception ignored) {}
}
for (int i = 0; i < hostTypeMenu.getItemCount(); i++) {
JMenuItem item = hostTypeMenu.getItem(i);
if (item instanceof JRadioButtonMenuItem) {
JRadioButtonMenuItem rb = (JRadioButtonMenuItem) item;
if (rb.getText().equalsIgnoreCase(currentHt.name())) {
rb.setSelected(true);
}
}
}
savedHostsMenu.removeAll();
java.util.List<haus.nightmare.j3270.storage.SavedHost> savedList = haus.nightmare.j3270.storage.HostStorage.getSavedHosts();
if (savedList.isEmpty()) {
JMenuItem noneItem = new JMenuItem("(No Saved Connections)");
noneItem.setEnabled(false);
ThemeManager.styleMenuItem(noneItem);
savedHostsMenu.add(noneItem);
} else {
for (haus.nightmare.j3270.storage.SavedHost sh : savedList) {
String label = sh.getName();
if (label == null || label.trim().isEmpty()) {
label = sh.getHost() + ":" + sh.getPort();
}
String ht = (sh.getHostType() != null && !sh.getHostType().trim().isEmpty()) ? sh.getHostType().trim() : "TSO";
JMenuItem hostItem = new JMenuItem(label + " [" + ht + "]");
ThemeManager.styleMenuItem(hostItem);
hostItem.addActionListener(ev -> connect(sh.toConnectionConfig(), true));
savedHostsMenu.add(hostItem);
}
} }
} }
@@ -768,6 +900,79 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
dialog.setVisible(true); dialog.setVisible(true);
} }
public void closeWindow() {
disconnect();
if (refreshTimer != null) {
refreshTimer.stop();
}
if (printerSessionDialog != null) {
printerSessionDialog.dispose();
}
if (screenHistoryDialog != null) {
screenHistoryDialog.dispose();
}
terminalPanel.dispose();
ThemeManager.removeThemeChangeListener(themeChangeListener);
openWindows.remove(this);
dispose();
if (openWindows.isEmpty() && !GraphicsEnvironment.isHeadless()) {
System.exit(0);
}
}
@Override
public void dispose() {
openWindows.remove(this);
ThemeManager.removeThemeChangeListener(themeChangeListener);
super.dispose();
}
public boolean isConnected() {
return client != null && (client.isConnected() ||
(client.getConnectionState() != null && client.getConnectionState().isConnected()));
}
public String getConnectedTarget() {
if (client != null && client.getConfig() != null) {
String profileName = client.getConfig().getSavedProfileName();
String host = client.getConfig().getHost();
int port = client.getConfig().getPort();
if (profileName != null && !profileName.trim().isEmpty()) {
return profileName + " (" + host + ":" + port + ")";
}
if (host != null && !host.trim().isEmpty()) {
return host + ":" + port;
}
}
if (lastHost != null && !lastHost.trim().isEmpty()) {
return lastHost + ":" + lastPort;
}
return "the current host";
}
public J3270App createNewWindow(ConnectionConfig config) {
J3270App newApp = new J3270App();
try {
Point loc = getLocation();
newApp.setLocation(loc.x + 30, loc.y + 30);
} catch (Exception ignored) {}
newApp.setVisible(true);
if (config != null) {
newApp.connect(config, false);
}
return newApp;
}
public ConnectChoiceDialog.Choice promptConnectConflict() {
if (connectChoiceHandler != null) {
return connectChoiceHandler.askChoice(this, getConnectedTarget());
}
if (GraphicsEnvironment.isHeadless()) {
return ConnectChoiceDialog.Choice.ABORT_CURRENT;
}
return ConnectChoiceDialog.showPrompt(this, getConnectedTarget());
}
public TerminalPanel getTerminalPanel() { public TerminalPanel getTerminalPanel() {
return terminalPanel; return terminalPanel;
} }
@@ -780,7 +985,22 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
return statusBar; return statusBar;
} }
void connect(ConnectionConfig config) { public void connect(ConnectionConfig config) {
connect(config, true);
}
public void connect(ConnectionConfig config, boolean promptIfConnected) {
if (promptIfConnected && isConnected() && (!GraphicsEnvironment.isHeadless() || connectChoiceHandler != null)) {
ConnectChoiceDialog.Choice choice = promptConnectConflict();
if (choice == ConnectChoiceDialog.Choice.NEW_WINDOW) {
createNewWindow(config);
return;
} else if (choice == ConnectChoiceDialog.Choice.CANCEL) {
return;
}
// choice == ABORT_CURRENT: proceed to connect in this window
}
lastHost = config.getHost(); lastHost = config.getHost();
lastPort = config.getPort(); lastPort = config.getPort();
@@ -789,11 +1009,29 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
haus.nightmare.j3270.config.Settings.setAutoConnectTls(config.isUseTls()); haus.nightmare.j3270.config.Settings.setAutoConnectTls(config.isUseTls());
haus.nightmare.j3270.config.Settings.setAutoConnectVerifyCert(config.isTlsVerifyCert()); haus.nightmare.j3270.config.Settings.setAutoConnectVerifyCert(config.isTlsVerifyCert());
haus.nightmare.j3270.config.Settings.setCodePage(config.getCodePage()); haus.nightmare.j3270.config.Settings.setCodePage(config.getCodePage());
if (config.getHostType() != null) {
haus.nightmare.j3270.config.Settings.setDefaultHostType(config.getHostType());
}
if (FileTransferDialog.getLastTransferState() != null && config.isSavedConnection()) {
FileTransferDialog.getLastTransferState().hostType = config.getFtHostType();
}
if (config.getGraphicsMode() != null) { if (config.getGraphicsMode() != null) {
haus.nightmare.j3270.config.Settings.setGraphicsMode(config.getGraphicsMode()); haus.nightmare.j3270.config.Settings.setGraphicsMode(config.getGraphicsMode());
} }
disconnect(); Telnet3270Client oldClient = client;
if (oldClient != null) {
if (fileTransfer != null) {
fileTransfer.cancel();
fileTransfer = null;
}
if (macroDialog != null) {
macroDialog.stopMacro();
}
oldClient.removeConnectionListener(this);
oldClient.removeScreenUpdateListener(this);
client = null;
}
if (config.getCertificateVerifier() == null) { if (config.getCertificateVerifier() == null) {
config.setCertificateVerifier((chain, authType, exception) -> config.setCertificateVerifier((chain, authType, exception) ->
@@ -827,6 +1065,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
new Thread(() -> { new Thread(() -> {
try { try {
if (oldClient != null) {
try {
oldClient.disconnect(1000);
} catch (Exception ignored) {}
}
client.connect(); client.connect();
} catch (IOException e) { } catch (IOException e) {
log.log(Level.WARNING, "Connection failed", e); log.log(Level.WARNING, "Connection failed", e);
@@ -841,7 +1084,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
terminalPanel.requestFocusInWindow(); terminalPanel.requestFocusInWindow();
} }
private void disconnect() { public void disconnect() {
disconnect(true);
}
public void disconnect(boolean resetUi) {
if (client != null) { if (client != null) {
if (fileTransfer != null) { if (fileTransfer != null) {
fileTransfer.cancel(); fileTransfer.cancel();
@@ -852,23 +1099,28 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
} }
Telnet3270Client c = client; Telnet3270Client c = client;
client = null; client = null;
c.removeConnectionListener(this);
c.removeScreenUpdateListener(this);
new Thread(() -> { new Thread(() -> {
try { try {
c.disconnect(); c.disconnect(1000);
} catch (Exception ignored) {} } catch (Exception ignored) {}
}, "Disconnect-Thread").start(); }, "Disconnect-Thread").start();
Runnable uiReset = () -> { if (resetUi) {
terminalPanel.setClient(null); Runnable uiReset = () -> {
statusBar.setClient(null, terminalPanel); terminalPanel.setClient(null);
syncModeMenuItems(); statusBar.setClient(null, terminalPanel);
terminalPanel.repaint(); syncModeMenuItems();
setTitle("j3270 — Java TN3270 Terminal Emulator"); terminalPanel.repaint();
}; setTitle("j3270 — Java TN3270 Terminal Emulator");
if (SwingUtilities.isEventDispatchThread()) { };
uiReset.run(); if (SwingUtilities.isEventDispatchThread()) {
} else { uiReset.run();
SwingUtilities.invokeLater(uiReset); } else {
SwingUtilities.invokeLater(uiReset);
}
} }
} }
} }
@@ -1201,6 +1453,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
String configFile = null; String configFile = null;
String cliProxyUrl = null; String cliProxyUrl = null;
String cliProfileName = null; String cliProfileName = null;
String cliHostType = null;
java.util.List<String> remainingArgs = new java.util.ArrayList<>(); java.util.List<String> remainingArgs = new java.util.ArrayList<>();
for (int i = 0; i < args.length; i++) { for (int i = 0; i < args.length; i++) {
@@ -1219,6 +1472,10 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
cliAutoSysUnlock = true; cliAutoSysUnlock = true;
} else if ("--no-auto-sys-unlock".equals(arg)) { } else if ("--no-auto-sys-unlock".equals(arg)) {
cliAutoSysUnlock = false; cliAutoSysUnlock = false;
} else if (arg.startsWith("--host-type=")) {
cliHostType = arg.substring(12).trim().toUpperCase();
} else if (("--host-type".equals(arg) || "-ht".equals(arg)) && i + 1 < args.length && !args[i + 1].startsWith("-")) {
cliHostType = args[++i].trim().toUpperCase();
} else if (arg.startsWith("--graphics=")) { } else if (arg.startsWith("--graphics=")) {
cliGraphicsMode = GraphicsMode.fromString(arg.substring(11)); cliGraphicsMode = GraphicsMode.fromString(arg.substring(11));
} else if (("--graphics".equals(arg) || "-g".equals(arg)) && i + 1 < args.length && !args[i + 1].startsWith("-")) { } else if (("--graphics".equals(arg) || "-g".equals(arg)) && i + 1 < args.length && !args[i + 1].startsWith("-")) {
@@ -1273,6 +1530,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
final GraphicsMode finalGraphicsMode = cliGraphicsMode; final GraphicsMode finalGraphicsMode = cliGraphicsMode;
final String finalProxyUrl = cliProxyUrl; final String finalProxyUrl = cliProxyUrl;
final String finalProfileName = cliProfileName; final String finalProfileName = cliProfileName;
final String finalHostType = cliHostType;
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
J3270App app = new J3270App(); J3270App app = new J3270App();
@@ -1285,6 +1543,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
} }
if (profile != null) { if (profile != null) {
ConnectionConfig config = profile.toConnectionConfig(); ConnectionConfig config = profile.toConnectionConfig();
if (finalHostType != null) config.setHostType(finalHostType);
if (finalTls) config.setUseTls(true); if (finalTls) config.setUseTls(true);
if (finalNoVerify) config.setTlsVerifyCert(false); if (finalNoVerify) config.setTlsVerifyCert(false);
if (finalTn3270e != null) config.setTn3270eEnabled(finalTn3270e); if (finalTn3270e != null) config.setTn3270eEnabled(finalTn3270e);
@@ -1341,6 +1600,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
if (finalProxyUrl != null) { if (finalProxyUrl != null) {
applyProxyUrl(config, finalProxyUrl); applyProxyUrl(config, finalProxyUrl);
} }
if (finalHostType != null) {
config.setHostType(finalHostType);
} else {
config.setHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
}
app.connect(config); app.connect(config);
} else { } else {
haus.nightmare.j3270.config.Settings.StartupBehavior behavior = haus.nightmare.j3270.config.Settings haus.nightmare.j3270.config.Settings.StartupBehavior behavior = haus.nightmare.j3270.config.Settings
@@ -1353,6 +1617,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
haus.nightmare.j3270.storage.SavedHost autoHost = haus.nightmare.j3270.storage.HostStorage.getAutoConnectHost(); haus.nightmare.j3270.storage.SavedHost autoHost = haus.nightmare.j3270.storage.HostStorage.getAutoConnectHost();
if (autoHost != null) { if (autoHost != null) {
ConnectionConfig config = autoHost.toConnectionConfig(); ConnectionConfig config = autoHost.toConnectionConfig();
if (finalHostType != null) config.setHostType(finalHostType);
if (finalTls) config.setUseTls(true); if (finalTls) config.setUseTls(true);
if (finalNoVerify) config.setTlsVerifyCert(false); if (finalNoVerify) config.setTlsVerifyCert(false);
if (finalTn3270e != null) config.setTn3270eEnabled(finalTn3270e); if (finalTn3270e != null) config.setTn3270eEnabled(finalTn3270e);
@@ -1381,6 +1646,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
if (finalProxyUrl != null) { if (finalProxyUrl != null) {
applyProxyUrl(config, finalProxyUrl); applyProxyUrl(config, finalProxyUrl);
} }
if (finalHostType != null) {
config.setHostType(finalHostType);
} else {
config.setHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
}
app.connect(config); app.connect(config);
} else { } else {
SwingUtilities.invokeLater(app::showConnectDialog); SwingUtilities.invokeLater(app::showConnectDialog);
@@ -163,6 +163,15 @@ public class Settings {
return getAutoConnectAutoReconnect(); return getAutoConnectAutoReconnect();
} }
public static String getDefaultHostType() {
return prefs.get("defaultHostType", "TSO");
}
public static void setDefaultHostType(String hostType) {
prefs.put("defaultHostType", (hostType != null && !hostType.trim().isEmpty()) ? hostType.trim().toUpperCase() : "TSO");
flushPrefs();
}
public static void setAutoReconnect(boolean autoReconnect) { public static void setAutoReconnect(boolean autoReconnect) {
setAutoConnectAutoReconnect(autoReconnect); setAutoConnectAutoReconnect(autoReconnect);
} }
@@ -738,6 +747,11 @@ public class Settings {
case "dynamic_cols": case "dynamic_cols":
setDynamicCols(Integer.parseInt(value)); setDynamicCols(Integer.parseInt(value));
break; break;
case "hostType":
case "hosttype":
case "host_type":
setDefaultHostType(value);
break;
case "blockSelectMode": setBlockSelectMode(Boolean.parseBoolean(value)); break; case "blockSelectMode": setBlockSelectMode(Boolean.parseBoolean(value)); break;
case "blockPaste": setBlockPaste(Boolean.parseBoolean(value)); break; case "blockPaste": setBlockPaste(Boolean.parseBoolean(value)); break;
case "autoReconnect": case "autoReconnect":
@@ -945,6 +959,7 @@ public class Settings {
w.println("autoConnectTn3270e = " + getAutoConnectTn3270e()); w.println("autoConnectTn3270e = " + getAutoConnectTn3270e());
} }
w.println("codePage = " + getCodePage()); w.println("codePage = " + getCodePage());
w.println("hostType = " + getDefaultHostType());
w.println("dynamicRows = " + getDynamicRows()); w.println("dynamicRows = " + getDynamicRows());
w.println("dynamicCols = " + getDynamicCols()); w.println("dynamicCols = " + getDynamicCols());
w.println("blockSelectMode = " + getBlockSelectMode()); w.println("blockSelectMode = " + getBlockSelectMode());
@@ -5,6 +5,7 @@ import haus.nightmare.j3270.ui.HostDirectoryDialog;
import haus.nightmare.j3270.ui.ThemeManager; import haus.nightmare.j3270.ui.ThemeManager;
import haus.nightmare.lib3270j.ft.FTConfig; import haus.nightmare.lib3270j.ft.FTConfig;
import haus.nightmare.lib3270j.ft.FTConstants; import haus.nightmare.lib3270j.ft.FTConstants;
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry;
import javax.swing.*; import javax.swing.*;
import javax.swing.border.EmptyBorder; import javax.swing.border.EmptyBorder;
@@ -329,6 +330,20 @@ public class FileTransferDialog extends JDialog {
if (lastTransferState.dirBlocks != null) dirField.setText(lastTransferState.dirBlocks); if (lastTransferState.dirBlocks != null) dirField.setText(lastTransferState.dirBlocks);
if (lastTransferState.options != null) optionsField.setText(lastTransferState.options); if (lastTransferState.options != null) optionsField.setText(lastTransferState.options);
} }
// When using a saved connection (or when no previous transfer state exists for this connection),
// use the configured host type (CMS/TSO/CICS) for File Transfers
if (coordinator != null && coordinator.getClient() != null) {
haus.nightmare.lib3270j.ConnectionConfig cfg = coordinator.getClient().getConfig();
if (cfg != null) {
FTConfig.HostType configuredType = cfg.getFtHostType();
if (configuredType != null) {
if (cfg.isSavedConnection() || lastTransferState == null) {
hostTypeCombo.setSelectedItem(configuredType);
}
}
}
}
} }
private void updateOptionStates() { private void updateOptionStates() {
@@ -381,11 +396,26 @@ public class FileTransferDialog extends JDialog {
private void browseHostDirectory() { private void browseHostDirectory() {
FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem(); FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
String initialQuery = hostFileField.getText().trim(); String initialQuery = hostFileField.getText().trim();
if (hostType == FTConfig.HostType.CMS && (initialQuery.isEmpty() || initialQuery.equals("* * *") || initialQuery.equals("*"))) {
initialQuery = "* * A";
}
HostDirectoryDialog dialog = new HostDirectoryDialog(this, coordinator.getClient(), hostType, initialQuery); HostDirectoryDialog dialog = new HostDirectoryDialog(this, coordinator.getClient(), hostType, initialQuery);
dialog.setVisible(true); dialog.setVisible(true);
if (dialog.isConfirmed() && dialog.getSelectedHostFile() != null) { if (dialog.isConfirmed() && dialog.getSelectedHostFile() != null) {
hostFileField.setText(dialog.getSelectedHostFile()); hostFileField.setText(dialog.getSelectedHostFile());
HostDirectoryEntry entry = dialog.getSelectedEntry();
if (entry != null) {
if (entry.getRecfm() != null && !entry.getRecfm().trim().isEmpty()) {
recfmField.setText(entry.getRecfm().trim());
}
if (entry.getLrecl() > 0) {
lreclField.setText(String.valueOf(entry.getLrecl()));
}
if (entry.getBlksize() > 0) {
blksizeField.setText(String.valueOf(entry.getBlksize()));
}
}
boolean shouldAutofill = localFileField.getText().trim().isEmpty() || boolean shouldAutofill = localFileField.getText().trim().isEmpty() ||
(lastTransferState != null && localFileField.getText().trim().equals(lastTransferState.localFile)); (lastTransferState != null && localFileField.getText().trim().equals(lastTransferState.localFile));
if (shouldAutofill) { if (shouldAutofill) {
@@ -108,6 +108,10 @@ public class SavedHost {
} }
ConnectionConfig config = new ConnectionConfig(host, port, tm, useTls); ConnectionConfig config = new ConnectionConfig(host, port, tm, useTls);
config.setSavedConnection(true);
config.setSavedProfileId(this.id);
config.setSavedProfileName(this.name);
config.setHostType(this.hostType != null ? this.hostType : "TSO");
if (model == 0 || tm.isDynamic()) { if (model == 0 || tm.isDynamic()) {
config.setDynamicDimensions(dynamicRows, dynamicCols); config.setDynamicDimensions(dynamicRows, dynamicCols);
} }
@@ -0,0 +1,146 @@
package haus.nightmare.j3270.ui;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.awt.event.KeyEvent;
/**
* Dialogue shown when a connection is initiated while j3270 is already connected.
* Asks the user whether the connection should be opened in a new window or
* if the current connection should be aborted.
*/
public class ConnectChoiceDialog extends JDialog {
public enum Choice {
NEW_WINDOW,
ABORT_CURRENT,
CANCEL
}
private Choice result = Choice.CANCEL;
private final JButton newWindowBtn;
private final JButton abortBtn;
private final JButton cancelBtn;
private final JLabel titleLabel;
private final JLabel line1;
private final JLabel line2;
public ConnectChoiceDialog(Window owner, String currentTarget) {
super(owner, "Active Connection", ModalityType.APPLICATION_MODAL);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setResizable(false);
JPanel mainPanel = new JPanel(new BorderLayout(16, 16));
mainPanel.setBorder(new EmptyBorder(20, 24, 20, 24));
// Message Panel
JPanel messagePanel = new JPanel();
messagePanel.setLayout(new BoxLayout(messagePanel, BoxLayout.Y_AXIS));
messagePanel.setOpaque(false);
titleLabel = new JLabel("Connection Already Active");
titleLabel.setFont(ThemeManager.getUiFont(Font.BOLD, 2));
messagePanel.add(titleLabel);
messagePanel.add(Box.createVerticalStrut(10));
String targetText = (currentTarget != null && !currentTarget.trim().isEmpty())
? currentTarget.trim()
: "the host";
line1 = new JLabel("j3270 is currently connected to " + targetText + ".");
line1.setFont(ThemeManager.getUiFont(Font.PLAIN, 0));
messagePanel.add(line1);
messagePanel.add(Box.createVerticalStrut(6));
line2 = new JLabel("Would you like to open the connection in a new window or abort the current connection?");
line2.setFont(ThemeManager.getUiFont(Font.PLAIN, 0));
messagePanel.add(line2);
mainPanel.add(messagePanel, BorderLayout.CENTER);
// Buttons
JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0));
buttonPanel.setOpaque(false);
cancelBtn = new JButton("Cancel");
ThemeManager.styleButton(cancelBtn, ThemeManager.ButtonVariant.CANCEL);
cancelBtn.setFont(ThemeManager.getUiFont(Font.PLAIN, 0));
cancelBtn.addActionListener(e -> {
result = Choice.CANCEL;
dispose();
});
abortBtn = new JButton("Abort Current Connection");
ThemeManager.styleButton(abortBtn, ThemeManager.ButtonVariant.DANGER);
abortBtn.setFont(ThemeManager.getUiFont(Font.PLAIN, 0));
abortBtn.addActionListener(e -> {
result = Choice.ABORT_CURRENT;
dispose();
});
newWindowBtn = new JButton("New Window");
ThemeManager.styleButton(newWindowBtn, ThemeManager.ButtonVariant.PRIMARY);
newWindowBtn.setFont(ThemeManager.getUiFont(Font.BOLD, 0));
newWindowBtn.addActionListener(e -> {
result = Choice.NEW_WINDOW;
dispose();
});
buttonPanel.add(cancelBtn);
buttonPanel.add(abortBtn);
buttonPanel.add(newWindowBtn);
mainPanel.add(buttonPanel, BorderLayout.SOUTH);
setContentPane(mainPanel);
ThemeManager.applyThemeToWindow(this);
// ESC key cancels
getRootPane().registerKeyboardAction(e -> {
result = Choice.CANCEL;
dispose();
}, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_IN_FOCUSED_WINDOW);
getRootPane().setDefaultButton(newWindowBtn);
pack();
if (owner != null) {
setLocationRelativeTo(owner);
} else {
setLocationRelativeTo(null);
}
}
public Choice getChoice() {
return result;
}
public JButton getNewWindowButton() {
return newWindowBtn;
}
public JButton getAbortButton() {
return abortBtn;
}
public JButton getCancelButton() {
return cancelBtn;
}
public JLabel getTitleLabel() {
return titleLabel;
}
public JLabel getLine1() {
return line1;
}
public JLabel getLine2() {
return line2;
}
public static Choice showPrompt(Window owner, String currentTarget) {
ConnectChoiceDialog dialog = new ConnectChoiceDialog(owner, currentTarget);
dialog.setVisible(true);
return dialog.getChoice();
}
}
@@ -36,6 +36,7 @@ public class ConnectDialog extends JDialog {
private JComboBox<haus.nightmare.lib3270j.graphics.GraphicsMode> graphicsCombo; private JComboBox<haus.nightmare.lib3270j.graphics.GraphicsMode> graphicsCombo;
private JComboBox<String> codePageCombo; private JComboBox<String> codePageCombo;
private JTextField luField; private JTextField luField;
private JComboBox<String> hostTypeCombo;
private JCheckBox tlsCheckBox; private JCheckBox tlsCheckBox;
private JCheckBox verifyCertCheckBox; private JCheckBox verifyCertCheckBox;
private JCheckBox tn3270eCheckBox; private JCheckBox tn3270eCheckBox;
@@ -214,6 +215,25 @@ public class ConnectDialog extends JDialog {
gridy++; gridy++;
// Host Type (CMS/TSO/CICS)
gbc.gridx = 0;
gbc.gridy = gridy;
gbc.weightx = 0;
JLabel hostTypeLabel = new JLabel("Host Type:");
hostTypeLabel.setFont(labelFont);
contentPanel.add(hostTypeLabel, gbc);
gbc.gridx = 1;
gbc.weightx = 1.0;
String[] hostTypes = {"TSO", "CMS", "CICS"};
hostTypeCombo = new JComboBox<>(hostTypes);
hostTypeCombo.setFont(ThemeManager.getUiFont());
ThemeManager.styleComboBox(hostTypeCombo);
setInitialHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
contentPanel.add(hostTypeCombo, gbc);
gridy++;
// 6. Graphics Mode // 6. Graphics Mode
gbc.gridx = 0; gbc.gridx = 0;
gbc.gridy = gridy; gbc.gridy = gridy;
@@ -565,6 +585,7 @@ public class ConnectDialog extends JDialog {
} else { } else {
selectedProfileId = null; selectedProfileId = null;
deleteProfileBtn.setEnabled(false); deleteProfileBtn.setEnabled(false);
setInitialHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
} }
} }
@@ -590,6 +611,7 @@ public class ConnectDialog extends JDialog {
dynamicRowsSpinner.setValue(h.getDynamicRows() > 0 ? h.getDynamicRows() : 62); dynamicRowsSpinner.setValue(h.getDynamicRows() > 0 ? h.getDynamicRows() : 62);
dynamicColsSpinner.setValue(h.getDynamicCols() > 0 ? h.getDynamicCols() : 160); dynamicColsSpinner.setValue(h.getDynamicCols() > 0 ? h.getDynamicCols() : 160);
luField.setText(h.getLuName() != null ? h.getLuName() : ""); luField.setText(h.getLuName() != null ? h.getLuName() : "");
setInitialHostType(h.getHostType() != null ? h.getHostType() : "TSO");
tlsCheckBox.setSelected(h.isUseTls()); tlsCheckBox.setSelected(h.isUseTls());
verifyCertCheckBox.setEnabled(h.isUseTls()); verifyCertCheckBox.setEnabled(h.isUseTls());
verifyCertCheckBox.setSelected(h.isTlsVerifyCert()); verifyCertCheckBox.setSelected(h.isTlsVerifyCert());
@@ -677,6 +699,7 @@ public class ConnectDialog extends JDialog {
int dRows = (Integer) dynamicRowsSpinner.getValue(); int dRows = (Integer) dynamicRowsSpinner.getValue();
int dCols = (Integer) dynamicColsSpinner.getValue(); int dCols = (Integer) dynamicColsSpinner.getValue();
String lu = luField.getText().trim(); String lu = luField.getText().trim();
String hostType = getSelectedHostType();
boolean tls = tlsCheckBox.isSelected(); boolean tls = tlsCheckBox.isSelected();
boolean verify = verifyCertCheckBox.isSelected(); boolean verify = verifyCertCheckBox.isSelected();
boolean tn3270e = tn3270eCheckBox.isSelected(); boolean tn3270e = tn3270eCheckBox.isSelected();
@@ -705,7 +728,7 @@ public class ConnectDialog extends JDialog {
String pPass = new String(proxyPassField.getPassword()); String pPass = new String(proxyPassField.getPassword());
return new SavedHost(id, profileName, h, port, mNum, dRows, dCols, lu, false, return new SavedHost(id, profileName, h, port, mNum, dRows, dCols, lu, false,
"TSO", tls, verify, tn3270e, gfx, cp, ptStr, pHost, pPort, pUser, pPass); hostType, tls, verify, tn3270e, gfx, cp, ptStr, pHost, pPort, pUser, pPass);
} }
// ================= Connect Action ================= // ================= Connect Action =================
@@ -797,10 +820,14 @@ public class ConnectDialog extends JDialog {
result.setProxy(pType, pHost, pPort, pUser.isEmpty() ? null : pUser, pPass.isEmpty() ? null : pPass); result.setProxy(pType, pHost, pPort, pUser.isEmpty() ? null : pUser, pPass.isEmpty() ? null : pPass);
} }
result.setHostType(getSelectedHostType());
if (selectedProfileId != null) { if (selectedProfileId != null) {
result.setSavedConnection(true);
result.setSavedProfileId(selectedProfileId);
SavedHost existing = HostStorage.findById(selectedProfileId); SavedHost existing = HostStorage.findById(selectedProfileId);
String pName = (existing != null && existing.getName() != null && !existing.getName().trim().isEmpty()) String pName = (existing != null && existing.getName() != null && !existing.getName().trim().isEmpty())
? existing.getName() : host; ? existing.getName() : host;
result.setSavedProfileName(pName);
SavedHost updated = buildCurrentSavedHost(pName); SavedHost updated = buildCurrentSavedHost(pName);
HostStorage.saveHost(updated); HostStorage.saveHost(updated);
} }
@@ -902,4 +929,44 @@ public class ConnectDialog extends JDialog {
public String getProxyPassword() { public String getProxyPassword() {
return new String(proxyPassField.getPassword()); return new String(proxyPassField.getPassword());
} }
public void setInitialHostType(String hostType) {
if (hostType != null && hostTypeCombo != null) {
for (int i = 0; i < hostTypeCombo.getItemCount(); i++) {
if (hostTypeCombo.getItemAt(i).equalsIgnoreCase(hostType.trim())) {
hostTypeCombo.setSelectedIndex(i);
return;
}
}
}
}
public void setInitialHostType(haus.nightmare.lib3270j.ft.FTConfig.HostType hostType) {
if (hostType != null) {
setInitialHostType(hostType.name());
}
}
public String getSelectedHostType() {
if (hostTypeCombo != null && hostTypeCombo.getSelectedItem() != null) {
return hostTypeCombo.getSelectedItem().toString().trim().toUpperCase();
}
return "TSO";
}
public haus.nightmare.lib3270j.ft.FTConfig.HostType getSelectedFtHostType() {
try {
return haus.nightmare.lib3270j.ft.FTConfig.HostType.valueOf(getSelectedHostType());
} catch (Exception e) {
return haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO;
}
}
public JComboBox<String> getHostTypeCombo() {
return hostTypeCombo;
}
public String getSelectedProfileId() {
return selectedProfileId;
}
} }
@@ -38,6 +38,7 @@ public class HostDirectoryDialog extends JDialog {
private JLabel statusLabel; private JLabel statusLabel;
private String selectedHostFile = null; private String selectedHostFile = null;
private HostDirectoryEntry selectedEntry = null;
private boolean confirmed = false; private boolean confirmed = false;
private final List<HostDirectoryEntry> allEntries = new ArrayList<>(); private final List<HostDirectoryEntry> allEntries = new ArrayList<>();
@@ -50,7 +51,7 @@ public class HostDirectoryDialog extends JDialog {
AppIcon.applyTo(this); AppIcon.applyTo(this);
buildUI(initialQuery); buildUI(initialQuery);
setSize(780, 460); setSize(840, 480);
setLocationRelativeTo(parent); setLocationRelativeTo(parent);
} }
@@ -78,6 +79,18 @@ public class HostDirectoryDialog extends JDialog {
ThemeManager.styleComboBox(hostTypeCombo); ThemeManager.styleComboBox(hostTypeCombo);
hostTypeCombo.addActionListener(e -> { hostTypeCombo.addActionListener(e -> {
setupTableColumns(); setupTableColumns();
FTConfig.HostType ht = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
if (ht == FTConfig.HostType.CMS) {
String cur = queryField.getText().trim();
if (cur.isEmpty() || cur.equals("* * *") || cur.equals("*")) {
queryField.setText("* * A");
}
} else if (ht == FTConfig.HostType.TSO) {
String cur = queryField.getText().trim();
if (cur.equals("* * A") || cur.equals("* * *")) {
queryField.setText("");
}
}
if (client != null && client.getConnectionState().isFullSession()) { if (client != null && client.getConnectionState().isFullSession()) {
queryHost(); queryHost();
} else { } else {
@@ -93,9 +106,9 @@ public class HostDirectoryDialog extends JDialog {
gbc.gridx = 3; gbc.gridx = 3;
gbc.weightx = 1.0; gbc.weightx = 1.0;
queryField = new JTextField(initialQuery != null ? initialQuery : "", 16); String defaultQuery = resolveDefaultQuery(initialHostType, initialQuery);
queryField = new JTextField(defaultQuery, 16);
queryField.setFont(ThemeManager.getMonospacedUiFont()); queryField.setFont(ThemeManager.getMonospacedUiFont());
ThemeManager.styleTextField(queryField);
queryField.addActionListener(e -> onFilterOrParse()); queryField.addActionListener(e -> onFilterOrParse());
topPanel.add(queryField, gbc); topPanel.add(queryField, gbc);
@@ -118,8 +131,14 @@ public class HostDirectoryDialog extends JDialog {
mainPanel.add(topPanel, BorderLayout.NORTH); mainPanel.add(topPanel, BorderLayout.NORTH);
// Table // Table
tableModel = new DefaultTableModel(); tableModel = new DefaultTableModel() {
@Override
public boolean isCellEditable(int row, int column) {
return false;
}
};
table = new JTable(tableModel); table = new JTable(tableModel);
table.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
ThemeManager.styleTable(table); ThemeManager.styleTable(table);
table.setFont(ThemeManager.getMonospacedUiFont()); table.setFont(ThemeManager.getMonospacedUiFont());
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
@@ -138,21 +157,30 @@ public class HostDirectoryDialog extends JDialog {
}); });
JScrollPane scrollPane = new JScrollPane(table); JScrollPane scrollPane = new JScrollPane(table);
scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED);
ThemeManager.styleScrollPane(scrollPane); ThemeManager.styleScrollPane(scrollPane);
mainPanel.add(scrollPane, BorderLayout.CENTER); mainPanel.add(scrollPane, BorderLayout.CENTER);
// Bottom // Bottom
JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); JPanel bottomPanel = new JPanel(new BorderLayout(5, 6));
bottomPanel.setOpaque(false); bottomPanel.setOpaque(false);
bottomPanel.setBorder(BorderFactory.createEmptyBorder(6, 4, 4, 4));
statusLabel = new JLabel("Enter a dataset pattern or parse active screen."); statusLabel = new JLabel("Enter a dataset pattern or parse active screen.");
statusLabel.setForeground(ThemeManager.getFgMuted()); statusLabel.setForeground(ThemeManager.getFgMuted());
statusLabel.setFont(font); statusLabel.setFont(font);
bottomPanel.add(statusLabel, BorderLayout.WEST); bottomPanel.add(statusLabel, BorderLayout.NORTH);
JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0));
btnPanel.setOpaque(false); btnPanel.setOpaque(false);
JButton inspectBtn = new JButton("Inspect (LISTDS)");
ThemeManager.styleButton(inspectBtn, ThemeManager.ButtonVariant.DEFAULT);
inspectBtn.setFont(font);
inspectBtn.setToolTipText("Retrieve VTOC attributes (RECFM, LRECL, BLKSIZE) for the selected dataset via TSO LISTDS");
inspectBtn.addActionListener(e -> inspectSelectedDataset());
JButton pasteScreenBtn = new JButton("Parse Screen"); JButton pasteScreenBtn = new JButton("Parse Screen");
ThemeManager.styleButton(pasteScreenBtn, ThemeManager.ButtonVariant.DEFAULT); ThemeManager.styleButton(pasteScreenBtn, ThemeManager.ButtonVariant.DEFAULT);
pasteScreenBtn.setFont(font); pasteScreenBtn.setFont(font);
@@ -178,13 +206,14 @@ public class HostDirectoryDialog extends JDialog {
dispose(); dispose();
}); });
btnPanel.add(inspectBtn);
btnPanel.add(pasteScreenBtn); btnPanel.add(pasteScreenBtn);
btnPanel.add(pasteClipBtn); btnPanel.add(pasteClipBtn);
btnPanel.add(Box.createHorizontalStrut(10)); btnPanel.add(Box.createHorizontalStrut(10));
btnPanel.add(selectBtn); btnPanel.add(selectBtn);
btnPanel.add(cancelBtn); btnPanel.add(cancelBtn);
bottomPanel.add(btnPanel, BorderLayout.EAST); bottomPanel.add(btnPanel, BorderLayout.SOUTH);
mainPanel.add(bottomPanel, BorderLayout.SOUTH); mainPanel.add(bottomPanel, BorderLayout.SOUTH);
setContentPane(mainPanel); setContentPane(mainPanel);
@@ -192,14 +221,11 @@ public class HostDirectoryDialog extends JDialog {
// Initial setup: columns and screen parse or host query // Initial setup: columns and screen parse or host query
setupTableColumns(); setupTableColumns();
if (initialQuery != null && !initialQuery.trim().isEmpty()) {
queryField.setText(initialQuery.trim());
}
if (client != null && client.getConnectionState().isFullSession()) { if (client != null && client.getConnectionState().isFullSession()) {
queryHost(); queryHost();
} else { } else {
parseCurrentScreen(); parseCurrentScreen();
if (initialQuery != null && !initialQuery.trim().isEmpty()) { if (!queryField.getText().trim().isEmpty()) {
applyFilter(); applyFilter();
} }
} }
@@ -212,10 +238,21 @@ public class HostDirectoryDialog extends JDialog {
tableModel.setColumnIdentifiers(new Object[]{ tableModel.setColumnIdentifiers(new Object[]{
"Filename", "Filetype", "Filemode", "Format", "Lrecl", "Records", "Date / Time" "Filename", "Filetype", "Filemode", "Format", "Lrecl", "Records", "Date / Time"
}); });
int[] widths = {120, 100, 80, 70, 70, 90, 170};
for (int i = 0; i < widths.length && i < table.getColumnCount(); i++) {
table.getColumnModel().getColumn(i).setPreferredWidth(widths[i]);
}
} else { } else {
tableModel.setColumnIdentifiers(new Object[]{ tableModel.setColumnIdentifiers(new Object[]{
"Dataset Name", "Volume", "Recfm", "Lrecl", "Blksize", "Tracks", "Extents" "Dataset Name", "Volume"
}); });
int[] widths = {620, 160};
for (int i = 0; i < widths.length && i < table.getColumnCount(); i++) {
table.getColumnModel().getColumn(i).setPreferredWidth(widths[i]);
if (i == 0) {
table.getColumnModel().getColumn(0).setMinWidth(250);
}
}
} }
} }
@@ -246,33 +283,82 @@ public class HostDirectoryDialog extends JDialog {
SwingWorker<String, Void> worker = new SwingWorker<>() { SwingWorker<String, Void> worker = new SwingWorker<>() {
@Override @Override
protected String doInBackground() throws Exception { protected String doInBackground() throws Exception {
if (client.getPS() == null) return ""; if (client == null || client.getPS() == null) return "";
// 1. Clear screen and wait for host to unlock keyboard StringBuilder allPages = new StringBuilder();
client.getPS().SendKeys("[clear]"); int maxPages = 500;
waitForKeyboardUnlock(client, 3000);
// 2. Format query command
if (hostType == FTConfig.HostType.CMS) { if (hostType == FTConfig.HostType.CMS) {
String cmsFilter; // CMS Query:
if (pattern.isEmpty()) { // 1. Clear presentation space first to ensure clean state
cmsFilter = "* * *"; client.getPS().SendKeys("[clear]");
} else { waitForKeyboardUnlock(client, 3000);
String clean = pattern.replace("'", "").replace("\"", "").trim();
if (clean.contains(".")) { // 2. Format CMS LISTFILE query command (default to minidisk A unless specified)
int dot = clean.indexOf('.'); String cmsFilter = formatCMSFilter(pattern);
cmsFilter = clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " *"; client.getPS().SendKeys("LISTFILE " + cmsFilter + " ( nohead alloc date[enter]");
} else if (clean.split("\\s+").length == 1) { Thread.sleep(100);
cmsFilter = clean + " * *";
// 3. Multi-page collection loop:
// CMS pagination displays MORE... or HOLDING when a screen is full.
// When advanced with [clear], CMS flashes RUNNING while generating the next screen.
// We poll up to 10 seconds per page for MORE... / HOLDING or Ready;
for (int page = 0; page < maxPages; page++) {
long startWait = System.currentTimeMillis();
long timeoutMs = 10000; // 10 seconds per page
String screen = "";
boolean isMore = false;
while (System.currentTimeMillis() - startWait < timeoutMs) {
waitForKeyboardUnlock(client, 500);
int rows = client.getPS().getRows();
int cols = client.getPS().getCols();
screen = client.getPS().copyString(0, 0, rows - 1, cols - 1);
String upper = screen.toUpperCase();
if (upper.contains("MORE...") || upper.contains("HOLDING")) {
isMore = true;
break;
}
if (upper.contains("READY;") || upper.contains("READY(")) {
break;
}
// Still RUNNING or transitioning, wait and re-check
Thread.sleep(150);
}
allPages.append(screen).append("\n");
if (isMore) {
// In CMS, sending [clear] advances to the next page of output
client.getPS().SendKeys("[clear]");
Thread.sleep(100);
waitForKeyboardUnlock(client, 3000);
} else { } else {
cmsFilter = clean; // Ready; encountered or timed out -> listing complete
break;
} }
} }
client.getPS().SendKeys("LISTFILE " + cmsFilter + " ( nohead alloc date[enter]");
// Automatically clear the listing from the terminal presentation space
// so the terminal screen is left clean for the user while keeping the dialog populated
if (client.getPS() != null) {
client.getPS().SendKeys("[clear]");
waitForKeyboardUnlock(client, 3000);
}
} else { } else {
if (pattern.isEmpty()) { // TSO Query:
client.getPS().SendKeys("LISTCAT[enter]"); // Check if current screen is in ISPF (formatted screen or containing ===> or ISPF)
} else { int rows = client.getPS().getRows();
int cols = client.getPS().getCols();
String initialScreen = client.getPS().copyString(0, 0, rows - 1, cols - 1);
boolean isFormatted = client.getScreenBuffer() != null && client.getScreenBuffer().isFormatted();
boolean hasCmdArrow = initialScreen.contains("===>");
boolean isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF");
String tsoCmd = "TSO LISTCAT ALL";
if (!pattern.isEmpty()) {
String dsnPattern = pattern.trim(); String dsnPattern = pattern.trim();
if (dsnPattern.startsWith("'") && dsnPattern.endsWith("'") && dsnPattern.length() > 2) { if (dsnPattern.startsWith("'") && dsnPattern.endsWith("'") && dsnPattern.length() > 2) {
dsnPattern = dsnPattern.substring(1, dsnPattern.length() - 1); dsnPattern = dsnPattern.substring(1, dsnPattern.length() - 1);
@@ -281,43 +367,139 @@ public class HostDirectoryDialog extends JDialog {
if (paren > 0) { if (paren > 0) {
dsnPattern = dsnPattern.substring(0, paren); dsnPattern = dsnPattern.substring(0, paren);
} }
client.getPS().SendKeys("LISTCAT LEVEL(" + dsnPattern + ")[enter]"); if (dsnPattern.equals("*") || dsnPattern.equals("* * *") || dsnPattern.equals("*.*")) {
} tsoCmd = "TSO LISTCAT ALL";
} } else {
String level = dsnPattern;
// 3. Wait for host to process command and unlock keyboard if (level.endsWith(".*")) {
Thread.sleep(100); level = level.substring(0, level.length() - 2);
waitForKeyboardUnlock(client, 5000); } else if (level.endsWith("*")) {
level = level.substring(0, level.length() - 1);
// 4. Collect pages, handling MORE... on CMS or *** on TSO }
StringBuilder allPages = new StringBuilder(); if (level.isEmpty()) {
int maxPages = 20; tsoCmd = "TSO LISTCAT ALL";
for (int page = 0; page < maxPages; page++) { } else {
int rows = client.getPS().getRows(); tsoCmd = "TSO LISTCAT LEVEL(" + level + ") ALL";
int cols = client.getPS().getCols(); }
String screen = client.getPS().copyString(0, 0, rows - 1, cols - 1);
allPages.append(screen).append("\n");
boolean hasMore = false;
if (hostType == FTConfig.HostType.CMS) {
if (screen.contains("MORE...") || screen.contains("HOLDING")) {
hasMore = true;
client.getPS().SendKeys("[enter]");
} }
}
if (isIspf) {
// In ISPF, do NOT send [clear] because [clear] destroys the ISPF panel.
// Locate the command/option line (indicated by ===>) or home to first input field.
int arrowPos = initialScreen.indexOf("===>");
if (arrowPos >= 0 && client.getScreenBuffer() != null) {
int inputAddr = client.getScreenBuffer().findNextUnprotected(arrowPos);
if (inputAddr > 0) {
client.getScreenBuffer().setCursorAddress(inputAddr);
client.getScreenBuffer().updateDisplaySnapshot();
} else {
client.getPS().SendKeys("[home]");
}
} else {
client.getPS().SendKeys("[home]");
}
client.getPS().SendKeys("[erase_eof]");
client.getPS().SendKeys(tsoCmd + "[enter]");
} else { } else {
if (screen.contains("***")) { // In native TSO (ISPF closed, line mode READY prompt)
hasMore = true; client.getPS().SendKeys("[clear]");
client.getPS().SendKeys("[enter]");
}
}
if (hasMore) {
Thread.sleep(100);
waitForKeyboardUnlock(client, 3000); waitForKeyboardUnlock(client, 3000);
} else { client.getPS().SendKeys(tsoCmd + "[enter]");
break; }
Thread.sleep(100);
// Multi-page collection loop for TSO:
// 1. Wait for output to start appearing after sending command
long startCmd = System.currentTimeMillis();
while (System.currentTimeMillis() - startCmd < 4000) {
waitForKeyboardUnlock(client, 200);
int r = client.getPS().getRows();
int c = client.getPS().getCols();
String s = client.getPS().copyString(0, 0, r - 1, c - 1);
String u = s.toUpperCase();
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);
}
// 2. Multi-page collection
String prevScreen = "";
for (int page = 0; page < maxPages; page++) {
waitForKeyboardUnlock(client, 300);
int r = client.getPS().getRows();
int c = client.getPS().getCols();
String screen = client.getPS().copyString(0, 0, r - 1, c - 1);
String upper = screen.toUpperCase();
if (screen.equals(prevScreen) || screen.trim().isEmpty()) {
break;
}
prevScreen = screen;
allPages.append(screen).append("\n");
if (upper.contains("***")) {
client.getPS().SendKeys("[enter]");
Thread.sleep(100);
waitForKeyboardUnlock(client, 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--")) {
// If screen shows dataset data without ***, send [enter] once to see if *** or more output is triggered
client.getPS().SendKeys("[enter]");
Thread.sleep(100);
waitForKeyboardUnlock(client, 1000);
int r2 = client.getPS().getRows();
int c2 = client.getPS().getCols();
String s2 = client.getPS().copyString(0, 0, r2 - 1, c2 - 1);
if (s2.toUpperCase().contains("***")) {
prevScreen = s2;
allPages.append(s2).append("\n");
client.getPS().SendKeys("[enter]");
Thread.sleep(100);
waitForKeyboardUnlock(client, 2000);
continue;
} else {
if (!s2.equals(screen) && !s2.trim().isEmpty()) {
allPages.append(s2).append("\n");
}
break;
}
} else {
break;
}
}
// 3. Terminal clean-up: clear the list from the terminal presentation space
if (client.getPS() != null) {
if (isIspf) {
client.getPS().SendKeys("[enter]");
Thread.sleep(150);
waitForKeyboardUnlock(client, 1500);
int r = client.getPS().getRows();
int c = client.getPS().getCols();
String currentScr = client.getPS().copyString(0, 0, r - 1, c - 1);
if (currentScr.toUpperCase().contains("NONVSAM") || currentScr.toUpperCase().contains("IDCAMS")
|| currentScr.contains(" --- ") || currentScr.toUpperCase().contains("--RECFM-")
|| (client.getScreenBuffer() != null && !client.getScreenBuffer().isFormatted())) {
client.getPS().SendKeys("[clear]");
waitForKeyboardUnlock(client, 2000);
}
} else {
client.getPS().SendKeys("[clear]");
waitForKeyboardUnlock(client, 2000);
}
} }
} }
return allPages.toString(); return allPages.toString();
} }
@@ -338,6 +520,180 @@ public class HostDirectoryDialog extends JDialog {
worker.execute(); worker.execute();
} }
private void inspectSelectedDataset() {
int row = table.getSelectedRow();
if (row < 0 || row >= currentEntries.size()) {
JOptionPane.showMessageDialog(this, "Please select an entry from the table to inspect.",
"No Selection", JOptionPane.INFORMATION_MESSAGE);
return;
}
HostDirectoryEntry entry = currentEntries.get(row);
if (!(entry instanceof TSODirectoryEntry)) {
JOptionPane.showMessageDialog(this, "Inspection is only supported for TSO datasets.",
"Not Supported", JOptionPane.INFORMATION_MESSAGE);
return;
}
if (client == null || !client.getConnectionState().isFullSession()) {
JOptionPane.showMessageDialog(this, "No active full 3270 session to query host.",
"Not Connected", JOptionPane.WARNING_MESSAGE);
return;
}
TSODirectoryEntry tso = (TSODirectoryEntry) entry;
String dsn = tso.getDatasetName();
if (dsn == null || dsn.trim().isEmpty()) {
return;
}
statusLabel.setText("Inspecting " + dsn + " via TSO LISTDS...");
SwingWorker<String, Void> worker = new SwingWorker<String, Void>() {
@Override
protected String doInBackground() throws Exception {
StringBuilder output = new StringBuilder();
int rows = client.getPS().getRows();
int cols = client.getPS().getCols();
String initialScreen = client.getPS().copyString(0, 0, rows - 1, cols - 1);
boolean isFormatted = client.getScreenBuffer() != null && client.getScreenBuffer().isFormatted();
boolean hasCmdArrow = initialScreen.contains("===>");
boolean isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF");
String tsoCmd = "TSO LISTDS '" + dsn + "' STATUS";
if (isIspf) {
int arrowPos = initialScreen.indexOf("===>");
if (arrowPos >= 0 && client.getScreenBuffer() != null) {
int inputAddr = client.getScreenBuffer().findNextUnprotected(arrowPos);
if (inputAddr > 0) {
client.getScreenBuffer().setCursorAddress(inputAddr);
client.getScreenBuffer().updateDisplaySnapshot();
} else {
client.getPS().SendKeys("[home]");
}
} else {
client.getPS().SendKeys("[home]");
}
client.getPS().SendKeys("[erase_eof]");
client.getPS().SendKeys(tsoCmd + "[enter]");
} else {
client.getPS().SendKeys("[clear]");
waitForKeyboardUnlock(client, 3000);
client.getPS().SendKeys(tsoCmd + "[enter]");
}
Thread.sleep(100);
// Wait for output to start appearing
long startCmd = System.currentTimeMillis();
while (System.currentTimeMillis() - startCmd < 4000) {
waitForKeyboardUnlock(client, 200);
int r = client.getPS().getRows();
int c = client.getPS().getCols();
String s = client.getPS().copyString(0, 0, r - 1, c - 1);
String u = s.toUpperCase();
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);
}
String prevScreen = "";
for (int page = 0; page < 5; page++) {
waitForKeyboardUnlock(client, 300);
int r = client.getPS().getRows();
int c = client.getPS().getCols();
String screen = client.getPS().copyString(0, 0, r - 1, c - 1);
String upper = screen.toUpperCase();
if (screen.equals(prevScreen) || screen.trim().isEmpty()) {
break;
}
prevScreen = screen;
output.append(screen).append("\n");
if (upper.contains("***")) {
client.getPS().SendKeys("[enter]");
Thread.sleep(100);
waitForKeyboardUnlock(client, 2000);
} else {
break;
}
}
// Terminal cleanup
if (client.getPS() != null) {
if (isIspf) {
client.getPS().SendKeys("[enter]");
Thread.sleep(150);
waitForKeyboardUnlock(client, 1500);
int r = client.getPS().getRows();
int c = client.getPS().getCols();
String currentScr = client.getPS().copyString(0, 0, r - 1, c - 1);
if (currentScr.toUpperCase().contains("--RECFM-") || (client.getScreenBuffer() != null && !client.getScreenBuffer().isFormatted())) {
client.getPS().SendKeys("[clear]");
waitForKeyboardUnlock(client, 2000);
}
} else {
client.getPS().SendKeys("[clear]");
waitForKeyboardUnlock(client, 2000);
}
}
return output.toString();
}
@Override
protected void done() {
try {
String outText = get();
List<TSODirectoryEntry> parsed = TSODirectoryParser.parse(outText);
if (!parsed.isEmpty()) {
TSODirectoryEntry updated = parsed.get(0);
if (updated.getRecfm() != null) tso.setRecfm(updated.getRecfm());
if (updated.getLrecl() > 0) tso.setLrecl(updated.getLrecl());
if (updated.getBlksize() > 0) tso.setBlksize(updated.getBlksize());
if (updated.getDsorg() != null) tso.setDsorg(updated.getDsorg());
if (updated.getVolume() != null && (tso.getVolume() == null || tso.getVolume().equals("******") || tso.getVolume().isEmpty())) {
tso.setVolume(updated.getVolume());
}
// Update table row in view
int modelRow = table.convertRowIndexToModel(row);
if (modelRow >= 0 && modelRow < tableModel.getRowCount()) {
tableModel.setValueAt(tso.getVolume() != null ? tso.getVolume() : "", modelRow, 1);
}
statusLabel.setText("Attributes for " + dsn + ": RECFM=" + tso.getRecfm() + ", LRECL=" + tso.getLrecl() + ", BLKSIZE=" + tso.getBlksize() + ", VOL=" + tso.getVolume());
StringBuilder msg = new StringBuilder();
msg.append("Dataset: ").append(dsn).append("\n\n");
msg.append("Volume: ").append(tso.getVolume() != null ? tso.getVolume() : "N/A").append("\n");
msg.append("Record Format (RECFM): ").append(tso.getRecfm() != null ? tso.getRecfm() : "N/A").append("\n");
msg.append("Logical Record Length (LRECL): ").append(tso.getLrecl() > 0 ? String.valueOf(tso.getLrecl()) : "N/A").append("\n");
msg.append("Block Size (BLKSIZE): ").append(tso.getBlksize() > 0 ? String.valueOf(tso.getBlksize()) : "N/A").append("\n");
msg.append("Dataset Organization (DSORG): ").append(tso.getDsorg() != null ? tso.getDsorg() : "N/A").append("\n");
if (tso.getTracksAllocated() > 0) {
msg.append("Allocated Tracks: ").append(tso.getTracksAllocated()).append("\n");
}
if (tso.getExtents() > 0) {
msg.append("Extents: ").append(tso.getExtents()).append("\n");
}
JOptionPane.showMessageDialog(HostDirectoryDialog.this, msg.toString(), "Dataset Attributes (LISTDS)", JOptionPane.INFORMATION_MESSAGE);
} else {
statusLabel.setText("No VTOC attributes found for " + dsn);
}
} catch (Exception ex) {
statusLabel.setText("Inspection failed: " + ex.getMessage());
}
}
};
worker.execute();
}
private static boolean waitForKeyboardUnlock(Telnet3270Client client, long timeoutMs) { private static boolean waitForKeyboardUnlock(Telnet3270Client client, long timeoutMs) {
if (client == null) return false; if (client == null) return false;
long deadline = System.currentTimeMillis() + timeoutMs; long deadline = System.currentTimeMillis() + timeoutMs;
@@ -416,25 +772,29 @@ public class HostDirectoryDialog extends JDialog {
tableModel.setRowCount(0); tableModel.setRowCount(0);
currentEntries.clear(); currentEntries.clear();
String filter = queryField.getText().trim(); String filter = queryField.getText().trim();
String filterUpper = filter.toUpperCase();
FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem(); FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
for (HostDirectoryEntry entry : allEntries) { for (HostDirectoryEntry entry : allEntries) {
if (!filter.isEmpty() && !matchesFilter(entry.getName().toUpperCase(), filterUpper)) { if (!matchesFilter(entry, filter)) {
continue; continue;
} }
currentEntries.add(entry); currentEntries.add(entry);
if (hostType == FTConfig.HostType.CMS && entry instanceof CMSDirectoryEntry) { if (hostType == FTConfig.HostType.CMS && entry instanceof CMSDirectoryEntry) {
CMSDirectoryEntry cms = (CMSDirectoryEntry) entry; CMSDirectoryEntry cms = (CMSDirectoryEntry) entry;
tableModel.addRow(new Object[]{ tableModel.addRow(new Object[]{
cms.getFilename(), cms.getFiletype(), cms.getFilemode(), cms.getFilename() != null ? cms.getFilename() : "",
cms.getRecfm(), cms.getLrecl(), cms.getNumRecords(), cms.getDate() + " " + cms.getTime() cms.getFiletype() != null ? cms.getFiletype() : "",
cms.getFilemode() != null ? cms.getFilemode() : "",
cms.getRecfm() != null ? cms.getRecfm() : "",
cms.getLrecl() > 0 ? String.valueOf(cms.getLrecl()) : "",
cms.getNumRecords() > 0 ? String.valueOf(cms.getNumRecords()) : "",
(cms.getDate() != null ? cms.getDate() : "") + " " + (cms.getTime() != null ? cms.getTime() : "")
}); });
} else if (entry instanceof TSODirectoryEntry) { } else if (entry instanceof TSODirectoryEntry) {
TSODirectoryEntry tso = (TSODirectoryEntry) entry; TSODirectoryEntry tso = (TSODirectoryEntry) entry;
tableModel.addRow(new Object[]{ tableModel.addRow(new Object[]{
tso.getDatasetName(), tso.getVolume(), tso.getRecfm(), tso.getDatasetName() != null ? tso.getDatasetName() : "",
tso.getLrecl(), tso.getBlksize(), tso.getTracksAllocated(), tso.getExtents() tso.getVolume() != null ? tso.getVolume() : ""
}); });
} }
} }
@@ -444,16 +804,99 @@ public class HostDirectoryDialog extends JDialog {
} }
} }
private boolean matchesFilter(String name, String filter) { public static boolean matchesFilter(HostDirectoryEntry entry, String filter) {
String cleanFilter = filter.replace("'", "").trim(); if (filter == null || filter.trim().isEmpty()) {
return true;
}
String cleanFilter = filter.replace("'", "").replace("\"", "").trim();
if (cleanFilter.isEmpty() || cleanFilter.equals("*") || cleanFilter.equals("* * *") || cleanFilter.equals("*.*")) { if (cleanFilter.isEmpty() || cleanFilter.equals("*") || cleanFilter.equals("* * *") || cleanFilter.equals("*.*")) {
return true; return true;
} }
if (cleanFilter.contains("*")) {
String regex = "\\Q" + cleanFilter.replace("*", "\\E.*\\Q") + "\\E"; if (entry instanceof CMSDirectoryEntry) {
return name.matches(regex); return matchesCmsFilter((CMSDirectoryEntry) entry, cleanFilter);
} else if (entry instanceof TSODirectoryEntry) {
return matchesTsoFilter((TSODirectoryEntry) entry, cleanFilter);
} else {
return matchesWildcard(entry.getName(), cleanFilter);
} }
return name.contains(cleanFilter); }
public static boolean matchesCmsFilter(CMSDirectoryEntry cms, String filter) {
String fn = cms.getFilename() != null ? cms.getFilename().trim() : "";
String ft = cms.getFiletype() != null ? cms.getFiletype().trim() : "";
String fm = cms.getFilemode() != null ? cms.getFilemode().trim() : "";
String[] tokens = filter.split("\\s+");
if (tokens.length >= 3) {
String fnPat = tokens[0];
String ftPat = tokens[1];
String fmPat = tokens[2];
return matchesWildcard(fn, fnPat)
&& matchesWildcard(ft, ftPat)
&& matchesCmsFilemode(fm, fmPat);
} else if (tokens.length == 2) {
String fnPat = tokens[0];
String ftPat = tokens[1];
return matchesWildcard(fn, fnPat) && matchesWildcard(ft, ftPat);
} else {
String tok = tokens[0];
if (tok.contains(".")) {
int dot = tok.indexOf('.');
String fnPat = tok.substring(0, dot);
String ftPat = tok.substring(dot + 1);
return matchesWildcard(fn, fnPat) && matchesWildcard(ft, ftPat);
}
if (matchesWildcard(fn, tok) || matchesWildcard(ft, tok) || matchesCmsFilemode(fm, tok)) {
return true;
}
return matchesWildcard(cms.getName(), tok);
}
}
public static boolean matchesCmsFilemode(String actualFm, String fmPattern) {
if (fmPattern == null || fmPattern.isEmpty() || fmPattern.equals("*")) {
return true;
}
if (actualFm == null || actualFm.isEmpty()) {
return false;
}
if (fmPattern.length() == 1 && Character.isLetter(fmPattern.charAt(0))) {
return actualFm.toUpperCase().startsWith(fmPattern.toUpperCase());
}
return matchesWildcard(actualFm, fmPattern);
}
public static boolean matchesTsoFilter(TSODirectoryEntry tso, String filter) {
String dsn = tso.getDatasetName() != null ? tso.getDatasetName().trim() : "";
if (filter.equals("*") || filter.equals("* * *") || filter.equals("*.*")) {
return true;
}
if (filter.contains("*") || filter.contains("?")) {
return matchesWildcard(dsn, filter);
}
return dsn.toUpperCase().contains(filter.toUpperCase());
}
public static boolean matchesWildcard(String text, String pattern) {
if (text == null) text = "";
if (pattern == null || pattern.isEmpty() || pattern.equals("*")) return true;
StringBuilder regex = new StringBuilder("^");
for (int i = 0; i < pattern.length(); i++) {
char c = pattern.charAt(i);
if (c == '*') {
regex.append(".*");
} else if (c == '?') {
regex.append(".");
} else if ("\\.[]{}()+^$|".indexOf(c) >= 0) {
regex.append("\\").append(c);
} else {
regex.append(Character.toUpperCase(c));
}
}
regex.append("$");
return text.toUpperCase().matches(regex.toString());
} }
private void onConfirmSelection() { private void onConfirmSelection() {
@@ -466,6 +909,7 @@ public class HostDirectoryDialog extends JDialog {
HostDirectoryEntry entry = currentEntries.get(row); HostDirectoryEntry entry = currentEntries.get(row);
selectedHostFile = entry.getName(); selectedHostFile = entry.getName();
selectedEntry = entry;
confirmed = true; confirmed = true;
dispose(); dispose();
} }
@@ -478,6 +922,10 @@ public class HostDirectoryDialog extends JDialog {
return selectedHostFile; return selectedHostFile;
} }
public HostDirectoryEntry getSelectedEntry() {
return selectedEntry;
}
public List<HostDirectoryEntry> getAllEntries() { public List<HostDirectoryEntry> getAllEntries() {
return allEntries; return allEntries;
} }
@@ -485,4 +933,38 @@ public class HostDirectoryDialog extends JDialog {
public List<HostDirectoryEntry> getCurrentEntries() { public List<HostDirectoryEntry> getCurrentEntries() {
return currentEntries; return currentEntries;
} }
public JTextField getQueryField() {
return queryField;
}
public static String resolveDefaultQuery(FTConfig.HostType hostType, String initialQuery) {
String defaultQuery = initialQuery != null ? initialQuery.trim() : "";
if (hostType == FTConfig.HostType.CMS && (defaultQuery.isEmpty() || defaultQuery.equals("* * *") || defaultQuery.equals("*"))) {
defaultQuery = "* * A";
}
return defaultQuery;
}
public static String formatCMSFilter(String pattern) {
if (pattern == null || pattern.trim().isEmpty()) {
return "* * A";
}
String clean = pattern.replace("'", "").replace("\"", "").trim();
if (clean.equals("* * *") || clean.equals("*")) {
return "* * A";
} else if (clean.contains(".")) {
int dot = clean.indexOf('.');
return clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " A";
} else {
String[] parts = clean.split("\\s+");
if (parts.length == 1) {
return parts[0] + " * A";
} else if (parts.length == 2) {
return parts[0] + " " + parts[1] + " A";
} else {
return parts[0] + " " + parts[1] + " " + parts[2];
}
}
}
} }
@@ -0,0 +1,297 @@
package haus.nightmare.j3270;
import haus.nightmare.j3270.ui.ConnectChoiceDialog;
import haus.nightmare.j3270.ui.ThemeManager;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import javax.swing.*;
import java.awt.*;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class ConnectionConflictTest {
private final List<J3270App> createdApps = new ArrayList<>();
@BeforeEach
public void setUp() {
for (J3270App app : new ArrayList<>(J3270App.getOpenWindows())) {
try {
app.dispose();
} catch (Exception ignored) {}
}
}
@AfterEach
public void tearDown() {
for (J3270App app : createdApps) {
try {
app.dispose();
} catch (Exception ignored) {}
}
for (J3270App app : new ArrayList<>(J3270App.getOpenWindows())) {
try {
app.dispose();
} catch (Exception ignored) {}
}
}
private J3270App createApp() {
try {
J3270App app = new J3270App();
createdApps.add(app);
return app;
} catch (HeadlessException e) {
return null;
}
}
@Test
@DisplayName("ConnectChoiceDialog components, styling, and action results")
public void testConnectChoiceDialogComponents() {
try {
ConnectChoiceDialog dialog = new ConnectChoiceDialog(null, "mvs.corp.local:23");
assertEquals("Active Connection", dialog.getTitle());
assertNotNull(dialog.getNewWindowButton());
assertNotNull(dialog.getAbortButton());
assertNotNull(dialog.getCancelButton());
assertEquals("New Window", dialog.getNewWindowButton().getText());
assertEquals("Abort Current Connection", dialog.getAbortButton().getText());
assertEquals("Cancel", dialog.getCancelButton().getText());
assertTrue(dialog.getLine1().getText().contains("mvs.corp.local:23"));
assertTrue(dialog.getLine2().getText().contains("new window"));
assertTrue(dialog.getLine2().getText().contains("abort"));
// Test button click choices
dialog.getNewWindowButton().doClick();
assertEquals(ConnectChoiceDialog.Choice.NEW_WINDOW, dialog.getChoice());
ConnectChoiceDialog dialog2 = new ConnectChoiceDialog(null, "cms.test:992");
dialog2.getAbortButton().doClick();
assertEquals(ConnectChoiceDialog.Choice.ABORT_CURRENT, dialog2.getChoice());
ConnectChoiceDialog dialog3 = new ConnectChoiceDialog(null, "cics.test:23");
dialog3.getCancelButton().doClick();
assertEquals(ConnectChoiceDialog.Choice.CANCEL, dialog3.getChoice());
} catch (HeadlessException ignored) {
// Expected in headless CI environments without X11
}
}
@Test
@DisplayName("When connected, Connect with NEW_WINDOW choice opens in a new window and preserves current connection")
public void testConnectChoiceNewWindow() {
J3270App app = createApp();
if (app == null) return;
ConnectionConfig config1 = new ConnectionConfig("host1.test", 23, TerminalModel.IBM_3278_2);
config1.setSavedProfileName("Host 1");
app.connect(config1, false);
assertTrue(app.getConnectedTarget().contains("host1.test") || app.getConnectedTarget().contains("Host 1"));
// Configure handler to return NEW_WINDOW
boolean[] handlerCalled = new boolean[]{false};
app.setConnectChoiceHandler((owner, target) -> {
handlerCalled[0] = true;
assertTrue(target.contains("host1.test") || target.contains("Host 1"));
return ConnectChoiceDialog.Choice.NEW_WINDOW;
});
int initialWindows = J3270App.getOpenWindows().size();
ConnectionConfig config2 = new ConnectionConfig("host2.test", 23, TerminalModel.IBM_3278_2);
config2.setSavedProfileName("Host 2");
app.connect(config2, true);
// Handler was invoked
assertTrue(handlerCalled[0], "Connect choice handler must be called when already connected");
// A new window was opened
assertEquals(initialWindows + 1, J3270App.getOpenWindows().size(),
"A new window should have been added to openWindows");
// Original app remains on config1
assertEquals("host1.test", app.getClient().getConfig().getHost());
// Find the new window
J3270App newWindow = null;
for (J3270App w : J3270App.getOpenWindows()) {
if (w != app) {
newWindow = w;
createdApps.add(w);
break;
}
}
assertNotNull(newWindow, "New window must exist");
assertNotNull(newWindow.getClient(), "New window should have a client");
assertEquals("host2.test", newWindow.getClient().getConfig().getHost(),
"New window must be connected to host2.test");
}
@Test
@DisplayName("When connected, Connect with ABORT_CURRENT choice aborts existing connection and connects in same window")
public void testConnectChoiceAbortCurrent() {
J3270App app = createApp();
if (app == null) return;
ConnectionConfig config1 = new ConnectionConfig("host1.test", 23, TerminalModel.IBM_3278_2);
app.connect(config1, false);
int initialWindows = J3270App.getOpenWindows().size();
boolean[] handlerCalled = new boolean[]{false};
app.setConnectChoiceHandler((owner, target) -> {
handlerCalled[0] = true;
return ConnectChoiceDialog.Choice.ABORT_CURRENT;
});
ConnectionConfig config2 = new ConnectionConfig("host2.test", 23, TerminalModel.IBM_3278_2);
app.connect(config2, true);
assertTrue(handlerCalled[0], "Connect choice handler must be called");
assertEquals(initialWindows, J3270App.getOpenWindows().size(),
"No new window should be created when choosing ABORT_CURRENT");
assertEquals("host2.test", app.getClient().getConfig().getHost(),
"Same window should now be connected to host2.test");
}
@Test
@DisplayName("When connected, Connect with CANCEL choice keeps existing connection and creates no new window")
public void testConnectChoiceCancel() {
J3270App app = createApp();
if (app == null) return;
ConnectionConfig config1 = new ConnectionConfig("host1.test", 23, TerminalModel.IBM_3278_2);
app.connect(config1, false);
int initialWindows = J3270App.getOpenWindows().size();
boolean[] handlerCalled = new boolean[]{false};
app.setConnectChoiceHandler((owner, target) -> {
handlerCalled[0] = true;
return ConnectChoiceDialog.Choice.CANCEL;
});
ConnectionConfig config2 = new ConnectionConfig("host2.test", 23, TerminalModel.IBM_3278_2);
app.connect(config2, true);
assertTrue(handlerCalled[0], "Connect choice handler must be called");
assertEquals(initialWindows, J3270App.getOpenWindows().size(),
"No new window should be created when choosing CANCEL");
assertEquals("host1.test", app.getClient().getConfig().getHost(),
"Existing window must remain connected to host1.test");
}
@Test
@DisplayName("File menu contains Close Window item with VK_W accelerator")
public void testCloseWindowMenuItem() {
J3270App app = createApp();
if (app == null) return;
JMenuBar mb = app.getJMenuBar();
assertNotNull(mb);
JMenu fileMenu = null;
for (int i = 0; i < mb.getMenuCount(); i++) {
if ("File".equals(mb.getMenu(i).getText())) {
fileMenu = mb.getMenu(i);
break;
}
}
assertNotNull(fileMenu, "File menu must exist");
JMenuItem closeWindowItem = null;
for (int i = 0; i < fileMenu.getItemCount(); i++) {
JMenuItem item = fileMenu.getItem(i);
if (item != null && "Close Window".equals(item.getText())) {
closeWindowItem = item;
break;
}
}
assertNotNull(closeWindowItem, "File menu must contain Close Window item");
KeyStroke ks = closeWindowItem.getAccelerator();
assertNotNull(ks, "Close Window item must have an accelerator");
assertEquals(KeyEvent.VK_W, ks.getKeyCode());
}
@Test
@DisplayName("Closing one window when multiple windows are open leaves remaining window open")
public void testMultiWindowCloseIndependent() {
J3270App app1 = createApp();
if (app1 == null) return;
J3270App app2 = createApp();
if (app2 == null) return;
assertTrue(J3270App.getOpenWindows().contains(app1));
assertTrue(J3270App.getOpenWindows().contains(app2));
assertEquals(2, J3270App.getOpenWindows().size());
// Close app1
app1.closeWindow();
assertFalse(J3270App.getOpenWindows().contains(app1), "app1 should be removed from open windows");
assertTrue(J3270App.getOpenWindows().contains(app2), "app2 should remain open");
assertEquals(1, J3270App.getOpenWindows().size());
}
@Test
@DisplayName("Abort current connection cleanly tears down old client and connects new client in same window")
public void testAbortCurrentConnectionGracefullyDisconnects() throws Exception {
J3270App app = createApp();
if (app == null) return;
ConnectionConfig config1 = new ConnectionConfig("host1.test", 23, TerminalModel.IBM_3278_2);
app.connect(config1, false);
Telnet3270Client oldClient = app.getClient();
assertNotNull(oldClient);
app.setConnectChoiceHandler((owner, target) -> ConnectChoiceDialog.Choice.ABORT_CURRENT);
ConnectionConfig config2 = new ConnectionConfig("host2.test", 23, TerminalModel.IBM_3278_2);
app.connect(config2, true);
// Client on app is immediately replaced with new client
Telnet3270Client newClient = app.getClient();
assertNotNull(newClient);
assertNotSame(oldClient, newClient);
assertEquals("host2.test", newClient.getConfig().getHost());
// Old client should be disconnected
Thread.sleep(100);
assertEquals(haus.nightmare.lib3270j.ConnectionState.NOT_CONNECTED, oldClient.getConnectionState());
assertFalse(oldClient.isConnected());
}
@Test
@DisplayName("Explicit app.disconnect() tears down client and clears UI state")
public void testExplicitDisconnectResetsUI() throws Exception {
J3270App app = createApp();
if (app == null) return;
ConnectionConfig config = new ConnectionConfig("host1.test", 23, TerminalModel.IBM_3278_2);
app.connect(config, false);
Telnet3270Client client = app.getClient();
assertNotNull(client);
app.disconnect();
assertNull(app.getClient());
assertFalse(app.isConnected());
Thread.sleep(100);
assertEquals(haus.nightmare.lib3270j.ConnectionState.NOT_CONNECTED, client.getConnectionState());
assertFalse(client.isConnected());
}
}
@@ -85,7 +85,8 @@ public class LoggingConfigurationTest {
assertEquals(Level.ALL, Logger.getLogger("haus.nightmare.lib3270j").getLevel()); assertEquals(Level.ALL, Logger.getLogger("haus.nightmare.lib3270j").getLevel());
File logFile = new File("j3270.log"); File logFile = new File("j3270.log");
assertTrue(logFile.exists(), "j3270.log should be created when debug is enabled"); File logFile1 = new File("j3270.log.1");
assertTrue(logFile.exists() || logFile1.exists(), "j3270.log should be created when debug is enabled");
// Clean up // Clean up
J3270App.configureLogging(false); J3270App.configureLogging(false);
@@ -0,0 +1,271 @@
package haus.nightmare.j3270.ft;
import haus.nightmare.j3270.J3270App;
import haus.nightmare.j3270.config.Settings;
import haus.nightmare.j3270.storage.HostStorage;
import haus.nightmare.j3270.storage.SavedHost;
import haus.nightmare.j3270.ui.ConnectDialog;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.ft.FTConfig;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import javax.swing.*;
import java.awt.Frame;
import java.awt.HeadlessException;
import java.io.File;
import java.io.PrintWriter;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
public class ConnectionHostTypeConfigTest {
private List<SavedHost> originalSavedHosts;
private String originalDefaultHostType;
@BeforeEach
public void setUp() {
FileTransferDialog.resetSessionState();
originalSavedHosts = HostStorage.getSavedHosts();
originalDefaultHostType = Settings.getDefaultHostType();
HostStorage.clearAll();
}
@AfterEach
public void tearDown() {
FileTransferDialog.resetSessionState();
HostStorage.clearAll();
if (originalSavedHosts != null) {
HostStorage.saveAll(originalSavedHosts);
}
Settings.setDefaultHostType(originalDefaultHostType);
}
@Test
@DisplayName("SavedHost converts to ConnectionConfig preserving hostType and saved connection metadata")
public void testSavedHostToConnectionConfigPreservesHostType() {
SavedHost cmsHost = new SavedHost(
"cms-uuid-1", "VM/CMS System", "vm370.corp.local", 23, 4, 62, 160, "",
false, "CMS", false, true, true, "BOTH", "037",
"NONE", "", 0, "", ""
);
ConnectionConfig cfgCms = cmsHost.toConnectionConfig();
assertTrue(cfgCms.isSavedConnection(), "Config from SavedHost must be marked as saved connection");
assertEquals("cms-uuid-1", cfgCms.getSavedProfileId());
assertEquals("VM/CMS System", cfgCms.getSavedProfileName());
assertEquals("CMS", cfgCms.getHostType());
assertEquals(FTConfig.HostType.CMS, cfgCms.getFtHostType());
SavedHost cicsHost = new SavedHost(
"cics-uuid-2", "CICS Gateway", "cics.corp.local", 23, 2, 24, 80, "LU1",
false, "CICS", false, true, true, "BOTH", "037",
"NONE", "", 0, "", ""
);
ConnectionConfig cfgCics = cicsHost.toConnectionConfig();
assertTrue(cfgCics.isSavedConnection());
assertEquals("CICS", cfgCics.getHostType());
assertEquals(FTConfig.HostType.CICS, cfgCics.getFtHostType());
SavedHost tsoHost = new SavedHost(
"tso-uuid-3", "MVS TSO", "tso.corp.local", 23, 4, 43, 80, "",
false, "TSO", false, true, true, "BOTH", "037",
"NONE", "", 0, "", ""
);
ConnectionConfig cfgTso = tsoHost.toConnectionConfig();
assertTrue(cfgTso.isSavedConnection());
assertEquals("TSO", cfgTso.getHostType());
assertEquals(FTConfig.HostType.TSO, cfgTso.getFtHostType());
}
@Test
@DisplayName("FileTransferDialog preselects CMS when using a saved connection configured as CMS")
public void testFileTransferDialogPrefillsFromSavedConnectionCms() {
ConnectionConfig config = new ConnectionConfig("vm.example.com", 23);
config.setSavedConnection(true);
config.setSavedProfileId("vm-1");
config.setSavedProfileName("VM370");
config.setHostType("CMS");
Telnet3270Client client = new Telnet3270Client(config);
FileTransfer ft = new FileTransfer(client, null);
try {
FileTransferDialog dialog = new FileTransferDialog(null, ft);
assertEquals(FTConfig.HostType.CMS, dialog.getHostTypeCombo().getSelectedItem(),
"FileTransferDialog must preselect CMS when saved connection is configured as CMS");
dialog.dispose();
} catch (HeadlessException ignored) {
}
}
@Test
@DisplayName("FileTransferDialog preselects CICS when using a saved connection configured as CICS")
public void testFileTransferDialogPrefillsFromSavedConnectionCics() {
ConnectionConfig config = new ConnectionConfig("cics.example.com", 23);
config.setSavedConnection(true);
config.setSavedProfileId("cics-1");
config.setSavedProfileName("CICS Region");
config.setHostType("CICS");
Telnet3270Client client = new Telnet3270Client(config);
FileTransfer ft = new FileTransfer(client, null);
try {
FileTransferDialog dialog = new FileTransferDialog(null, ft);
assertEquals(FTConfig.HostType.CICS, dialog.getHostTypeCombo().getSelectedItem(),
"FileTransferDialog must preselect CICS when saved connection is configured as CICS");
dialog.dispose();
} catch (HeadlessException ignored) {
}
}
@Test
@DisplayName("FileTransferDialog overrides stale prior transfer state with configured host type from saved connection")
public void testFileTransferDialogOverridesOldTransferStateWhenSavedConnectionUsed() {
// Simulate a prior transfer performed on a TSO system
FileTransferDialog.TransferSessionState oldState = new FileTransferDialog.TransferSessionState();
oldState.hostType = FTConfig.HostType.TSO;
oldState.localFile = "test.txt";
oldState.hostFile = "USER.DATA";
FileTransferDialog.setLastTransferState(oldState);
// Now connect to a CMS saved connection
ConnectionConfig cmsConfig = new ConnectionConfig("vm.corp.internal", 23);
cmsConfig.setSavedConnection(true);
cmsConfig.setSavedProfileName("VM Prod");
cmsConfig.setHostType("CMS");
Telnet3270Client client = new Telnet3270Client(cmsConfig);
FileTransfer ft = new FileTransfer(client, null);
try {
FileTransferDialog dialog = new FileTransferDialog(null, ft);
assertEquals(FTConfig.HostType.CMS, dialog.getHostTypeCombo().getSelectedItem(),
"FileTransferDialog must use the configured host type (CMS) of the saved connection rather than prior session's TSO");
dialog.dispose();
} catch (HeadlessException ignored) {
}
}
@Test
@DisplayName("ConnectDialog allows host type configuration and saves it to profile")
public void testConnectDialogHostTypeConfigurationAndProfile() {
try {
ConnectDialog dialog = new ConnectDialog((Frame) null);
assertNotNull(dialog.getHostTypeCombo(), "ConnectDialog must provide a Host Type combo box");
assertEquals("TSO", dialog.getSelectedHostType());
// Switch to CMS
dialog.setInitialHostType("CMS");
assertEquals("CMS", dialog.getSelectedHostType());
assertEquals(FTConfig.HostType.CMS, dialog.getSelectedFtHostType());
// Switch to CICS
dialog.setInitialHostType("CICS");
assertEquals("CICS", dialog.getSelectedHostType());
assertEquals(FTConfig.HostType.CICS, dialog.getSelectedFtHostType());
// Loading a SavedHost profile updates host type in ConnectDialog
SavedHost cmsProfile = new SavedHost(
"cms-prof-1", "CMS Dev", "cms.dev.local", 23, 4, 62, 160, "",
false, "CMS", false, true, true, "BOTH", "037",
"NONE", "", 0, "", ""
);
dialog.loadProfile(cmsProfile);
assertEquals("CMS", dialog.getSelectedHostType());
dialog.dispose();
} catch (HeadlessException ignored) {
}
}
@Test
@DisplayName("Settings persists and exports defaultHostType")
public void testSettingsDefaultHostTypePersistenceAndIni() throws Exception {
Settings.setDefaultHostType("CMS");
assertEquals("CMS", Settings.getDefaultHostType());
File tmpIni = File.createTempFile("j3270_test_cfg", ".ini");
tmpIni.deleteOnExit();
try (PrintWriter writer = new PrintWriter(tmpIni)) {
writer.println("[connection]");
writer.println("hostType = CICS");
}
Settings.loadFromIniFile(tmpIni.getAbsolutePath());
assertEquals("CICS", Settings.getDefaultHostType());
// Test export
File exportFile = File.createTempFile("j3270_export", ".ini");
exportFile.deleteOnExit();
Settings.exportToIniFile(exportFile.getAbsolutePath());
String content = new String(java.nio.file.Files.readAllBytes(exportFile.toPath()));
assertTrue(content.contains("hostType = CICS"), "Exported INI must contain hostType = CICS");
}
@Test
@DisplayName("J3270App setConnectionHostType updates connection config and syncs with profile in HostStorage")
public void testSetConnectionHostTypeUpdatesActiveConnectionAndStorage() {
SavedHost saved = new SavedHost(
"saved-prof-1", "Prod CMS", "vm.prod", 23, 4, 62, 160, "",
false, "TSO", false, true, true, "BOTH", "037",
"NONE", "", 0, "", ""
);
HostStorage.saveHost(saved);
ConnectionConfig config = saved.toConnectionConfig();
assertEquals("TSO", config.getHostType());
try {
J3270App app = new J3270App();
try {
// Verify Connection menu exists in JMenuBar
JMenuBar mb = app.getJMenuBar();
assertNotNull(mb);
boolean hasConnectionMenu = false;
for (int i = 0; i < mb.getMenuCount(); i++) {
JMenu m = mb.getMenu(i);
if (m != null && "Connection".equals(m.getText())) {
hasConnectionMenu = true;
break;
}
}
assertTrue(hasConnectionMenu, "JMenuBar must include a Connection menu");
// Simulate connecting with this config
app.connect(config);
// Change host type via setConnectionHostType
app.setConnectionHostType(FTConfig.HostType.CMS);
assertEquals(FTConfig.HostType.CMS, app.getClient().getConfig().getFtHostType());
// Profile in storage should also be updated
SavedHost reloaded = HostStorage.findById("saved-prof-1");
assertNotNull(reloaded);
assertEquals("CMS", reloaded.getHostType());
} finally {
app.dispose();
}
} catch (HeadlessException ignored) {
// In headless environments where JFrame cannot be initialized, test model updates directly
Telnet3270Client client = new Telnet3270Client(config);
client.getConfig().setFtHostType(FTConfig.HostType.CMS);
saved.setHostType(FTConfig.HostType.CMS.name());
HostStorage.saveHost(saved);
SavedHost reloaded = HostStorage.findById("saved-prof-1");
assertNotNull(reloaded);
assertEquals("CMS", reloaded.getHostType());
}
}
}
@@ -1,6 +1,9 @@
package haus.nightmare.j3270.ft; package haus.nightmare.j3270.ft;
import haus.nightmare.j3270.ui.HostDirectoryDialog;
import haus.nightmare.lib3270j.ft.FTConfig; import haus.nightmare.lib3270j.ft.FTConfig;
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry;
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -130,14 +133,83 @@ public class FileTransferSessionAndCmsTest {
@Test @Test
public void testHostDirectoryDialogStructureAndFilter() { public void testHostDirectoryDialogStructureAndFilter() {
// Test default query resolution
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.CMS, null));
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.CMS, ""));
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.CMS, " "));
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.CMS, "* * *"));
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.CMS, "*"));
assertEquals("TEST EXEC", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.CMS, "TEST EXEC"));
assertEquals("", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.TSO, null));
assertEquals("", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.TSO, ""));
assertEquals("'MY.DATASET'", haus.nightmare.j3270.ui.HostDirectoryDialog.resolveDefaultQuery(FTConfig.HostType.TSO, "'MY.DATASET'"));
// Test CMS filter formatting
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.formatCMSFilter(null));
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.formatCMSFilter(""));
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.formatCMSFilter("* * *"));
assertEquals("* * A", haus.nightmare.j3270.ui.HostDirectoryDialog.formatCMSFilter("*"));
assertEquals("TEST * A", haus.nightmare.j3270.ui.HostDirectoryDialog.formatCMSFilter("TEST"));
assertEquals("TEST EXEC A", haus.nightmare.j3270.ui.HostDirectoryDialog.formatCMSFilter("TEST EXEC"));
assertEquals("TEST EXEC B", haus.nightmare.j3270.ui.HostDirectoryDialog.formatCMSFilter("TEST EXEC B"));
assertEquals("TEST EXEC A", haus.nightmare.j3270.ui.HostDirectoryDialog.formatCMSFilter("TEST.EXEC"));
try { try {
haus.nightmare.j3270.ui.HostDirectoryDialog dialog = haus.nightmare.j3270.ui.HostDirectoryDialog dialog =
new haus.nightmare.j3270.ui.HostDirectoryDialog(null, null, FTConfig.HostType.CMS, "TEST EXEC"); new haus.nightmare.j3270.ui.HostDirectoryDialog(null, null, FTConfig.HostType.CMS, "");
assertNotNull(dialog); assertNotNull(dialog);
assertFalse(dialog.isConfirmed()); assertFalse(dialog.isConfirmed());
assertNull(dialog.getSelectedHostFile()); assertNull(dialog.getSelectedHostFile());
assertEquals("* * A", dialog.getQueryField().getText());
dialog.dispose(); dialog.dispose();
haus.nightmare.j3270.ui.HostDirectoryDialog dialog2 =
new haus.nightmare.j3270.ui.HostDirectoryDialog(null, null, FTConfig.HostType.CMS, "* * *");
assertEquals("* * A", dialog2.getQueryField().getText());
dialog2.dispose();
haus.nightmare.j3270.ui.HostDirectoryDialog dialog3 =
new haus.nightmare.j3270.ui.HostDirectoryDialog(null, null, FTConfig.HostType.CMS, "TEST EXEC");
assertEquals("TEST EXEC", dialog3.getQueryField().getText());
dialog3.dispose();
} catch (HeadlessException ignored) { } catch (HeadlessException ignored) {
} }
} }
@Test
public void testMatchesFilterWildcards() {
CMSDirectoryEntry cmsA = new CMSDirectoryEntry("PROFILE", "EXEC", "A1");
CMSDirectoryEntry cmsB = new CMSDirectoryEntry("PROFILE", "EXEC", "B1");
CMSDirectoryEntry cmsSys = new CMSDirectoryEntry("SYSTEM", "CONFIG", "S2");
// * * A matches A disk files (A1, A0, etc.), excludes B and S
assertTrue(HostDirectoryDialog.matchesFilter(cmsA, "* * A"));
assertFalse(HostDirectoryDialog.matchesFilter(cmsB, "* * A"));
assertFalse(HostDirectoryDialog.matchesFilter(cmsSys, "* * A"));
// Blank, *, * * * matches all
assertTrue(HostDirectoryDialog.matchesFilter(cmsA, ""));
assertTrue(HostDirectoryDialog.matchesFilter(cmsA, "*"));
assertTrue(HostDirectoryDialog.matchesFilter(cmsB, "* * *"));
assertTrue(HostDirectoryDialog.matchesFilter(cmsSys, "* * *"));
// Two tokens (fn ft) and dot tokens
assertTrue(HostDirectoryDialog.matchesFilter(cmsA, "* EXEC"));
assertTrue(HostDirectoryDialog.matchesFilter(cmsA, "*.EXEC"));
assertTrue(HostDirectoryDialog.matchesFilter(cmsA, "PROFILE *"));
assertTrue(HostDirectoryDialog.matchesFilter(cmsA, "PROF*.*"));
assertFalse(HostDirectoryDialog.matchesFilter(cmsSys, "* EXEC"));
// TSO Wildcards
TSODirectoryEntry tso = new TSODirectoryEntry("TSOUSER.TEST.DATA");
assertTrue(HostDirectoryDialog.matchesFilter(tso, ""));
assertTrue(HostDirectoryDialog.matchesFilter(tso, "*"));
assertTrue(HostDirectoryDialog.matchesFilter(tso, "* * *"));
assertTrue(HostDirectoryDialog.matchesFilter(tso, "*.DATA"));
assertTrue(HostDirectoryDialog.matchesFilter(tso, "TEST"));
assertTrue(HostDirectoryDialog.matchesFilter(tso, "TSOUSER.*"));
assertTrue(HostDirectoryDialog.matchesFilter(tso, "'TSOUSER.TEST.*'"));
assertFalse(HostDirectoryDialog.matchesFilter(tso, "*.CNTL"));
}
} }
@@ -69,6 +69,12 @@ public class ConnectionConfig {
private int reconnectMaxRetries = 5; private int reconnectMaxRetries = 5;
private int tcpUserTimeoutMs = 0; private int tcpUserTimeoutMs = 0;
// Host operating system type (TSO, CMS, CICS)
private String hostType = "TSO";
private boolean savedConnection = false;
private String savedProfileId = null;
private String savedProfileName = null;
public ConnectionConfig() {} public ConnectionConfig() {}
public ConnectionConfig(String host, int port) { public ConnectionConfig(String host, int port) {
@@ -312,6 +318,51 @@ public class ConnectionConfig {
public int getTcpUserTimeoutMs() { return tcpUserTimeoutMs; } public int getTcpUserTimeoutMs() { return tcpUserTimeoutMs; }
public void setTcpUserTimeoutMs(int timeoutMs) { this.tcpUserTimeoutMs = Math.max(0, timeoutMs); } public void setTcpUserTimeoutMs(int timeoutMs) { this.tcpUserTimeoutMs = Math.max(0, timeoutMs); }
public String getHostType() {
return hostType != null ? hostType : "TSO";
}
public void setHostType(String hostType) {
this.hostType = (hostType != null && !hostType.trim().isEmpty()) ? hostType.trim().toUpperCase() : "TSO";
}
public haus.nightmare.lib3270j.ft.FTConfig.HostType getFtHostType() {
if (hostType != null) {
try {
return haus.nightmare.lib3270j.ft.FTConfig.HostType.valueOf(hostType.trim().toUpperCase());
} catch (Exception ignored) {}
}
return haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO;
}
public void setFtHostType(haus.nightmare.lib3270j.ft.FTConfig.HostType ftHostType) {
this.hostType = (ftHostType != null) ? ftHostType.name() : "TSO";
}
public boolean isSavedConnection() {
return savedConnection;
}
public void setSavedConnection(boolean savedConnection) {
this.savedConnection = savedConnection;
}
public String getSavedProfileId() {
return savedProfileId;
}
public void setSavedProfileId(String savedProfileId) {
this.savedProfileId = savedProfileId;
}
public String getSavedProfileName() {
return savedProfileName;
}
public void setSavedProfileName(String savedProfileName) {
this.savedProfileName = savedProfileName;
}
/** /**
* Parse a host connection string which may include prefixes for TLS (e.g. "L:host:port", "ssl:host:port", "y:host:port"), * Parse a host connection string which may include prefixes for TLS (e.g. "L:host:port", "ssl:host:port", "y:host:port"),
* plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), proxy flags (e.g. "--proxy=http://proxy:8080 host:23"), * plain TN3270 (e.g. "P:host:port", "plain:host:port", "non-e:host:port"), proxy flags (e.g. "--proxy=http://proxy:8080 host:23"),
@@ -11,6 +11,7 @@ import haus.nightmare.lib3270j.telnet.TelnetFSM;
import java.io.IOException; import java.io.IOException;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.logging.Level;
/** /**
* Main API entry point for lib3270j. * Main API entry point for lib3270j.
@@ -195,6 +196,13 @@ public class Telnet3270Client {
public void disconnect() { public void disconnect() {
cancelAutoReconnect(); cancelAutoReconnect();
if (connection != null) { if (connection != null) {
try {
if (fsm != null) {
fsm.sendTelnetDisconnect();
}
} catch (Exception e) {
log.log(Level.FINE, "Error during Telnet disconnect negotiation", e);
}
connection.disconnect(); connection.disconnect();
connection = null; connection = null;
} }
@@ -542,7 +542,12 @@ public class FTConfig {
StringBuilder cmd = new StringBuilder(); StringBuilder cmd = new StringBuilder();
// Host program selection: IND$FILE or APVUFILE // Host program selection: IND$FILE or APVUFILE
cmd.append(getProgramName()).append(" "); // For TSO, prefix with "TSO " so it can be called from any ISPF command line or native prompt
String prog = getProgramName();
if (hostType == HostType.TSO && !prog.toUpperCase().startsWith("TSO ")) {
cmd.append("TSO ");
}
cmd.append(prog).append(" ");
cmd.append(isReceive() ? "GET " : "PUT "); cmd.append(isReceive() ? "GET " : "PUT ");
cmd.append(hostFilename != null ? hostFilename.trim() : ""); cmd.append(hostFilename != null ? hostFilename.trim() : "");
@@ -26,7 +26,16 @@ public class CMSDirectoryParser {
String upper = trimmed.toUpperCase(); String upper = trimmed.toUpperCase();
if (upper.startsWith("FILENAME") || upper.startsWith("DIRECTORY") || upper.startsWith("FILELIST")) continue; if (upper.startsWith("FILENAME") || upper.startsWith("DIRECTORY") || upper.startsWith("FILELIST")) continue;
if (upper.startsWith("READY;") || upper.equals("READY") || upper.startsWith("MORE...") || upper.startsWith("HOLDING")) continue; if (upper.startsWith("READY;") || upper.startsWith("READY(") || upper.equals("READY")
|| upper.startsWith("MORE...") || upper.startsWith("HOLDING")
|| upper.startsWith("RUNNING") || upper.equals("RUNNING")
|| upper.startsWith("DMSLST") || upper.startsWith("DMS")) continue;
if (upper.endsWith("MORE...") || upper.endsWith("HOLDING") || upper.endsWith("RUNNING")) {
int idx = Math.max(upper.lastIndexOf("MORE..."), Math.max(upper.lastIndexOf("HOLDING"), upper.lastIndexOf("RUNNING")));
trimmed = trimmed.substring(0, idx).trim();
if (trimmed.isEmpty()) continue;
}
CMSDirectoryEntry entry = parseCmsLine(trimmed); CMSDirectoryEntry entry = parseCmsLine(trimmed);
if (entry != null) { if (entry != null) {
@@ -11,12 +11,16 @@ import java.util.regex.Pattern;
public class TSODirectoryParser { public class TSODirectoryParser {
private static final Pattern DSN_PATTERN = Pattern.compile("([A-Z0-9@#$]+(?:\\.[A-Z0-9@#$]+)+)"); private static final Pattern DSN_PATTERN = Pattern.compile("([A-Z0-9@#$]+(?:\\.[A-Z0-9@#$]+)+)");
private static final Pattern NONVSAM_PATTERN = Pattern.compile("NONVSAM\\s+-+\\s+([A-Z0-9@#$]+(?:\\.[A-Z0-9@#$]+)+)", Pattern.CASE_INSENSITIVE); private static final Pattern NONVSAM_PATTERN = Pattern.compile("(?:NONVSAM|CLUSTER|GDG|ALIAS|AIX|PATH)\\s*[-:=]+\\s*([A-Z0-9@#$]+(?:\\.[A-Z0-9@#$]+)*)", Pattern.CASE_INSENSITIVE);
private static final Pattern VOLSER_PATTERN = Pattern.compile("VOLSER-+([A-Z0-9]+)", Pattern.CASE_INSENSITIVE); private static final Pattern VOLSER_PATTERN = Pattern.compile("VOLSER\\s*[-:=]+\\s*([A-Z0-9*@#$&]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern RECFM_PATTERN = Pattern.compile("RECFM-+([A-Z]+)", Pattern.CASE_INSENSITIVE); private static final Pattern RECFM_PATTERN = Pattern.compile("RECFM\\s*[-:=]+\\s*([A-Z0-9,]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern LRECL_PATTERN = Pattern.compile("LRECL-+([0-9]+)", Pattern.CASE_INSENSITIVE); private static final Pattern LRECL_PATTERN = Pattern.compile("(?:AVG|MAX)?LRECL\\s*[-:=]+\\s*([0-9]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern BLKSIZE_PATTERN = Pattern.compile("BLKSIZE-+([0-9]+)", Pattern.CASE_INSENSITIVE); private static final Pattern BLKSIZE_PATTERN = Pattern.compile("(?:CISIZE|BLKSIZE)\\s*[-:=]+\\s*([0-9]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern DSORG_PATTERN = Pattern.compile("DSORG-+([A-Z]+)", Pattern.CASE_INSENSITIVE); private static final Pattern DSORG_PATTERN = Pattern.compile("DSORG\\s*[-:=]+\\s*([A-Z0-9\\-]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern EXTENTS_PATTERN = Pattern.compile("(?:VOL-)?EXTENTS\\s*[-:=]+\\s*([0-9]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern SPACE_PRI_PATTERN = Pattern.compile("(?:SPACE-PRI|ALLOC-PRM|ALLOC_PRM)\\s*[-:=]+\\s*([0-9]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern SPACE_TYPE_PATTERN = Pattern.compile("SPACE-TYPE\\s*[-:=]+\\s*([A-Z]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern TRACKS_PATTERN = Pattern.compile("(?:TRACKS|TRKS)\\s*[-:=]+\\s*([0-9]+)", Pattern.CASE_INSENSITIVE);
/** /**
* Parse raw text containing TSO dataset listings into a structured list of TSODirectoryEntry. * Parse raw text containing TSO dataset listings into a structured list of TSODirectoryEntry.
@@ -30,11 +34,18 @@ public class TSODirectoryParser {
String[] lines = splitIntoLines(text); String[] lines = splitIntoLines(text);
// Check if text is LISTCAT format // Check if text is LISTCAT format
if (text.toUpperCase().contains("NONVSAM") || text.toUpperCase().contains("IN-CAT")) { if (text.toUpperCase().contains("NONVSAM") || text.toUpperCase().contains("IN-CAT") || text.toUpperCase().contains("IDCAMS")
|| text.toUpperCase().contains("CLUSTER") || text.toUpperCase().contains("GDG")) {
parseListcat(lines, entries); parseListcat(lines, entries);
if (!entries.isEmpty()) return entries; if (!entries.isEmpty()) return entries;
} }
// Check if text is LISTDS format
if (text.toUpperCase().contains("--RECFM-") || text.toUpperCase().contains("--VOLUMES--")) {
parseListds(lines, entries);
if (!entries.isEmpty()) return entries;
}
// Check for ISPF DSLIST or general tabular format // Check for ISPF DSLIST or general tabular format
for (String line : lines) { for (String line : lines) {
String trimmed = line.trim(); String trimmed = line.trim();
@@ -72,36 +83,176 @@ public class TSODirectoryParser {
private static void parseListcat(String[] lines, List<TSODirectoryEntry> entries) { private static void parseListcat(String[] lines, List<TSODirectoryEntry> entries) {
TSODirectoryEntry current = null; TSODirectoryEntry current = null;
String currentSpaceType = null;
for (String line : lines) { for (String line : lines) {
String upper = line.toUpperCase(); String upper = line.toUpperCase();
Matcher nonvsamMat = NONVSAM_PATTERN.matcher(upper); Matcher nonvsamMat = NONVSAM_PATTERN.matcher(upper);
if (nonvsamMat.find()) { if (nonvsamMat.find()) {
if (current != null && current.getName() != null) { if (current != null && current.getName() != null) {
if (current.getTracksAllocated() > 0 && current.getExtents() == 0) {
current.setExtents(1);
}
entries.add(current); entries.add(current);
} }
current = new TSODirectoryEntry(nonvsamMat.group(1)); current = new TSODirectoryEntry(nonvsamMat.group(1));
if (upper.contains("CLUSTER") || upper.contains("AIX") || upper.contains("PATH")) {
current.setDsorg("VSAM");
}
currentSpaceType = null;
continue; continue;
} }
if (current != null) { if (current != null) {
Matcher volMat = VOLSER_PATTERN.matcher(upper); Matcher volMat = VOLSER_PATTERN.matcher(upper);
if (volMat.find()) current.setVolume(volMat.group(1)); if (volMat.find() && current.getVolume() == null) current.setVolume(volMat.group(1));
Matcher recMat = RECFM_PATTERN.matcher(upper); Matcher recMat = RECFM_PATTERN.matcher(upper);
if (recMat.find()) current.setRecfm(recMat.group(1)); if (recMat.find()) {
String raw = recMat.group(1).toUpperCase();
if (raw.contains("VAR") && raw.contains("BLOCK")) current.setRecfm("VB");
else if (raw.contains("FIX") && raw.contains("BLOCK")) current.setRecfm("FB");
else if (raw.contains("VAR")) current.setRecfm("V");
else if (raw.contains("FIX")) current.setRecfm("F");
else if (raw.contains("UNDEF")) current.setRecfm("U");
else current.setRecfm(raw);
}
Matcher lreclMat = LRECL_PATTERN.matcher(upper); Matcher lreclMat = LRECL_PATTERN.matcher(upper);
if (lreclMat.find()) { if (lreclMat.find() && current.getLrecl() == 0) {
try { current.setLrecl(Integer.parseInt(lreclMat.group(1))); } catch (NumberFormatException ignored) {} try { current.setLrecl(Integer.parseInt(lreclMat.group(1))); } catch (NumberFormatException ignored) {}
} }
Matcher blkMat = BLKSIZE_PATTERN.matcher(upper); Matcher blkMat = BLKSIZE_PATTERN.matcher(upper);
if (blkMat.find()) { if (blkMat.find() && current.getBlksize() == 0) {
try { current.setBlksize(Integer.parseInt(blkMat.group(1))); } catch (NumberFormatException ignored) {} try { current.setBlksize(Integer.parseInt(blkMat.group(1))); } catch (NumberFormatException ignored) {}
} }
Matcher dsorgMat = DSORG_PATTERN.matcher(upper); Matcher dsorgMat = DSORG_PATTERN.matcher(upper);
if (dsorgMat.find()) current.setDsorg(dsorgMat.group(1)); if (dsorgMat.find() && current.getDsorg() == null) current.setDsorg(dsorgMat.group(1));
Matcher extMat = EXTENTS_PATTERN.matcher(upper);
if (extMat.find() && current.getExtents() == 0) {
try { current.setExtents(Integer.parseInt(extMat.group(1))); } catch (NumberFormatException ignored) {}
}
Matcher typeMat = SPACE_TYPE_PATTERN.matcher(upper);
if (typeMat.find()) {
currentSpaceType = typeMat.group(1);
}
Matcher trkMat = TRACKS_PATTERN.matcher(upper);
if (trkMat.find() && current.getTracksAllocated() == 0) {
try { current.setTracksAllocated(Integer.parseInt(trkMat.group(1))); } catch (NumberFormatException ignored) {}
} else {
Matcher priMat = SPACE_PRI_PATTERN.matcher(upper);
if (priMat.find() && current.getTracksAllocated() == 0) {
try {
int pri = Integer.parseInt(priMat.group(1));
if (currentSpaceType != null && currentSpaceType.startsWith("CYL")) {
pri = pri * 15;
}
current.setTracksAllocated(pri);
} catch (NumberFormatException ignored) {}
}
}
}
}
if (current != null && current.getName() != null) {
if (current.getTracksAllocated() > 0 && current.getExtents() == 0) {
current.setExtents(1);
}
entries.add(current);
}
}
private static void parseListds(String[] lines, List<TSODirectoryEntry> entries) {
TSODirectoryEntry current = null;
boolean inRecfm = false;
boolean inVol = false;
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.isEmpty()) continue;
String upper = trimmed.toUpperCase();
if (upper.startsWith("--RECFM-")) {
inRecfm = true;
inVol = false;
continue;
}
if (upper.startsWith("--VOLUMES--")) {
inVol = true;
inRecfm = false;
continue;
}
if (upper.startsWith("--ALLOCATION") || upper.startsWith("--TRACKS-")) {
inRecfm = false;
inVol = false;
continue;
}
if (inRecfm && current != null) {
String[] toks = trimmed.split("\\s+");
if (toks.length >= 1 && toks[0].matches("^[A-Z]+$")) current.setRecfm(toks[0]);
if (toks.length >= 2 && toks[1].matches("^\\d+$")) {
try { current.setLrecl(Integer.parseInt(toks[1])); } catch (NumberFormatException ignored) {}
}
if (toks.length >= 3 && toks[2].matches("^\\d+$")) {
try { current.setBlksize(Integer.parseInt(toks[2])); } catch (NumberFormatException ignored) {}
}
if (toks.length >= 4 && toks[3].matches("^[A-Z]+$")) current.setDsorg(toks[3]);
inRecfm = false;
continue;
}
if (inVol && current != null) {
String[] toks = trimmed.split("\\s+");
if (toks.length >= 1 && toks[0].matches("^[A-Z0-9*@#$&]{1,6}$")) {
current.setVolume(toks[0]);
}
inVol = false;
continue;
}
if (current != null) {
Matcher trkMat = TRACKS_PATTERN.matcher(upper);
if (trkMat.find() && current.getTracksAllocated() == 0) {
try { current.setTracksAllocated(Integer.parseInt(trkMat.group(1))); } catch (NumberFormatException ignored) {}
} else {
Matcher priMat = SPACE_PRI_PATTERN.matcher(upper);
if (priMat.find() && current.getTracksAllocated() == 0) {
try { current.setTracksAllocated(Integer.parseInt(priMat.group(1))); } catch (NumberFormatException ignored) {}
}
}
Matcher extMat = EXTENTS_PATTERN.matcher(upper);
if (extMat.find() && current.getExtents() == 0) {
try { current.setExtents(Integer.parseInt(extMat.group(1))); } catch (NumberFormatException ignored) {}
}
// Check for tabular numbers row under --TRACKS-ALLOCATED-EXTENTS-
String[] numToks = trimmed.split("\\s+");
if (numToks.length >= 2 && numToks[0].matches("^\\d+$") && numToks[1].matches("^\\d+$")) {
if (current.getTracksAllocated() == 0) {
try { current.setTracksAllocated(Integer.parseInt(numToks[0])); } catch (NumberFormatException ignored) {}
}
if (current.getExtents() == 0) {
try { current.setExtents(Integer.parseInt(numToks[1])); } catch (NumberFormatException ignored) {}
}
}
}
String[] toks = trimmed.split("\\s+");
if (toks.length > 0) {
String cleanTok = toks[0].replace("'", "").replace("\"", "");
if (DSN_PATTERN.matcher(cleanTok.toUpperCase()).matches()) {
if (current != null && current.getName() != null) {
entries.add(current);
}
current = new TSODirectoryEntry(cleanTok.toUpperCase());
inRecfm = false;
inVol = false;
}
} }
} }
if (current != null && current.getName() != null) { if (current != null && current.getName() != null) {
@@ -127,6 +278,8 @@ public class TSODirectoryParser {
TSODirectoryEntry entry = new TSODirectoryEntry(tokens[dsnIdx].toUpperCase()); TSODirectoryEntry entry = new TSODirectoryEntry(tokens[dsnIdx].toUpperCase());
boolean seenFormat = false; boolean seenFormat = false;
int numBeforeFormat = 0;
int numAfterFormat = 0;
// Parse remaining tokens // Parse remaining tokens
for (int i = 0; i < tokens.length; i++) { for (int i = 0; i < tokens.length; i++) {
@@ -136,7 +289,7 @@ public class TSODirectoryParser {
if (tok.matches("^(PS|PO|PO-E|VSAM|DA|IS)$")) { if (tok.matches("^(PS|PO|PO-E|VSAM|DA|IS)$")) {
entry.setDsorg(tok); entry.setDsorg(tok);
seenFormat = true; seenFormat = true;
} else if (tok.matches("^(F|FB|V|VB|U|VBS|FBS)$")) { } else if (tok.matches("^(F|FB|V|VB|U|VBS|FBS|FBA|VBA|FA|VA|FBM|VBM)$")) {
entry.setRecfm(tok); entry.setRecfm(tok);
seenFormat = true; seenFormat = true;
} else if (tok.matches("^(3390|3380|TAPE|VIO)$")) { } else if (tok.matches("^(3390|3380|TAPE|VIO)$")) {
@@ -148,23 +301,29 @@ public class TSODirectoryParser {
} else { } else {
entry.setReferencedDate(tok); entry.setReferencedDate(tok);
} }
} else if (tok.matches("^[A-Z0-9]{6}$") && entry.getVolume() == null && !tok.matches("^\\d+$")) { } else if (tok.matches("^[A-Z0-9*@#$&]{1,6}$") && entry.getVolume() == null && !tok.matches("^\\d+$")
&& !tok.matches("^(PAGE|TOTAL|TRACKS|TRKS|EXTENTS|VOLUME|DSORG|RECFM|DATASET|NAME|DSN)$")) {
entry.setVolume(tok); entry.setVolume(tok);
} else if (tok.matches("^\\d+$")) { } else if (tok.matches("^\\d+$")) {
int val = Integer.parseInt(tok); int val = Integer.parseInt(tok);
if (!seenFormat) { if (!seenFormat) {
if (entry.getTracksAllocated() == 0) { if (numBeforeFormat == 0) {
entry.setTracksAllocated(val); entry.setTracksAllocated(val);
} else if (entry.getPercentUsed() == 0 && val <= 100) { numBeforeFormat++;
} else if (numBeforeFormat == 1 && val <= 100) {
entry.setPercentUsed(val); entry.setPercentUsed(val);
} else if (entry.getExtents() == 0 && val <= 128) { numBeforeFormat++;
} else if (numBeforeFormat <= 2 && val <= 128) {
entry.setExtents(val); entry.setExtents(val);
numBeforeFormat++;
} }
} else { } else {
if (entry.getLrecl() == 0 && val <= 32760) { if (numAfterFormat == 0 && val <= 32760) {
entry.setLrecl(val); entry.setLrecl(val);
} else if (entry.getBlksize() == 0 && val <= 32760) { numAfterFormat++;
} else if (numAfterFormat == 1 && val <= 32760) {
entry.setBlksize(val); entry.setBlksize(val);
numAfterFormat++;
} else if (entry.getTracksAllocated() == 0) { } else if (entry.getTracksAllocated() == 0) {
entry.setTracksAllocated(val); entry.setTracksAllocated(val);
} }
@@ -172,6 +331,10 @@ public class TSODirectoryParser {
} }
} }
if (entry.getTracksAllocated() > 0 && entry.getExtents() == 0) {
entry.setExtents(1);
}
return entry; return entry;
} }
} }
@@ -556,17 +556,42 @@ public class TelnetConnection {
running = false; running = false;
stopKeepAlive(); stopKeepAlive();
try { try {
if (outputStream != null) {
try {
outputStream.flush();
} catch (IOException ignored) {}
}
if (socket != null && !socket.isClosed()) { if (socket != null && !socket.isClosed()) {
socket.shutdownInput(); try {
socket.shutdownOutput(); if (!socket.isOutputShutdown()) {
socket.shutdownOutput();
}
} catch (IOException ignored) {}
// Allow host a brief drain window to receive EOF / FIN-ACK
if (readerThread != null && readerThread.isAlive() && Thread.currentThread() != readerThread) {
try {
readerThread.join(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
} else if (inputStream != null) {
try {
socket.setSoTimeout(100);
byte[] b = new byte[256];
while (inputStream.read(b) != -1) {}
} catch (Throwable ignored) {}
}
socket.close(); socket.close();
} }
} catch (IOException e) { } catch (IOException e) {
log.log(Level.FINE, "Error during disconnect", e); log.log(Level.FINE, "Error during disconnect", e);
} finally {
socket = null;
inputStream = null;
outputStream = null;
readerThread = null;
} }
socket = null;
inputStream = null;
outputStream = null;
log.info("Disconnected"); log.info("Disconnected");
} }
@@ -1366,6 +1366,71 @@ public class TelnetFSM {
sendRecord(resp); sendRecord(resp);
} }
/**
* Send TN3270E UNBIND request to release LU session cleanly (RFC 2355 Section 9).
* @param reason SNA UNBIND reason code (e.g. UNBIND_NORMAL = 0x01)
*/
public synchronized void sendUnbind(int reason) {
if (!tn3270eNegotiated || !tn3270eBound) {
return;
}
if (connection == null || !connection.isConnected()) {
return;
}
try {
byte[] unbindData = new byte[EH_SIZE + 1];
unbindData[0] = (byte) DT_UNBIND;
unbindData[1] = 0; // Request flags
unbindData[2] = (byte) RSF_NO_RESPONSE; // Response flags
unbindData[3] = (byte) ((eXmitSeq >> 8) & 0xFF);
unbindData[4] = (byte) (eXmitSeq & 0xFF);
unbindData[5] = (byte) (reason & 0xFF);
eXmitSeq = (eXmitSeq + 1) & 0xFFFF;
log.info("Sending TN3270E UNBIND reason=" + unbindReasonName(reason));
sendRecord(unbindData);
tn3270eBound = false;
} catch (Exception e) {
log.log(Level.FINE, "Failed to send TN3270E UNBIND", e);
}
}
/**
* Send graceful Telnet de-negotiation (UNBIND if bound, followed by WONT/DONT for active options).
*/
public synchronized void sendTelnetDisconnect() {
if (connection == null || !connection.isConnected()) {
return;
}
try {
if (tn3270eNegotiated && tn3270eBound) {
sendUnbind(UNBIND_NORMAL);
}
// De-negotiate TN3270E first if active
if (myOpts[TELOPT_TN3270E]) {
sendCommand(WONT, TELOPT_TN3270E);
myOpts[TELOPT_TN3270E] = false;
}
if (hisOpts[TELOPT_TN3270E]) {
sendCommand(DONT, TELOPT_TN3270E);
hisOpts[TELOPT_TN3270E] = false;
}
// De-negotiate all other active options
for (int opt = 0; opt < 256; opt++) {
if (myOpts[opt]) {
sendCommand(WONT, opt);
myOpts[opt] = false;
}
if (hisOpts[opt]) {
sendCommand(DONT, opt);
hisOpts[opt] = false;
}
}
log.fine("Sent Telnet option de-negotiation");
} catch (Exception e) {
log.log(Level.FINE, "Error sending Telnet de-negotiation", e);
}
}
// ========== Check if we should transition to 3270 mode ========== // ========== Check if we should transition to 3270 mode ==========
private void checkIn3270() { private void checkIn3270() {
@@ -1531,6 +1596,9 @@ public class TelnetFSM {
private void changeState(ConnectionState newState) { private void changeState(ConnectionState newState) {
ConnectionState old = this.connectionState; ConnectionState old = this.connectionState;
if (old == newState) {
return;
}
this.connectionState = newState; this.connectionState = newState;
log.info("State: " + old + " -> " + newState); log.info("State: " + old + " -> " + newState);
for (ConnectionListener l : connectionListeners) { for (ConnectionListener l : connectionListeners) {
@@ -610,55 +610,242 @@ public class Xfer3270 implements FileTransferInterface {
if (session != null && session.getPS() != null) { if (session != null && session.getPS() != null) {
try { try {
ECLPS ps = session.getPS(); ECLPS ps = session.getPS();
ps.SendKeys("[clear]"); ScreenBuffer sb = ps.getScreenBuffer();
if (session.getOIA() != null) {
session.getOIA().waitForInput(3000);
}
if (hostType == VM_CMS) {
String filter = (hostQuery != null && !hostQuery.trim().isEmpty()) ? hostQuery.trim() : "* * *";
ps.SendKeys("LISTFILE " + filter + " ( nohead alloc date[enter]");
} else {
String filter = (hostQuery != null && !hostQuery.trim().isEmpty()) ? hostQuery.trim() : "";
if (filter.isEmpty()) {
ps.SendKeys("LISTCAT[enter]");
} else {
if (!filter.startsWith("'")) filter = "'" + filter + "'";
ps.SendKeys("LISTCAT LEVEL(" + filter + ")[enter]");
}
}
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
if (session.getOIA() != null) {
session.getOIA().waitForInput(5000);
}
StringBuilder sbAll = new StringBuilder(); StringBuilder sbAll = new StringBuilder();
for (int page = 0; page < 20; page++) { int maxPages = 500;
if (hostType == VM_CMS) {
ps.SendKeys("[clear]");
if (session.getOIA() != null) {
session.getOIA().waitForInput(3000);
}
String filter;
if (hostQuery == null || hostQuery.trim().isEmpty()) {
filter = "* * A";
} else {
String clean = hostQuery.replace("'", "").replace("\"", "").trim();
if (clean.equals("* * *") || clean.equals("*")) {
filter = "* * A";
} else if (clean.contains(".")) {
int dot = clean.indexOf('.');
filter = clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " A";
} else {
String[] parts = clean.split("\\s+");
if (parts.length == 1) {
filter = parts[0] + " * A";
} else if (parts.length == 2) {
filter = parts[0] + " " + parts[1] + " A";
} else {
filter = parts[0] + " " + parts[1] + " " + parts[2];
}
}
}
ps.SendKeys("LISTFILE " + filter + " ( nohead alloc date[enter]");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
for (int page = 0; page < maxPages; page++) {
long startWait = System.currentTimeMillis();
long timeoutMs = 10000;
String screen = "";
boolean isMore = false;
while (System.currentTimeMillis() - startWait < timeoutMs) {
if (session.getOIA() != null) {
session.getOIA().waitForInput(500);
}
int rows = ps.getRows();
int cols = ps.getCols();
screen = ps.copyString(0, 0, rows - 1, cols - 1);
String upper = screen.toUpperCase();
if (upper.contains("MORE...") || upper.contains("HOLDING")) {
isMore = true;
break;
}
if (upper.contains("READY;") || upper.contains("READY(")) {
break;
}
try { Thread.sleep(150); } catch (InterruptedException ignored) {}
}
sbAll.append(screen).append("\n");
if (isMore) {
ps.SendKeys("[clear]");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
if (session.getOIA() != null) {
session.getOIA().waitForInput(3000);
}
} else {
break;
}
}
// Automatically clear the listing from the terminal presentation space
ps.SendKeys("[clear]");
if (session.getOIA() != null) {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
session.getOIA().waitForInput(3000);
}
} else {
// TSO
int rows = ps.getRows(); int rows = ps.getRows();
int cols = ps.getCols(); int cols = ps.getCols();
String screen = ps.copyString(0, 0, rows - 1, cols - 1); String initialScreen = ps.copyString(0, 0, rows - 1, cols - 1);
sbAll.append(screen).append("\n"); boolean isFormatted = sb != null && sb.isFormatted();
boolean hasCmdArrow = initialScreen.contains("===>");
boolean isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF");
boolean hasMore = false; String filter = (hostQuery != null && !hostQuery.trim().isEmpty()) ? hostQuery.trim() : "";
if (hostType == VM_CMS) { String tsoCmd = "TSO LISTCAT ALL";
if (screen.contains("MORE...") || screen.contains("HOLDING")) { if (!filter.isEmpty()) {
hasMore = true; if (filter.startsWith("'") && filter.endsWith("'") && filter.length() > 2) {
ps.SendKeys("[enter]"); filter = filter.substring(1, filter.length() - 1);
} }
} else { int paren = filter.indexOf('(');
if (screen.contains("***")) { if (paren > 0) filter = filter.substring(0, paren);
hasMore = true; if (filter.equals("*") || filter.equals("* * *") || filter.equals("*.*")) {
ps.SendKeys("[enter]"); tsoCmd = "TSO LISTCAT ALL";
} else {
String level = filter;
if (level.endsWith(".*")) {
level = level.substring(0, level.length() - 2);
} else if (level.endsWith("*")) {
level = level.substring(0, level.length() - 1);
}
if (level.isEmpty()) {
tsoCmd = "TSO LISTCAT ALL";
} else {
tsoCmd = "TSO LISTCAT LEVEL(" + level + ") ALL";
}
} }
} }
if (hasMore) { if (isIspf) {
try { Thread.sleep(100); } catch (InterruptedException ignored) {} int arrowPos = initialScreen.indexOf("===>");
if (arrowPos >= 0 && sb != null) {
int inputAddr = sb.findNextUnprotected(arrowPos);
if (inputAddr > 0) {
sb.setCursorAddress(inputAddr);
sb.updateDisplaySnapshot();
} else {
ps.SendKeys("[home]");
}
} else {
ps.SendKeys("[home]");
}
ps.SendKeys("[erase_eof]");
ps.SendKeys(tsoCmd + "[enter]");
} else {
ps.SendKeys("[clear]");
if (session.getOIA() != null) { if (session.getOIA() != null) {
session.getOIA().waitForInput(3000); session.getOIA().waitForInput(3000);
} }
ps.SendKeys(tsoCmd + "[enter]");
}
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
// Wait for output to start appearing
long startCmd = System.currentTimeMillis();
while (System.currentTimeMillis() - startCmd < 4000) {
if (session.getOIA() != null) {
session.getOIA().waitForInput(200);
}
int r = ps.getRows();
int c = ps.getCols();
String s = ps.copyString(0, 0, r - 1, c - 1);
String u = s.toUpperCase();
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(" --- ")) {
break;
}
try { Thread.sleep(80); } catch (InterruptedException ignored) {}
}
String prevScreen = "";
for (int page = 0; page < maxPages; page++) {
if (session.getOIA() != null) {
session.getOIA().waitForInput(300);
}
int r = ps.getRows();
int c = ps.getCols();
String screen = ps.copyString(0, 0, r - 1, c - 1);
String upper = screen.toUpperCase();
if (screen.equals(prevScreen) || screen.trim().isEmpty()) {
break;
}
prevScreen = screen;
sbAll.append(screen).append("\n");
if (upper.contains("***")) {
ps.SendKeys("[enter]");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
if (session.getOIA() != null) {
session.getOIA().waitForInput(2000);
}
} else if (upper.contains("NONVSAM") || upper.contains("CLUSTER") || upper.contains("GDG")
|| upper.contains("ALIAS") || upper.contains("IDCAMS") || screen.contains(" --- ")) {
ps.SendKeys("[enter]");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
if (session.getOIA() != null) {
session.getOIA().waitForInput(1000);
}
int r2 = ps.getRows();
int c2 = ps.getCols();
String s2 = ps.copyString(0, 0, r2 - 1, c2 - 1);
if (s2.toUpperCase().contains("***")) {
prevScreen = s2;
sbAll.append(s2).append("\n");
ps.SendKeys("[enter]");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
if (session.getOIA() != null) {
session.getOIA().waitForInput(2000);
}
continue;
} else {
if (!s2.equals(screen) && !s2.trim().isEmpty()) {
sbAll.append(s2).append("\n");
}
break;
}
} else {
break;
}
}
// Terminal clean-up
if (isIspf) {
ps.SendKeys("[enter]");
try { Thread.sleep(150); } catch (InterruptedException ignored) {}
if (session.getOIA() != null) {
session.getOIA().waitForInput(1500);
}
int r = ps.getRows();
int c = ps.getCols();
String currentScr = ps.copyString(0, 0, r - 1, c - 1);
if (currentScr.toUpperCase().contains("NONVSAM") || currentScr.toUpperCase().contains("IDCAMS")
|| currentScr.contains(" --- ") || (sb != null && !sb.isFormatted())) {
ps.SendKeys("[clear]");
if (session.getOIA() != null) {
session.getOIA().waitForInput(2000);
}
}
} else { } else {
break; ps.SendKeys("[clear]");
if (session.getOIA() != null) {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
session.getOIA().waitForInput(2000);
}
} }
} }
dataToParse = sbAll.toString(); dataToParse = sbAll.toString();
} catch (Exception ignored) { } catch (Exception ignored) {
// Fall back to hostQuery // Fall back to hostQuery
@@ -14,7 +14,7 @@ public class FTConfigTest {
config.setTransferMode(FTConfig.TransferMode.ASCII); config.setTransferMode(FTConfig.TransferMode.ASCII);
config.setCrAction(FTConfig.CrAction.REMOVE); config.setCrAction(FTConfig.CrAction.REMOVE);
assertEquals("IND$FILE GET MY.DATASET ASCII CRLF", config.buildCommand()); assertEquals("TSO IND$FILE GET MY.DATASET ASCII CRLF", config.buildCommand());
} }
@Test @Test
@@ -25,7 +25,7 @@ public class FTConfigTest {
config.setHostFilename("'USER.BIN.DATA'"); config.setHostFilename("'USER.BIN.DATA'");
config.setTransferMode(FTConfig.TransferMode.BINARY); config.setTransferMode(FTConfig.TransferMode.BINARY);
assertEquals("IND$FILE GET 'USER.BIN.DATA'", config.buildCommand()); assertEquals("TSO IND$FILE GET 'USER.BIN.DATA'", config.buildCommand());
} }
@Test @Test
@@ -40,7 +40,7 @@ public class FTConfigTest {
config.setLrecl("80"); config.setLrecl("80");
config.setBlksize("3120"); config.setBlksize("3120");
assertEquals("IND$FILE PUT MY.DATASET ASCII CRLF RECFM(F) LRECL(80) BLKSIZE(3120)", config.buildCommand()); assertEquals("TSO IND$FILE PUT MY.DATASET ASCII CRLF RECFM(F) LRECL(80) BLKSIZE(3120)", config.buildCommand());
} }
@Test @Test
@@ -143,7 +143,7 @@ public class FTConfigTest {
config.setUnits(FTConfig.AllocationUnit.TRACKS); config.setUnits(FTConfig.AllocationUnit.TRACKS);
String cmd = config.buildCommand(); String cmd = config.buildCommand();
assertEquals("IND$FILE PUT 'USER.PDS.DATA' ASCII CRLF RECFM(F) LRECL(80) BLKSIZE(3120) SPACE(15,5,10) TRACKS", cmd); assertEquals("TSO IND$FILE PUT 'USER.PDS.DATA' ASCII CRLF RECFM(F) LRECL(80) BLKSIZE(3120) SPACE(15,5,10) TRACKS", cmd);
} }
@Test @Test
@@ -157,7 +157,7 @@ public class FTConfigTest {
assertEquals(10, config.getSecondarySpace()); assertEquals(10, config.getSecondarySpace());
assertEquals(15, config.getDirectoryBlocks()); assertEquals(15, config.getDirectoryBlocks());
assertEquals(FTConfig.AllocationUnit.TRACKS, config.getUnits()); assertEquals(FTConfig.AllocationUnit.TRACKS, config.getUnits());
assertEquals("IND$FILE PUT SYS1.PARMLIB ASCII CRLF SPACE(20,10,15) TRACKS", config.buildCommand()); assertEquals("TSO IND$FILE PUT SYS1.PARMLIB ASCII CRLF SPACE(20,10,15) TRACKS", config.buildCommand());
} }
@Test @Test
@@ -171,7 +171,7 @@ public class FTConfigTest {
assertEquals(5, config.getSecondarySpace()); assertEquals(5, config.getSecondarySpace());
assertEquals(20, config.getDirectoryBlocks()); assertEquals(20, config.getDirectoryBlocks());
assertEquals(FTConfig.AllocationUnit.CYLINDERS, config.getUnits()); assertEquals(FTConfig.AllocationUnit.CYLINDERS, config.getUnits());
assertEquals("IND$FILE PUT SYS1.PROCLIB ASCII CRLF SPACE(10,5,20) CYLINDERS", config.buildCommand()); assertEquals("TSO IND$FILE PUT SYS1.PROCLIB ASCII CRLF SPACE(10,5,20) CYLINDERS", config.buildCommand());
} }
@Test @Test
@@ -443,7 +443,7 @@ public class FTProtocolHardeningPhase16Test {
assertEquals("APVUFILE", optionsCfg.getProgramName()); assertEquals("APVUFILE", optionsCfg.getProgramName());
String cmd = optionsCfg.buildCommand(); String cmd = optionsCfg.buildCommand();
assertTrue(cmd.startsWith("APVUFILE PUT 'TEST.FILE'")); assertTrue(cmd.startsWith("TSO APVUFILE PUT 'TEST.FILE'"));
assertTrue(cmd.contains("NEW")); assertTrue(cmd.contains("NEW"));
assertTrue(cmd.contains("NOSO")); assertTrue(cmd.contains("NOSO"));
} }
@@ -68,6 +68,8 @@ public class DirectoryParserTest {
assertEquals(80, e0.getLrecl()); assertEquals(80, e0.getLrecl());
assertEquals(3120, e0.getBlksize()); assertEquals(3120, e0.getBlksize());
assertEquals("PS", e0.getDsorg()); assertEquals("PS", e0.getDsorg());
assertEquals(15, e0.getTracksAllocated());
assertEquals(1, e0.getExtents());
TSODirectoryEntry e1 = entries.get(1); TSODirectoryEntry e1 = entries.get(1);
assertEquals("TSOUSER.OTHER.DATA", e1.getName()); assertEquals("TSOUSER.OTHER.DATA", e1.getName());
@@ -151,4 +153,183 @@ public class DirectoryParserTest {
assertEquals("USER.TEST.CNTL", entries.get(0).getName()); assertEquals("USER.TEST.CNTL", entries.get(0).getName());
assertEquals("USER.TEST.DATA", entries.get(1).getName()); assertEquals("USER.TEST.DATA", entries.get(1).getName());
} }
@Test
public void testCmsMoreRunningAndReadyHandling() {
String cmsOutput =
"PROFILE EXEC A1 V 80 25 1 2026-05-10 14:22:01 MORE...\n" +
"MORE...\n" +
"RUNNING\n" +
"HOLDING\n" +
"TEST DATA A1 F 80 100 2 2026-08-01 09:15:00\n" +
"OTHER FILE A1 V 80 50 1 2026-08-02 10:00:00 RUNNING\n" +
"Ready(00000); T=0.01/0.01 12:00:00\n";
List<CMSDirectoryEntry> entries = CMSDirectoryParser.parse(cmsOutput);
assertEquals(3, entries.size(), "Should parse 3 CMS entries and strip/ignore MORE..., RUNNING, HOLDING, Ready(00000)");
assertEquals("PROFILE", entries.get(0).getFilename());
assertEquals("TEST", entries.get(1).getFilename());
assertEquals("OTHER", entries.get(2).getFilename());
}
@Test
public void testTsoListcatWithClusterAndGdg() {
String listcatOutput =
"IDCAMS SYSTEM SERVICES TIME: 12:00:00\n" +
"NONVSAM ------- TSOUSER.NORMAL.DATA\n" +
" VOLUMES\n" +
" VOLSER------------TSO001\n" +
"CLUSTER ------- TSOUSER.VSAM.KSDS\n" +
" IN-CAT --- CATALOG.USER\n" +
"GDG ----------- TSOUSER.GDG.BASE\n" +
"ALIAS --------- TSOUSER.MY.ALIAS\n";
List<TSODirectoryEntry> entries = TSODirectoryParser.parse(listcatOutput);
assertEquals(4, entries.size(), "Should parse NONVSAM, CLUSTER, GDG, and ALIAS");
assertEquals("TSOUSER.NORMAL.DATA", entries.get(0).getName());
assertEquals("TSO001", entries.get(0).getVolume());
assertEquals("TSOUSER.VSAM.KSDS", entries.get(1).getName());
assertEquals("VSAM", entries.get(1).getDsorg());
assertEquals("TSOUSER.GDG.BASE", entries.get(2).getName());
assertEquals("TSOUSER.MY.ALIAS", entries.get(3).getName());
}
@Test
public void testTsoListcatAllocDetails() {
String allocOutput =
"IDCAMS SYSTEM SERVICES TIME: 12:00:00\n" +
"NONVSAM ------- TSOUSER.PROJECT.DATA\n" +
" IN-CAT --- CATALOG.USER\n" +
" VOLUMES\n" +
" VOLSER------------TSOP01 DEVTYPE------X'3010200F'\n" +
" ALLOCATION\n" +
" SPACE-TYPE---------TRACK SPACE-PRI----------------15 SPACE-SEC----------------5\n" +
" EXTENTS-----------------1\n" +
"CLUSTER ------- TSOUSER.VSAM.TEST\n" +
" IN-CAT --- CATALOG.USER\n" +
" DATA ------- TSOUSER.VSAM.TEST.DATA\n" +
" VOLUMES\n" +
" VOLSER------------TSOP02\n" +
" ALLOCATION\n" +
" SPACE-TYPE------CYLINDER SPACE-PRI----------------2\n" +
" EXTENTS-----------------2\n" +
" ATTRIBUTES\n" +
" AVGLRECL--------------80 MAXLRECL----------------80 CISIZE----------------4096\n";
List<TSODirectoryEntry> entries = TSODirectoryParser.parse(allocOutput);
assertEquals(2, entries.size());
TSODirectoryEntry nonvsam = entries.get(0);
assertEquals("TSOUSER.PROJECT.DATA", nonvsam.getName());
assertEquals("TSOP01", nonvsam.getVolume());
assertEquals(15, nonvsam.getTracksAllocated());
assertEquals(1, nonvsam.getExtents());
TSODirectoryEntry vsam = entries.get(1);
assertEquals("TSOUSER.VSAM.TEST", vsam.getName());
assertEquals("VSAM", vsam.getDsorg());
assertEquals("TSOP02", vsam.getVolume());
assertEquals(30, vsam.getTracksAllocated()); // 2 cylinders * 15 tracks
assertEquals(2, vsam.getExtents());
assertEquals(80, vsam.getLrecl());
assertEquals(4096, vsam.getBlksize());
}
@Test
public void testTsoListdsParsing() {
String listdsOutput =
"TSOUSER.MY.SOURCE\n" +
"--RECFM-LRECL-BLKSIZE-DSORG\n" +
" FB 80 3120 PO\n" +
"--VOLUMES--\n" +
" SRC001\n";
List<TSODirectoryEntry> entries = TSODirectoryParser.parse(listdsOutput);
assertEquals(1, entries.size());
TSODirectoryEntry e = entries.get(0);
assertEquals("TSOUSER.MY.SOURCE", e.getName());
assertEquals("FB", e.getRecfm());
assertEquals(80, e.getLrecl());
assertEquals(3120, e.getBlksize());
assertEquals("PO", e.getDsorg());
assertEquals("SRC001", e.getVolume());
}
@Test
public void testTsoListdsWithStatusAllocationParsing() {
String listdsStatusOutput =
"SYS1.ACMDLIB\n" +
"--RECFM-LRECL-BLKSIZE-DSORG\n" +
" U 0 32760 PO\n" +
"--VOLUMES--\n" +
" TK5DLB\n" +
"--ALLOCATION---------------\n" +
" SPACE-TYPE TRACK\n" +
" SPACE-PRI 30\n" +
" SPACE-SEC 5\n" +
"--TRACKS-ALLOCATED-EXTENTS-\n" +
" 30 1\n";
List<TSODirectoryEntry> entries = TSODirectoryParser.parse(listdsStatusOutput);
assertEquals(1, entries.size());
TSODirectoryEntry e = entries.get(0);
assertEquals("SYS1.ACMDLIB", e.getName());
assertEquals("U", e.getRecfm());
assertEquals(0, e.getLrecl());
assertEquals(32760, e.getBlksize());
assertEquals("PO", e.getDsorg());
assertEquals("TK5DLB", e.getVolume());
assertEquals(30, e.getTracksAllocated());
assertEquals(1, e.getExtents());
}
@Test
public void testTsoTabularDslispParsing() {
String tabular = " USER.SAMPLE.COBOL MNT01 15 50 1 3390 PO FB 80 3120\n";
List<TSODirectoryEntry> entries = TSODirectoryParser.parse(tabular);
assertEquals(1, entries.size());
TSODirectoryEntry e = entries.get(0);
assertEquals("USER.SAMPLE.COBOL", e.getName());
assertEquals("MNT01", e.getVolume());
assertEquals(15, e.getTracksAllocated());
assertEquals(50, e.getPercentUsed());
assertEquals(1, e.getExtents());
assertEquals("3390", e.getDevice());
assertEquals("PO", e.getDsorg());
assertEquals("FB", e.getRecfm());
assertEquals(80, e.getLrecl());
assertEquals(3120, e.getBlksize());
}
@Test
public void testTsoListcatWithSysresAsterisksVolume() {
String listcatOutput =
"IDCAMS SYSTEM SERVICES TIME: 02:32:00\n" +
"NONVSAM ------- SYS1.ACMDLIB\n" +
" IN-CAT --- SYS1.MCAT.TK5\n" +
" HISTORY\n" +
" OWNER-IDENT-------(NULL) CREATION----------23.146\n" +
" RELEASE----------------2 EXPIRATION--------00.000\n" +
" VOLUMES\n" +
" VOLSER------------****** DEVTYPE------X'3010200F' FSEQN------------------0\n" +
" ASSOCIATIONS--------(NULL)\n" +
"NONVSAM ------- SYS1.SYSTEM.CMDSBSYS.CSCDATA\n" +
" IN-CAT --- SYS1.MCAT.TK5\n" +
" HISTORY\n" +
" OWNER-IDENT-------(NULL) CREATION----------23.146\n" +
" VOLUMES\n" +
" VOLSER------------TK5002 DEVTYPE------X'3010200F' FSEQN------------------0\n" +
" ASSOCIATIONS--------(NULL)\n";
List<TSODirectoryEntry> entries = TSODirectoryParser.parse(listcatOutput);
assertEquals(2, entries.size());
TSODirectoryEntry sysresEntry = entries.get(0);
assertEquals("SYS1.ACMDLIB", sysresEntry.getName());
assertEquals("******", sysresEntry.getVolume(), "SYSRES volume with asterisks should be preserved");
TSODirectoryEntry explicitEntry = entries.get(1);
assertEquals("SYS1.SYSTEM.CMDSBSYS.CSCDATA", explicitEntry.getName());
assertEquals("TK5002", explicitEntry.getVolume());
}
} }
@@ -54,6 +54,7 @@ public class Mock3270Server implements AutoCloseable {
private int sequenceNumber = 0; private int sequenceNumber = 0;
private final List<byte[]> receivedRecords = new CopyOnWriteArrayList<>(); private final List<byte[]> receivedRecords = new CopyOnWriteArrayList<>();
private final List<int[]> receivedCommands = new CopyOnWriteArrayList<>();
private final AtomicInteger receivedNops = new AtomicInteger(0); private final AtomicInteger receivedNops = new AtomicInteger(0);
private final AtomicInteger receivedTimingMarks = new AtomicInteger(0); private final AtomicInteger receivedTimingMarks = new AtomicInteger(0);
@@ -126,6 +127,10 @@ public class Mock3270Server implements AutoCloseable {
return Collections.unmodifiableList(receivedRecords); return Collections.unmodifiableList(receivedRecords);
} }
public List<int[]> getReceivedCommands() {
return Collections.unmodifiableList(receivedCommands);
}
public byte[] getLastReceivedRecord() { public byte[] getLastReceivedRecord() {
if (receivedRecords.isEmpty()) return null; if (receivedRecords.isEmpty()) return null;
return receivedRecords.get(receivedRecords.size() - 1); return receivedRecords.get(receivedRecords.size() - 1);
@@ -285,6 +290,7 @@ public class Mock3270Server implements AutoCloseable {
private void handleOptionCommand(int cmd, int opt) throws IOException { private void handleOptionCommand(int cmd, int opt) throws IOException {
log.fine("Mock3270Server: RCVD IAC " + cmd + " opt=" + opt); log.fine("Mock3270Server: RCVD IAC " + cmd + " opt=" + opt);
receivedCommands.add(new int[] { cmd, opt });
if (cmd == WILL && opt == TELOPT_TN3270E) { if (cmd == WILL && opt == TELOPT_TN3270E) {
// Client accepted TN3270E -> Send SB TN3270E SEND DEVICE-TYPE IAC SE // Client accepted TN3270E -> Send SB TN3270E SEND DEVICE-TYPE IAC SE
sendDeviceTypeSend(); sendDeviceTypeSend();
@@ -290,4 +290,46 @@ public class Mock3270ServerIntegrationTest {
assertTrue(matched, "Fragmented stream must be reassembled and displayed correctly"); assertTrue(matched, "Fragmented stream must be reassembled and displayed correctly");
} }
@Test
public void testGracefulDisconnectSendsUnbindAndDeNegotiation() throws Exception {
ConnectionConfig config = new ConnectionConfig("127.0.0.1", server.getPort(), TerminalModel.IBM_3279_2);
client = new Telnet3270Client(config);
assertTrue(client.connect(8000));
assertTrue(server.waitForHandshake(5, TimeUnit.SECONDS));
waitForState(client, ConnectionState.CONNECTED_TN3270E, 5000);
assertTrue(client.getTelnetFSM().isTn3270eBound());
// Perform graceful disconnect
client.disconnect();
// Verify client transitioned to NOT_CONNECTED
assertEquals(ConnectionState.NOT_CONNECTED, client.getConnectionState());
assertFalse(client.isConnected());
// Wait briefly for server to process queued records
Thread.sleep(150);
// Verify server received TN3270E UNBIND
boolean receivedUnbind = false;
for (byte[] rec : server.getReceivedRecords()) {
if (rec != null && rec.length > 5 && (rec[0] & 0xFF) == DT_UNBIND) {
int reason = rec[5] & 0xFF;
assertEquals(UNBIND_NORMAL, reason, "UNBIND reason should be UNBIND_NORMAL (0x01)");
receivedUnbind = true;
break;
}
}
assertTrue(receivedUnbind, "Server should have received TN3270E UNBIND record");
// Verify server received Telnet option de-negotiation (WONT TN3270E or DONT TN3270E)
boolean receivedWontOrDontTn3270e = false;
for (int[] cmd : server.getReceivedCommands()) {
if ((cmd[0] == WONT || cmd[0] == DONT) && cmd[1] == TELOPT_TN3270E) {
receivedWontOrDontTn3270e = true;
break;
}
}
assertTrue(receivedWontOrDontTn3270e, "Server should have received Telnet de-negotiation for TN3270E");
}
} }