diff --git a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java index 68a9112..f7ce565 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java +++ b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java @@ -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 openWindows = new java.util.concurrent.CopyOnWriteArrayList<>(); + + public static java.util.List 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 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 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,23 +1099,28 @@ 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(); - Runnable uiReset = () -> { - terminalPanel.setClient(null); - statusBar.setClient(null, terminalPanel); - syncModeMenuItems(); - terminalPanel.repaint(); - setTitle("j3270 — Java TN3270 Terminal Emulator"); - }; - if (SwingUtilities.isEventDispatchThread()) { - uiReset.run(); - } else { - SwingUtilities.invokeLater(uiReset); + if (resetUi) { + Runnable uiReset = () -> { + terminalPanel.setClient(null); + statusBar.setClient(null, terminalPanel); + syncModeMenuItems(); + terminalPanel.repaint(); + setTitle("j3270 — Java TN3270 Terminal Emulator"); + }; + if (SwingUtilities.isEventDispatchThread()) { + uiReset.run(); + } else { + SwingUtilities.invokeLater(uiReset); + } } } } @@ -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 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); diff --git a/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java b/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java index 31301fe..6dc6bb6 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java +++ b/j3270/src/main/java/haus/nightmare/j3270/config/Settings.java @@ -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()); diff --git a/j3270/src/main/java/haus/nightmare/j3270/ft/FileTransferDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ft/FileTransferDialog.java index 589db47..f35c458 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ft/FileTransferDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ft/FileTransferDialog.java @@ -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) { diff --git a/j3270/src/main/java/haus/nightmare/j3270/storage/SavedHost.java b/j3270/src/main/java/haus/nightmare/j3270/storage/SavedHost.java index 28607d2..4d7ac7b 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/storage/SavedHost.java +++ b/j3270/src/main/java/haus/nightmare/j3270/storage/SavedHost.java @@ -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); } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectChoiceDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectChoiceDialog.java new file mode 100644 index 0000000..896106a --- /dev/null +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectChoiceDialog.java @@ -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(); + } +} diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java index ae144cb..cc2c840 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/ConnectDialog.java @@ -36,6 +36,7 @@ public class ConnectDialog extends JDialog { private JComboBox graphicsCombo; private JComboBox codePageCombo; private JTextField luField; + private JComboBox 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 getHostTypeCombo() { + return hostTypeCombo; + } + + public String getSelectedProfileId() { + return selectedProfileId; + } } diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/HostDirectoryDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/HostDirectoryDialog.java index be94a64..949174d 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/HostDirectoryDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/HostDirectoryDialog.java @@ -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 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 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 - client.getPS().SendKeys("[clear]"); - waitForKeyboardUnlock(client, 3000); + StringBuilder allPages = new StringBuilder(); + int maxPages = 500; - // 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 + " * *"; + // CMS Query: + // 1. Clear presentation space first to ensure clean state + client.getPS().SendKeys("[clear]"); + waitForKeyboardUnlock(client, 3000); + + // 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 { - cmsFilter = clean; + // Ready; encountered or timed out -> listing complete + break; } } - client.getPS().SendKeys("LISTFILE " + cmsFilter + " ( nohead alloc date[enter]"); + + // Automatically clear the listing from the terminal presentation space + // so the terminal screen is left clean for the user while keeping the dialog populated + if (client.getPS() != null) { + client.getPS().SendKeys("[clear]"); + waitForKeyboardUnlock(client, 3000); + } + } else { - if (pattern.isEmpty()) { - client.getPS().SendKeys("LISTCAT[enter]"); - } 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]"); - } - } - - // 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 (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"; + } } + } + + if (isIspf) { + // In ISPF, do NOT send [clear] because [clear] destroys the ISPF panel. + // Locate the command/option line (indicated by ===>) or home to first input field. + int arrowPos = initialScreen.indexOf("===>"); + if (arrowPos >= 0 && client.getScreenBuffer() != null) { + int inputAddr = client.getScreenBuffer().findNextUnprotected(arrowPos); + if (inputAddr > 0) { + client.getScreenBuffer().setCursorAddress(inputAddr); + client.getScreenBuffer().updateDisplaySnapshot(); + } else { + client.getPS().SendKeys("[home]"); + } + } else { + client.getPS().SendKeys("[home]"); + } + client.getPS().SendKeys("[erase_eof]"); + client.getPS().SendKeys(tsoCmd + "[enter]"); } else { - if (screen.contains("***")) { - hasMore = true; - client.getPS().SendKeys("[enter]"); - } - } - - if (hasMore) { - Thread.sleep(100); + // In native TSO (ISPF closed, line mode READY prompt) + client.getPS().SendKeys("[clear]"); waitForKeyboardUnlock(client, 3000); - } else { - break; + client.getPS().SendKeys(tsoCmd + "[enter]"); + } + + Thread.sleep(100); + + // Multi-page collection loop for TSO: + // 1. Wait for output to start appearing after sending command + long startCmd = System.currentTimeMillis(); + while (System.currentTimeMillis() - startCmd < 4000) { + waitForKeyboardUnlock(client, 200); + int r = client.getPS().getRows(); + int c = client.getPS().getCols(); + String s = client.getPS().copyString(0, 0, r - 1, c - 1); + String u = s.toUpperCase(); + if (u.contains("NONVSAM") || u.contains("CLUSTER") || u.contains("GDG") + || u.contains("ALIAS") || u.contains("IN-CAT") || u.contains("IDCAMS") + || u.contains("***") || u.contains("READY") || u.contains("NOT FOUND") + || u.contains("NO ENTRY") || s.contains(" --- ") + || u.contains("--RECFM-") || u.contains("--VOLUMES--")) { + break; + } + Thread.sleep(80); + } + + // 2. Multi-page collection + String prevScreen = ""; + for (int page = 0; page < maxPages; page++) { + waitForKeyboardUnlock(client, 300); + int r = client.getPS().getRows(); + int c = client.getPS().getCols(); + String screen = client.getPS().copyString(0, 0, r - 1, c - 1); + String upper = screen.toUpperCase(); + + if (screen.equals(prevScreen) || screen.trim().isEmpty()) { + break; + } + prevScreen = screen; + allPages.append(screen).append("\n"); + + if (upper.contains("***")) { + client.getPS().SendKeys("[enter]"); + Thread.sleep(100); + waitForKeyboardUnlock(client, 2000); + } else if (upper.contains("NONVSAM") || upper.contains("CLUSTER") || upper.contains("GDG") + || upper.contains("ALIAS") || upper.contains("IDCAMS") || screen.contains(" --- ") + || upper.contains("--RECFM-") || upper.contains("--VOLUMES--")) { + // If screen shows dataset data without ***, send [enter] once to see if *** or more output is triggered + client.getPS().SendKeys("[enter]"); + Thread.sleep(100); + waitForKeyboardUnlock(client, 1000); + + int r2 = client.getPS().getRows(); + int c2 = client.getPS().getCols(); + String s2 = client.getPS().copyString(0, 0, r2 - 1, c2 - 1); + if (s2.toUpperCase().contains("***")) { + prevScreen = s2; + allPages.append(s2).append("\n"); + client.getPS().SendKeys("[enter]"); + Thread.sleep(100); + waitForKeyboardUnlock(client, 2000); + continue; + } else { + if (!s2.equals(screen) && !s2.trim().isEmpty()) { + allPages.append(s2).append("\n"); + } + break; + } + } else { + break; + } + } + + // 3. Terminal clean-up: clear the list from the terminal presentation space + if (client.getPS() != null) { + if (isIspf) { + client.getPS().SendKeys("[enter]"); + Thread.sleep(150); + waitForKeyboardUnlock(client, 1500); + + int r = client.getPS().getRows(); + int c = client.getPS().getCols(); + String currentScr = client.getPS().copyString(0, 0, r - 1, c - 1); + if (currentScr.toUpperCase().contains("NONVSAM") || currentScr.toUpperCase().contains("IDCAMS") + || currentScr.contains(" --- ") || currentScr.toUpperCase().contains("--RECFM-") + || (client.getScreenBuffer() != null && !client.getScreenBuffer().isFormatted())) { + client.getPS().SendKeys("[clear]"); + waitForKeyboardUnlock(client, 2000); + } + } else { + client.getPS().SendKeys("[clear]"); + waitForKeyboardUnlock(client, 2000); + } } } + return allPages.toString(); } @@ -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 worker = new SwingWorker() { + @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 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 getAllEntries() { return allEntries; } @@ -485,4 +933,38 @@ public class HostDirectoryDialog extends JDialog { public List 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]; + } + } + } } diff --git a/j3270/src/test/java/haus/nightmare/j3270/ConnectionConflictTest.java b/j3270/src/test/java/haus/nightmare/j3270/ConnectionConflictTest.java new file mode 100644 index 0000000..f773f06 --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/ConnectionConflictTest.java @@ -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 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()); + } +} diff --git a/j3270/src/test/java/haus/nightmare/j3270/LoggingConfigurationTest.java b/j3270/src/test/java/haus/nightmare/j3270/LoggingConfigurationTest.java index af9f89b..c29eb98 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/LoggingConfigurationTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/LoggingConfigurationTest.java @@ -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); diff --git a/j3270/src/test/java/haus/nightmare/j3270/ft/ConnectionHostTypeConfigTest.java b/j3270/src/test/java/haus/nightmare/j3270/ft/ConnectionHostTypeConfigTest.java new file mode 100644 index 0000000..a43c0a1 --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/ft/ConnectionHostTypeConfigTest.java @@ -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 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()); + } + } +} diff --git a/j3270/src/test/java/haus/nightmare/j3270/ft/FileTransferSessionAndCmsTest.java b/j3270/src/test/java/haus/nightmare/j3270/ft/FileTransferSessionAndCmsTest.java index 7abaa2f..23a4080 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/ft/FileTransferSessionAndCmsTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/ft/FileTransferSessionAndCmsTest.java @@ -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")); + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java index 0c6fabf..3c644da 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java @@ -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"), diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java index b5e3cf2..4b6e4fd 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Telnet3270Client.java @@ -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; } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java index dfa6a8c..e5baf1b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTConfig.java @@ -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() : ""); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryParser.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryParser.java index 3c2352f..317bdc2 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryParser.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/CMSDirectoryParser.java @@ -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) { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryParser.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryParser.java index 67d482c..b437b9b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryParser.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/dir/TSODirectoryParser.java @@ -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 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 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; } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java index d73635e..971493e 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetConnection.java @@ -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(); - socket.shutdownOutput(); + 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; } - socket = null; - inputStream = null; - outputStream = null; log.info("Disconnected"); } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java index 4829586..1ac0c4a 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java @@ -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) { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/xfer3270/Xfer3270.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/xfer3270/Xfer3270.java index 99a36f0..fb7193b 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/xfer3270/Xfer3270.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/xfer3270/Xfer3270.java @@ -610,55 +610,242 @@ public class Xfer3270 implements FileTransferInterface { if (session != null && session.getPS() != null) { try { ECLPS ps = session.getPS(); - ps.SendKeys("[clear]"); - if (session.getOIA() != null) { - session.getOIA().waitForInput(3000); - } - if (hostType == VM_CMS) { - String filter = (hostQuery != null && !hostQuery.trim().isEmpty()) ? hostQuery.trim() : "* * *"; - ps.SendKeys("LISTFILE " + filter + " ( nohead alloc date[enter]"); - } else { - String filter = (hostQuery != null && !hostQuery.trim().isEmpty()) ? hostQuery.trim() : ""; - if (filter.isEmpty()) { - ps.SendKeys("LISTCAT[enter]"); - } else { - if (!filter.startsWith("'")) filter = "'" + filter + "'"; - ps.SendKeys("LISTCAT LEVEL(" + filter + ")[enter]"); - } - } - try { Thread.sleep(100); } catch (InterruptedException ignored) {} - if (session.getOIA() != null) { - session.getOIA().waitForInput(5000); - } + ScreenBuffer sb = ps.getScreenBuffer(); StringBuilder sbAll = new StringBuilder(); - for (int page = 0; page < 20; page++) { + int maxPages = 500; + + if (hostType == VM_CMS) { + ps.SendKeys("[clear]"); + if (session.getOIA() != null) { + session.getOIA().waitForInput(3000); + } + String filter; + if (hostQuery == null || hostQuery.trim().isEmpty()) { + filter = "* * A"; + } else { + String clean = hostQuery.replace("'", "").replace("\"", "").trim(); + if (clean.equals("* * *") || clean.equals("*")) { + filter = "* * A"; + } else if (clean.contains(".")) { + int dot = clean.indexOf('.'); + filter = clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " A"; + } else { + String[] parts = clean.split("\\s+"); + if (parts.length == 1) { + filter = parts[0] + " * A"; + } else if (parts.length == 2) { + filter = parts[0] + " " + parts[1] + " A"; + } else { + filter = parts[0] + " " + parts[1] + " " + parts[2]; + } + } + } + ps.SendKeys("LISTFILE " + filter + " ( nohead alloc date[enter]"); + try { Thread.sleep(100); } catch (InterruptedException ignored) {} + + for (int page = 0; page < maxPages; page++) { + long startWait = System.currentTimeMillis(); + long timeoutMs = 10000; + String screen = ""; + boolean isMore = false; + + while (System.currentTimeMillis() - startWait < timeoutMs) { + if (session.getOIA() != null) { + session.getOIA().waitForInput(500); + } + int rows = ps.getRows(); + int cols = ps.getCols(); + screen = ps.copyString(0, 0, rows - 1, cols - 1); + String upper = screen.toUpperCase(); + + if (upper.contains("MORE...") || upper.contains("HOLDING")) { + isMore = true; + break; + } + if (upper.contains("READY;") || upper.contains("READY(")) { + break; + } + try { Thread.sleep(150); } catch (InterruptedException ignored) {} + } + + sbAll.append(screen).append("\n"); + + if (isMore) { + ps.SendKeys("[clear]"); + try { Thread.sleep(100); } catch (InterruptedException ignored) {} + if (session.getOIA() != null) { + session.getOIA().waitForInput(3000); + } + } else { + break; + } + } + + // Automatically clear the listing from the terminal presentation space + ps.SendKeys("[clear]"); + if (session.getOIA() != null) { + try { Thread.sleep(100); } catch (InterruptedException ignored) {} + session.getOIA().waitForInput(3000); + } + + } else { + // TSO int rows = ps.getRows(); int cols = ps.getCols(); - String screen = ps.copyString(0, 0, rows - 1, cols - 1); - sbAll.append(screen).append("\n"); + 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"); - boolean hasMore = false; - if (hostType == VM_CMS) { - if (screen.contains("MORE...") || screen.contains("HOLDING")) { - hasMore = true; - ps.SendKeys("[enter]"); + 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); } - } else { - if (screen.contains("***")) { - hasMore = true; - ps.SendKeys("[enter]"); + 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 (hasMore) { - try { Thread.sleep(100); } catch (InterruptedException ignored) {} + 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 { - break; + 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 diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTConfigTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTConfigTest.java index 508c954..db99de1 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTConfigTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTConfigTest.java @@ -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 diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTProtocolHardeningPhase16Test.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTProtocolHardeningPhase16Test.java index 0b17a82..950021d 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTProtocolHardeningPhase16Test.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/FTProtocolHardeningPhase16Test.java @@ -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")); } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/dir/DirectoryParserTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/dir/DirectoryParserTest.java index 502737f..500f4f1 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/dir/DirectoryParserTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/ft/dir/DirectoryParserTest.java @@ -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 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 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 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 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 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 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 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()); + } } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270Server.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270Server.java index 51af59d..4bd1ad5 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270Server.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270Server.java @@ -54,6 +54,7 @@ public class Mock3270Server implements AutoCloseable { private int sequenceNumber = 0; private final List receivedRecords = new CopyOnWriteArrayList<>(); + private final List 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 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(); diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270ServerIntegrationTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270ServerIntegrationTest.java index 7d9e14b..f03dd57 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270ServerIntegrationTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/testutil/Mock3270ServerIntegrationTest.java @@ -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"); + } }