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());
// 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 TerminalPanel terminalPanel;
private StatusBar statusBar;
@@ -60,14 +84,16 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
public J3270App() {
super("j3270 — Java TN3270 Terminal Emulator");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
setBackground(Color.BLACK);
AppIcon.applyTo(this);
openWindows.add(this);
buildUI();
buildMenuBar();
ThemeManager.addThemeChangeListener(this::onThemeChanged);
ThemeManager.addThemeChangeListener(themeChangeListener);
pack();
setLocationRelativeTo(null);
@@ -92,16 +118,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
disconnect();
if (refreshTimer != null)
refreshTimer.stop();
if (printerSessionDialog != null) {
printerSessionDialog.dispose();
}
if (screenHistoryDialog != null) {
screenHistoryDialog.dispose();
}
terminalPanel.dispose();
closeWindow();
}
@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("Disconnect", KeyEvent.VK_D, this::disconnect));
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("3287 Printer Session...", -1, this::showPrinterSessionDialog));
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.addSeparator();
fileMenu.add(createMenuItem("Quit", KeyEvent.VK_Q, () -> {
disconnect();
for (J3270App win : new java.util.ArrayList<>(openWindows)) {
win.disconnect();
win.dispose();
}
System.exit(0);
}));
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");
editMenu.add(createMenuItem("Cut", KeyEvent.VK_X, () -> terminalPanel.cutSelection()));
editMenu.add(createMenuItem("Copy", KeyEvent.VK_C, () -> terminalPanel.copySelection()));
@@ -743,7 +799,20 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
// ========== 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);
String initialHost = lastHost != null && !lastHost.isEmpty() ? lastHost : haus.nightmare.j3270.config.Settings.getAutoConnectHost();
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.setInitialCodePage(haus.nightmare.j3270.config.Settings.getCodePage());
dialog.setInitialGraphicsMode(haus.nightmare.j3270.config.Settings.getGraphicsMode());
dialog.setInitialHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
dialog.setVisible(true);
if (dialog.isConfirmed()) {
ConnectionConfig config = dialog.getConnectionConfig();
lastHost = config.getHost();
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);
}
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() {
return terminalPanel;
}
@@ -780,7 +985,22 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
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();
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.setAutoConnectVerifyCert(config.isTlsVerifyCert());
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) {
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) {
config.setCertificateVerifier((chain, authType, exception) ->
@@ -827,6 +1065,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
new Thread(() -> {
try {
if (oldClient != null) {
try {
oldClient.disconnect(1000);
} catch (Exception ignored) {}
}
client.connect();
} catch (IOException e) {
log.log(Level.WARNING, "Connection failed", e);
@@ -841,7 +1084,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
terminalPanel.requestFocusInWindow();
}
private void disconnect() {
public void disconnect() {
disconnect(true);
}
public void disconnect(boolean resetUi) {
if (client != null) {
if (fileTransfer != null) {
fileTransfer.cancel();
@@ -852,12 +1099,16 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
}
Telnet3270Client c = client;
client = null;
c.removeConnectionListener(this);
c.removeScreenUpdateListener(this);
new Thread(() -> {
try {
c.disconnect();
c.disconnect(1000);
} catch (Exception ignored) {}
}, "Disconnect-Thread").start();
if (resetUi) {
Runnable uiReset = () -> {
terminalPanel.setClient(null);
statusBar.setClient(null, terminalPanel);
@@ -872,6 +1123,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
}
}
}
}
private void adjustFontSize(int delta) {
int current = terminalPanel.getFontSize();
@@ -1201,6 +1453,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
String configFile = null;
String cliProxyUrl = null;
String cliProfileName = null;
String cliHostType = null;
java.util.List<String> remainingArgs = new java.util.ArrayList<>();
for (int i = 0; i < args.length; i++) {
@@ -1219,6 +1472,10 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
cliAutoSysUnlock = true;
} else if ("--no-auto-sys-unlock".equals(arg)) {
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=")) {
cliGraphicsMode = GraphicsMode.fromString(arg.substring(11));
} 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 String finalProxyUrl = cliProxyUrl;
final String finalProfileName = cliProfileName;
final String finalHostType = cliHostType;
SwingUtilities.invokeLater(() -> {
J3270App app = new J3270App();
@@ -1285,6 +1543,7 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
}
if (profile != null) {
ConnectionConfig config = profile.toConnectionConfig();
if (finalHostType != null) config.setHostType(finalHostType);
if (finalTls) config.setUseTls(true);
if (finalNoVerify) config.setTlsVerifyCert(false);
if (finalTn3270e != null) config.setTn3270eEnabled(finalTn3270e);
@@ -1341,6 +1600,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
if (finalProxyUrl != null) {
applyProxyUrl(config, finalProxyUrl);
}
if (finalHostType != null) {
config.setHostType(finalHostType);
} else {
config.setHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
}
app.connect(config);
} else {
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();
if (autoHost != null) {
ConnectionConfig config = autoHost.toConnectionConfig();
if (finalHostType != null) config.setHostType(finalHostType);
if (finalTls) config.setUseTls(true);
if (finalNoVerify) config.setTlsVerifyCert(false);
if (finalTn3270e != null) config.setTn3270eEnabled(finalTn3270e);
@@ -1381,6 +1646,11 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate
if (finalProxyUrl != null) {
applyProxyUrl(config, finalProxyUrl);
}
if (finalHostType != null) {
config.setHostType(finalHostType);
} else {
config.setHostType(haus.nightmare.j3270.config.Settings.getDefaultHostType());
}
app.connect(config);
} else {
SwingUtilities.invokeLater(app::showConnectDialog);
@@ -163,6 +163,15 @@ public class Settings {
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) {
setAutoConnectAutoReconnect(autoReconnect);
}
@@ -738,6 +747,11 @@ public class Settings {
case "dynamic_cols":
setDynamicCols(Integer.parseInt(value));
break;
case "hostType":
case "hosttype":
case "host_type":
setDefaultHostType(value);
break;
case "blockSelectMode": setBlockSelectMode(Boolean.parseBoolean(value)); break;
case "blockPaste": setBlockPaste(Boolean.parseBoolean(value)); break;
case "autoReconnect":
@@ -945,6 +959,7 @@ public class Settings {
w.println("autoConnectTn3270e = " + getAutoConnectTn3270e());
}
w.println("codePage = " + getCodePage());
w.println("hostType = " + getDefaultHostType());
w.println("dynamicRows = " + getDynamicRows());
w.println("dynamicCols = " + getDynamicCols());
w.println("blockSelectMode = " + getBlockSelectMode());
@@ -5,6 +5,7 @@ import haus.nightmare.j3270.ui.HostDirectoryDialog;
import haus.nightmare.j3270.ui.ThemeManager;
import haus.nightmare.lib3270j.ft.FTConfig;
import haus.nightmare.lib3270j.ft.FTConstants;
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
@@ -329,6 +330,20 @@ public class FileTransferDialog extends JDialog {
if (lastTransferState.dirBlocks != null) dirField.setText(lastTransferState.dirBlocks);
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() {
@@ -381,11 +396,26 @@ public class FileTransferDialog extends JDialog {
private void browseHostDirectory() {
FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
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);
dialog.setVisible(true);
if (dialog.isConfirmed() && dialog.getSelectedHostFile() != null) {
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() ||
(lastTransferState != null && localFileField.getText().trim().equals(lastTransferState.localFile));
if (shouldAutofill) {
@@ -108,6 +108,10 @@ public class SavedHost {
}
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()) {
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<String> codePageCombo;
private JTextField luField;
private JComboBox<String> hostTypeCombo;
private JCheckBox tlsCheckBox;
private JCheckBox verifyCertCheckBox;
private JCheckBox tn3270eCheckBox;
@@ -214,6 +215,25 @@ public class ConnectDialog extends JDialog {
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
gbc.gridx = 0;
gbc.gridy = gridy;
@@ -565,6 +585,7 @@ public class ConnectDialog extends JDialog {
} else {
selectedProfileId = null;
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);
dynamicColsSpinner.setValue(h.getDynamicCols() > 0 ? h.getDynamicCols() : 160);
luField.setText(h.getLuName() != null ? h.getLuName() : "");
setInitialHostType(h.getHostType() != null ? h.getHostType() : "TSO");
tlsCheckBox.setSelected(h.isUseTls());
verifyCertCheckBox.setEnabled(h.isUseTls());
verifyCertCheckBox.setSelected(h.isTlsVerifyCert());
@@ -677,6 +699,7 @@ public class ConnectDialog extends JDialog {
int dRows = (Integer) dynamicRowsSpinner.getValue();
int dCols = (Integer) dynamicColsSpinner.getValue();
String lu = luField.getText().trim();
String hostType = getSelectedHostType();
boolean tls = tlsCheckBox.isSelected();
boolean verify = verifyCertCheckBox.isSelected();
boolean tn3270e = tn3270eCheckBox.isSelected();
@@ -705,7 +728,7 @@ public class ConnectDialog extends JDialog {
String pPass = new String(proxyPassField.getPassword());
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 =================
@@ -797,10 +820,14 @@ public class ConnectDialog extends JDialog {
result.setProxy(pType, pHost, pPort, pUser.isEmpty() ? null : pUser, pPass.isEmpty() ? null : pPass);
}
result.setHostType(getSelectedHostType());
if (selectedProfileId != null) {
result.setSavedConnection(true);
result.setSavedProfileId(selectedProfileId);
SavedHost existing = HostStorage.findById(selectedProfileId);
String pName = (existing != null && existing.getName() != null && !existing.getName().trim().isEmpty())
? existing.getName() : host;
result.setSavedProfileName(pName);
SavedHost updated = buildCurrentSavedHost(pName);
HostStorage.saveHost(updated);
}
@@ -902,4 +929,44 @@ public class ConnectDialog extends JDialog {
public String getProxyPassword() {
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 String selectedHostFile = null;
private HostDirectoryEntry selectedEntry = null;
private boolean confirmed = false;
private final List<HostDirectoryEntry> allEntries = new ArrayList<>();
@@ -50,7 +51,7 @@ public class HostDirectoryDialog extends JDialog {
AppIcon.applyTo(this);
buildUI(initialQuery);
setSize(780, 460);
setSize(840, 480);
setLocationRelativeTo(parent);
}
@@ -78,6 +79,18 @@ public class HostDirectoryDialog extends JDialog {
ThemeManager.styleComboBox(hostTypeCombo);
hostTypeCombo.addActionListener(e -> {
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()) {
queryHost();
} else {
@@ -93,9 +106,9 @@ public class HostDirectoryDialog extends JDialog {
gbc.gridx = 3;
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());
ThemeManager.styleTextField(queryField);
queryField.addActionListener(e -> onFilterOrParse());
topPanel.add(queryField, gbc);
@@ -118,8 +131,14 @@ public class HostDirectoryDialog extends JDialog {
mainPanel.add(topPanel, BorderLayout.NORTH);
// Table
tableModel = new DefaultTableModel();
tableModel = new DefaultTableModel() {
@Override
public boolean isCellEditable(int row, int column) {
return false;
}
};
table = new JTable(tableModel);
table.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
ThemeManager.styleTable(table);
table.setFont(ThemeManager.getMonospacedUiFont());
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
@@ -138,21 +157,30 @@ public class HostDirectoryDialog extends JDialog {
});
JScrollPane scrollPane = new JScrollPane(table);
scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED);
ThemeManager.styleScrollPane(scrollPane);
mainPanel.add(scrollPane, BorderLayout.CENTER);
// Bottom
JPanel bottomPanel = new JPanel(new BorderLayout(5, 5));
JPanel bottomPanel = new JPanel(new BorderLayout(5, 6));
bottomPanel.setOpaque(false);
bottomPanel.setBorder(BorderFactory.createEmptyBorder(6, 4, 4, 4));
statusLabel = new JLabel("Enter a dataset pattern or parse active screen.");
statusLabel.setForeground(ThemeManager.getFgMuted());
statusLabel.setFont(font);
bottomPanel.add(statusLabel, BorderLayout.WEST);
bottomPanel.add(statusLabel, BorderLayout.NORTH);
JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0));
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");
ThemeManager.styleButton(pasteScreenBtn, ThemeManager.ButtonVariant.DEFAULT);
pasteScreenBtn.setFont(font);
@@ -178,13 +206,14 @@ public class HostDirectoryDialog extends JDialog {
dispose();
});
btnPanel.add(inspectBtn);
btnPanel.add(pasteScreenBtn);
btnPanel.add(pasteClipBtn);
btnPanel.add(Box.createHorizontalStrut(10));
btnPanel.add(selectBtn);
btnPanel.add(cancelBtn);
bottomPanel.add(btnPanel, BorderLayout.EAST);
bottomPanel.add(btnPanel, BorderLayout.SOUTH);
mainPanel.add(bottomPanel, BorderLayout.SOUTH);
setContentPane(mainPanel);
@@ -192,14 +221,11 @@ public class HostDirectoryDialog extends JDialog {
// Initial setup: columns and screen parse or host query
setupTableColumns();
if (initialQuery != null && !initialQuery.trim().isEmpty()) {
queryField.setText(initialQuery.trim());
}
if (client != null && client.getConnectionState().isFullSession()) {
queryHost();
} else {
parseCurrentScreen();
if (initialQuery != null && !initialQuery.trim().isEmpty()) {
if (!queryField.getText().trim().isEmpty()) {
applyFilter();
}
}
@@ -212,10 +238,21 @@ public class HostDirectoryDialog extends JDialog {
tableModel.setColumnIdentifiers(new Object[]{
"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 {
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<>() {
@Override
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();
int maxPages = 500;
if (hostType == FTConfig.HostType.CMS) {
// CMS Query:
// 1. Clear presentation space first to ensure clean state
client.getPS().SendKeys("[clear]");
waitForKeyboardUnlock(client, 3000);
// 2. Format query command
if (hostType == FTConfig.HostType.CMS) {
String cmsFilter;
if (pattern.isEmpty()) {
cmsFilter = "* * *";
} else {
String clean = pattern.replace("'", "").replace("\"", "").trim();
if (clean.contains(".")) {
int dot = clean.indexOf('.');
cmsFilter = clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " *";
} else if (clean.split("\\s+").length == 1) {
cmsFilter = clean + " * *";
} else {
cmsFilter = clean;
}
}
// 2. Format CMS LISTFILE query command (default to minidisk A unless specified)
String cmsFilter = formatCMSFilter(pattern);
client.getPS().SendKeys("LISTFILE " + cmsFilter + " ( nohead alloc date[enter]");
Thread.sleep(100);
// 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 {
if (pattern.isEmpty()) {
client.getPS().SendKeys("LISTCAT[enter]");
// Ready; encountered or timed out -> listing complete
break;
}
}
// 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 {
// TSO Query:
// Check if current screen is in ISPF (formatted screen or containing ===> or ISPF)
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();
if (dsnPattern.startsWith("'") && dsnPattern.endsWith("'") && dsnPattern.length() > 2) {
dsnPattern = dsnPattern.substring(1, dsnPattern.length() - 1);
@@ -281,43 +367,139 @@ public class HostDirectoryDialog extends JDialog {
if (paren > 0) {
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;
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";
}
}
}
// 3. Wait for host to process command and unlock keyboard
Thread.sleep(100);
waitForKeyboardUnlock(client, 5000);
// 4. Collect pages, handling MORE... on CMS or *** on TSO
StringBuilder allPages = new StringBuilder();
int maxPages = 20;
for (int page = 0; page < maxPages; page++) {
int rows = client.getPS().getRows();
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 {
if (screen.contains("***")) {
hasMore = true;
client.getPS().SendKeys("[enter]");
client.getPS().SendKeys("[home]");
}
client.getPS().SendKeys("[erase_eof]");
client.getPS().SendKeys(tsoCmd + "[enter]");
} else {
// In native TSO (ISPF closed, line mode READY prompt)
client.getPS().SendKeys("[clear]");
waitForKeyboardUnlock(client, 3000);
client.getPS().SendKeys(tsoCmd + "[enter]");
}
if (hasMore) {
Thread.sleep(100);
waitForKeyboardUnlock(client, 3000);
// 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();
}
@@ -338,6 +520,180 @@ public class HostDirectoryDialog extends JDialog {
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) {
if (client == null) return false;
long deadline = System.currentTimeMillis() + timeoutMs;
@@ -416,25 +772,29 @@ public class HostDirectoryDialog extends JDialog {
tableModel.setRowCount(0);
currentEntries.clear();
String filter = queryField.getText().trim();
String filterUpper = filter.toUpperCase();
FTConfig.HostType hostType = (FTConfig.HostType) hostTypeCombo.getSelectedItem();
for (HostDirectoryEntry entry : allEntries) {
if (!filter.isEmpty() && !matchesFilter(entry.getName().toUpperCase(), filterUpper)) {
if (!matchesFilter(entry, filter)) {
continue;
}
currentEntries.add(entry);
if (hostType == FTConfig.HostType.CMS && entry instanceof CMSDirectoryEntry) {
CMSDirectoryEntry cms = (CMSDirectoryEntry) entry;
tableModel.addRow(new Object[]{
cms.getFilename(), cms.getFiletype(), cms.getFilemode(),
cms.getRecfm(), cms.getLrecl(), cms.getNumRecords(), cms.getDate() + " " + cms.getTime()
cms.getFilename() != null ? cms.getFilename() : "",
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) {
TSODirectoryEntry tso = (TSODirectoryEntry) entry;
tableModel.addRow(new Object[]{
tso.getDatasetName(), tso.getVolume(), tso.getRecfm(),
tso.getLrecl(), tso.getBlksize(), tso.getTracksAllocated(), tso.getExtents()
tso.getDatasetName() != null ? tso.getDatasetName() : "",
tso.getVolume() != null ? tso.getVolume() : ""
});
}
}
@@ -444,16 +804,99 @@ public class HostDirectoryDialog extends JDialog {
}
}
private boolean matchesFilter(String name, String filter) {
String cleanFilter = filter.replace("'", "").trim();
public static boolean matchesFilter(HostDirectoryEntry entry, String filter) {
if (filter == null || filter.trim().isEmpty()) {
return true;
}
String cleanFilter = filter.replace("'", "").replace("\"", "").trim();
if (cleanFilter.isEmpty() || cleanFilter.equals("*") || cleanFilter.equals("* * *") || cleanFilter.equals("*.*")) {
return true;
}
if (cleanFilter.contains("*")) {
String regex = "\\Q" + cleanFilter.replace("*", "\\E.*\\Q") + "\\E";
return name.matches(regex);
if (entry instanceof CMSDirectoryEntry) {
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() {
@@ -466,6 +909,7 @@ public class HostDirectoryDialog extends JDialog {
HostDirectoryEntry entry = currentEntries.get(row);
selectedHostFile = entry.getName();
selectedEntry = entry;
confirmed = true;
dispose();
}
@@ -478,6 +922,10 @@ public class HostDirectoryDialog extends JDialog {
return selectedHostFile;
}
public HostDirectoryEntry getSelectedEntry() {
return selectedEntry;
}
public List<HostDirectoryEntry> getAllEntries() {
return allEntries;
}
@@ -485,4 +933,38 @@ public class HostDirectoryDialog extends JDialog {
public List<HostDirectoryEntry> getCurrentEntries() {
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());
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
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;
import haus.nightmare.j3270.ui.HostDirectoryDialog;
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.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -130,14 +133,83 @@ public class FileTransferSessionAndCmsTest {
@Test
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 {
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);
assertFalse(dialog.isConfirmed());
assertNull(dialog.getSelectedHostFile());
assertEquals("* * A", dialog.getQueryField().getText());
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) {
}
}
@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 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(String host, int port) {
@@ -312,6 +318,51 @@ public class ConnectionConfig {
public int getTcpUserTimeoutMs() { return tcpUserTimeoutMs; }
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"),
* 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.util.logging.Logger;
import java.util.logging.Level;
/**
* Main API entry point for lib3270j.
@@ -195,6 +196,13 @@ public class Telnet3270Client {
public void disconnect() {
cancelAutoReconnect();
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 = null;
}
@@ -542,7 +542,12 @@ public class FTConfig {
StringBuilder cmd = new StringBuilder();
// 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(hostFilename != null ? hostFilename.trim() : "");
@@ -26,7 +26,16 @@ public class CMSDirectoryParser {
String upper = trimmed.toUpperCase();
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);
if (entry != null) {
@@ -11,12 +11,16 @@ import java.util.regex.Pattern;
public class TSODirectoryParser {
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 VOLSER_PATTERN = Pattern.compile("VOLSER-+([A-Z0-9]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern RECFM_PATTERN = Pattern.compile("RECFM-+([A-Z]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern LRECL_PATTERN = Pattern.compile("LRECL-+([0-9]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern BLKSIZE_PATTERN = Pattern.compile("BLKSIZE-+([0-9]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern DSORG_PATTERN = Pattern.compile("DSORG-+([A-Z]+)", 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\\s*[-:=]+\\s*([A-Z0-9*@#$&]+)", 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("(?:AVG|MAX)?LRECL\\s*[-:=]+\\s*([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\\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.
@@ -30,11 +34,18 @@ public class TSODirectoryParser {
String[] lines = splitIntoLines(text);
// 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);
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
for (String line : lines) {
String trimmed = line.trim();
@@ -72,36 +83,176 @@ public class TSODirectoryParser {
private static void parseListcat(String[] lines, List<TSODirectoryEntry> entries) {
TSODirectoryEntry current = null;
String currentSpaceType = null;
for (String line : lines) {
String upper = line.toUpperCase();
Matcher nonvsamMat = NONVSAM_PATTERN.matcher(upper);
if (nonvsamMat.find()) {
if (current != null && current.getName() != null) {
if (current.getTracksAllocated() > 0 && current.getExtents() == 0) {
current.setExtents(1);
}
entries.add(current);
}
current = new TSODirectoryEntry(nonvsamMat.group(1));
if (upper.contains("CLUSTER") || upper.contains("AIX") || upper.contains("PATH")) {
current.setDsorg("VSAM");
}
currentSpaceType = null;
continue;
}
if (current != null) {
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);
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);
if (lreclMat.find()) {
if (lreclMat.find() && current.getLrecl() == 0) {
try { current.setLrecl(Integer.parseInt(lreclMat.group(1))); } catch (NumberFormatException ignored) {}
}
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) {}
}
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) {
@@ -127,6 +278,8 @@ public class TSODirectoryParser {
TSODirectoryEntry entry = new TSODirectoryEntry(tokens[dsnIdx].toUpperCase());
boolean seenFormat = false;
int numBeforeFormat = 0;
int numAfterFormat = 0;
// Parse remaining tokens
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)$")) {
entry.setDsorg(tok);
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);
seenFormat = true;
} else if (tok.matches("^(3390|3380|TAPE|VIO)$")) {
@@ -148,23 +301,29 @@ public class TSODirectoryParser {
} else {
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);
} else if (tok.matches("^\\d+$")) {
int val = Integer.parseInt(tok);
if (!seenFormat) {
if (entry.getTracksAllocated() == 0) {
if (numBeforeFormat == 0) {
entry.setTracksAllocated(val);
} else if (entry.getPercentUsed() == 0 && val <= 100) {
numBeforeFormat++;
} else if (numBeforeFormat == 1 && val <= 100) {
entry.setPercentUsed(val);
} else if (entry.getExtents() == 0 && val <= 128) {
numBeforeFormat++;
} else if (numBeforeFormat <= 2 && val <= 128) {
entry.setExtents(val);
numBeforeFormat++;
}
} else {
if (entry.getLrecl() == 0 && val <= 32760) {
if (numAfterFormat == 0 && val <= 32760) {
entry.setLrecl(val);
} else if (entry.getBlksize() == 0 && val <= 32760) {
numAfterFormat++;
} else if (numAfterFormat == 1 && val <= 32760) {
entry.setBlksize(val);
numAfterFormat++;
} else if (entry.getTracksAllocated() == 0) {
entry.setTracksAllocated(val);
}
@@ -172,6 +331,10 @@ public class TSODirectoryParser {
}
}
if (entry.getTracksAllocated() > 0 && entry.getExtents() == 0) {
entry.setExtents(1);
}
return entry;
}
}
@@ -556,17 +556,42 @@ public class TelnetConnection {
running = false;
stopKeepAlive();
try {
if (outputStream != null) {
try {
outputStream.flush();
} catch (IOException ignored) {}
}
if (socket != null && !socket.isClosed()) {
socket.shutdownInput();
try {
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();
}
} catch (IOException e) {
log.log(Level.FINE, "Error during disconnect", e);
}
} finally {
socket = null;
inputStream = null;
outputStream = null;
readerThread = null;
}
log.info("Disconnected");
}
@@ -1366,6 +1366,71 @@ public class TelnetFSM {
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 ==========
private void checkIn3270() {
@@ -1531,6 +1596,9 @@ public class TelnetFSM {
private void changeState(ConnectionState newState) {
ConnectionState old = this.connectionState;
if (old == newState) {
return;
}
this.connectionState = newState;
log.info("State: " + old + " -> " + newState);
for (ConnectionListener l : connectionListeners) {
@@ -610,47 +610,68 @@ public class Xfer3270 implements FileTransferInterface {
if (session != null && session.getPS() != null) {
try {
ECLPS ps = session.getPS();
ScreenBuffer sb = ps.getScreenBuffer();
StringBuilder sbAll = new StringBuilder();
int maxPages = 500;
if (hostType == VM_CMS) {
ps.SendKeys("[clear]");
if (session.getOIA() != null) {
session.getOIA().waitForInput(3000);
}
if (hostType == VM_CMS) {
String filter = (hostQuery != null && !hostQuery.trim().isEmpty()) ? hostQuery.trim() : "* * *";
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]");
} 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) {}
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(5000);
session.getOIA().waitForInput(500);
}
StringBuilder sbAll = new StringBuilder();
for (int page = 0; page < 20; page++) {
int rows = ps.getRows();
int cols = ps.getCols();
String screen = ps.copyString(0, 0, rows - 1, cols - 1);
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");
boolean hasMore = false;
if (hostType == VM_CMS) {
if (screen.contains("MORE...") || screen.contains("HOLDING")) {
hasMore = true;
ps.SendKeys("[enter]");
}
} else {
if (screen.contains("***")) {
hasMore = true;
ps.SendKeys("[enter]");
}
}
if (hasMore) {
if (isMore) {
ps.SendKeys("[clear]");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
if (session.getOIA() != null) {
session.getOIA().waitForInput(3000);
@@ -659,6 +680,172 @@ public class Xfer3270 implements FileTransferInterface {
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 cols = ps.getCols();
String initialScreen = ps.copyString(0, 0, rows - 1, cols - 1);
boolean isFormatted = sb != null && sb.isFormatted();
boolean hasCmdArrow = initialScreen.contains("===>");
boolean isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF");
String filter = (hostQuery != null && !hostQuery.trim().isEmpty()) ? hostQuery.trim() : "";
String tsoCmd = "TSO LISTCAT ALL";
if (!filter.isEmpty()) {
if (filter.startsWith("'") && filter.endsWith("'") && filter.length() > 2) {
filter = filter.substring(1, filter.length() - 1);
}
int paren = filter.indexOf('(');
if (paren > 0) filter = filter.substring(0, paren);
if (filter.equals("*") || filter.equals("* * *") || filter.equals("*.*")) {
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 (isIspf) {
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) {
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 {
ps.SendKeys("[clear]");
if (session.getOIA() != null) {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
session.getOIA().waitForInput(2000);
}
}
}
dataToParse = sbAll.toString();
} catch (Exception ignored) {
// Fall back to hostQuery
@@ -14,7 +14,7 @@ public class FTConfigTest {
config.setTransferMode(FTConfig.TransferMode.ASCII);
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
@@ -25,7 +25,7 @@ public class FTConfigTest {
config.setHostFilename("'USER.BIN.DATA'");
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
@@ -40,7 +40,7 @@ public class FTConfigTest {
config.setLrecl("80");
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
@@ -143,7 +143,7 @@ public class FTConfigTest {
config.setUnits(FTConfig.AllocationUnit.TRACKS);
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
@@ -157,7 +157,7 @@ public class FTConfigTest {
assertEquals(10, config.getSecondarySpace());
assertEquals(15, config.getDirectoryBlocks());
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
@@ -171,7 +171,7 @@ public class FTConfigTest {
assertEquals(5, config.getSecondarySpace());
assertEquals(20, config.getDirectoryBlocks());
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
@@ -443,7 +443,7 @@ public class FTProtocolHardeningPhase16Test {
assertEquals("APVUFILE", optionsCfg.getProgramName());
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("NOSO"));
}
@@ -68,6 +68,8 @@ public class DirectoryParserTest {
assertEquals(80, e0.getLrecl());
assertEquals(3120, e0.getBlksize());
assertEquals("PS", e0.getDsorg());
assertEquals(15, e0.getTracksAllocated());
assertEquals(1, e0.getExtents());
TSODirectoryEntry e1 = entries.get(1);
assertEquals("TSOUSER.OTHER.DATA", e1.getName());
@@ -151,4 +153,183 @@ public class DirectoryParserTest {
assertEquals("USER.TEST.CNTL", entries.get(0).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 final List<byte[]> receivedRecords = new CopyOnWriteArrayList<>();
private final List<int[]> receivedCommands = new CopyOnWriteArrayList<>();
private final AtomicInteger receivedNops = new AtomicInteger(0);
private final AtomicInteger receivedTimingMarks = new AtomicInteger(0);
@@ -126,6 +127,10 @@ public class Mock3270Server implements AutoCloseable {
return Collections.unmodifiableList(receivedRecords);
}
public List<int[]> getReceivedCommands() {
return Collections.unmodifiableList(receivedCommands);
}
public byte[] getLastReceivedRecord() {
if (receivedRecords.isEmpty()) return null;
return receivedRecords.get(receivedRecords.size() - 1);
@@ -285,6 +290,7 @@ public class Mock3270Server implements AutoCloseable {
private void handleOptionCommand(int cmd, int opt) throws IOException {
log.fine("Mock3270Server: RCVD IAC " + cmd + " opt=" + opt);
receivedCommands.add(new int[] { cmd, opt });
if (cmd == WILL && opt == TELOPT_TN3270E) {
// Client accepted TN3270E -> Send SB TN3270E SEND DEVICE-TYPE IAC SE
sendDeviceTypeSend();
@@ -290,4 +290,46 @@ public class Mock3270ServerIntegrationTest {
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");
}
}