first commit
This commit is contained in:
commit
fc9e7b7598
16
.github/copilot-instructions.md
vendored
Normal file
16
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
# Workspace Instructions
|
||||
- Project: Java 11 SSH Terminal
|
||||
- Language: Java 11
|
||||
- UI Framework: Swing
|
||||
- SSH Library: JSch (mwiede fork)
|
||||
- Security: AES Encryption with Master Password
|
||||
|
||||
## Steps Completed
|
||||
- [x] Initialize project structure and pom.xml
|
||||
- [x] Implement Encryption Service
|
||||
- [x] Implement Connection Model and Storage
|
||||
- [x] Implement SSH Terminal UI component
|
||||
- [x] Implement Main UI with SplitPane and Tabs
|
||||
- [x] Add Connection Management (Add/Remove)
|
||||
- [x] Compile and Test
|
||||
- [x] README and Documentation
|
||||
1
.master_key
Normal file
1
.master_key
Normal file
@ -0,0 +1 @@
|
||||
ByBYvcAXjSA51UXD76DrxA==
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
26
README.md
Normal file
26
README.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Java SSH Terminal
|
||||
|
||||
A basic SSH terminal application built with Java 11 and Swing.
|
||||
|
||||
## Features
|
||||
- Connection management in the left side panel.
|
||||
- Multiple SSH terminals in a tabbed view.
|
||||
- Master password protection for stored credentials (AES encrypted).
|
||||
- Toolbar for quick actions (Add connection, Disconnect).
|
||||
|
||||
## Prerequisites
|
||||
- Java 11
|
||||
- Maven
|
||||
|
||||
## How to Run
|
||||
1. Build the project:
|
||||
```bash
|
||||
mvn clean package
|
||||
```
|
||||
2. Run the application:
|
||||
```bash
|
||||
java -jar target/java-ssh-terminal-1.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
## Note on Terminal Emulation
|
||||
This is a base application. The terminal component uses a basic `JTextArea` which does not support full ANSI escape sequences (colors, cursor positioning). For production use, consider integrating a library like `JediTerm`.
|
||||
1
connections.json
Normal file
1
connections.json
Normal file
@ -0,0 +1 @@
|
||||
[{"name":"server01","host":"server01","port":64022,"username":"kamma","encryptedPassword":"cT7BoqH9BONVY740RjdkFQ\u003d\u003d"},{"name":"kamma.cz","host":"kamma.cz","port":22,"username":"root","encryptedPassword":"DmErsKpA1Jf4az0cc2lrgA\u003d\u003d"}]
|
||||
78
pom.xml
Normal file
78
pom.xml
Normal file
@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>cz.kamma.term</groupId>
|
||||
<artifactId>java-ssh-terminal</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- SSH Library -->
|
||||
<dependency>
|
||||
<groupId>com.github.mwiede</groupId>
|
||||
<artifactId>jsch</artifactId>
|
||||
<version>0.2.17</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON for storing connections -->
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.10.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Better Terminal UI (Optional but good) -->
|
||||
<!-- We will use a standard JTextArea/JTextPane with ANSI support for simplicity or a lightweight lib -->
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>cz.kamma.term.App</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>cz.kamma.term.App</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
1
settings.json
Normal file
1
settings.json
Normal file
@ -0,0 +1 @@
|
||||
{"fontName":"Monospaced","fontSize":12,"backgroundColor":-16777216,"foregroundColor":-16711936}
|
||||
18
src/main/java/cz/kamma/term/App.java
Normal file
18
src/main/java/cz/kamma/term/App.java
Normal file
@ -0,0 +1,18 @@
|
||||
package cz.kamma.term;
|
||||
|
||||
import cz.kamma.term.ui.MainFrame;
|
||||
|
||||
import javax.swing.*;
|
||||
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
new MainFrame().setVisible(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
20
src/main/java/cz/kamma/term/model/AppSettings.java
Normal file
20
src/main/java/cz/kamma/term/model/AppSettings.java
Normal file
@ -0,0 +1,20 @@
|
||||
package cz.kamma.term.model;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
public class AppSettings {
|
||||
private String fontName = "Monospaced";
|
||||
private int fontSize = 14;
|
||||
private int backgroundColor = Color.BLACK.getRGB();
|
||||
private int foregroundColor = Color.GREEN.getRGB();
|
||||
|
||||
// Getters and Setters
|
||||
public String getFontName() { return fontName; }
|
||||
public void setFontName(String fontName) { this.fontName = fontName; }
|
||||
public int getFontSize() { return fontSize; }
|
||||
public void setFontSize(int fontSize) { this.fontSize = fontSize; }
|
||||
public int getBackgroundColor() { return backgroundColor; }
|
||||
public void setBackgroundColor(int backgroundColor) { this.backgroundColor = backgroundColor; }
|
||||
public int getForegroundColor() { return foregroundColor; }
|
||||
public void setForegroundColor(int foregroundColor) { this.foregroundColor = foregroundColor; }
|
||||
}
|
||||
34
src/main/java/cz/kamma/term/model/ConnectionInfo.java
Normal file
34
src/main/java/cz/kamma/term/model/ConnectionInfo.java
Normal file
@ -0,0 +1,34 @@
|
||||
package cz.kamma.term.model;
|
||||
|
||||
public class ConnectionInfo {
|
||||
private String name;
|
||||
private String host;
|
||||
private int port = 22;
|
||||
private String username;
|
||||
private String encryptedPassword;
|
||||
|
||||
public ConnectionInfo(String name, String host, int port, String username, String encryptedPassword) {
|
||||
this.name = name;
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.username = username;
|
||||
this.encryptedPassword = encryptedPassword;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getHost() { return host; }
|
||||
public void setHost(String host) { this.host = host; }
|
||||
public int getPort() { return port; }
|
||||
public void setPort(int port) { this.port = port; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getEncryptedPassword() { return encryptedPassword; }
|
||||
public void setEncryptedPassword(String encryptedPassword) { this.encryptedPassword = encryptedPassword; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name + " (" + host + ")";
|
||||
}
|
||||
}
|
||||
55
src/main/java/cz/kamma/term/service/ConnectionManager.java
Normal file
55
src/main/java/cz/kamma/term/service/ConnectionManager.java
Normal file
@ -0,0 +1,55 @@
|
||||
package cz.kamma.term.service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import cz.kamma.term.model.ConnectionInfo;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ConnectionManager {
|
||||
private static final String FILE_NAME = "connections.json";
|
||||
private final Gson gson = new Gson();
|
||||
private List<ConnectionInfo> connections = new ArrayList<>();
|
||||
|
||||
public ConnectionManager() {
|
||||
load();
|
||||
}
|
||||
|
||||
public List<ConnectionInfo> getConnections() {
|
||||
return connections;
|
||||
}
|
||||
|
||||
public void addConnection(ConnectionInfo connection) {
|
||||
connections.add(connection);
|
||||
save();
|
||||
}
|
||||
|
||||
public void removeConnection(ConnectionInfo connection) {
|
||||
connections.remove(connection);
|
||||
save();
|
||||
}
|
||||
|
||||
public void save() {
|
||||
try (Writer writer = new FileWriter(FILE_NAME)) {
|
||||
gson.toJson(connections, writer);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private void load() {
|
||||
File file = new File(FILE_NAME);
|
||||
if (file.exists()) {
|
||||
try (Reader reader = new FileReader(file)) {
|
||||
Type listType = new TypeToken<ArrayList<ConnectionInfo>>(){}.getType();
|
||||
connections = gson.fromJson(reader, listType);
|
||||
if (connections == null) connections = new ArrayList<>();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/main/java/cz/kamma/term/service/EncryptionService.java
Normal file
37
src/main/java/cz/kamma/term/service/EncryptionService.java
Normal file
@ -0,0 +1,37 @@
|
||||
package cz.kamma.term.service;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.spec.KeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
public class EncryptionService {
|
||||
private static final String ALGORITHM = "AES";
|
||||
private static final String SALT = "some_random_salt_123"; // Should be stored properly, but for base app this is okay
|
||||
|
||||
public static String encrypt(String password, String masterPassword) throws Exception {
|
||||
SecretKey key = deriveKey(masterPassword);
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
||||
byte[] encryptedBytes = cipher.doFinal(password.getBytes());
|
||||
return Base64.getEncoder().encodeToString(encryptedBytes);
|
||||
}
|
||||
|
||||
public static String decrypt(String encryptedPassword, String masterPassword) throws Exception {
|
||||
SecretKey key = deriveKey(masterPassword);
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, key);
|
||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedPassword));
|
||||
return new String(decryptedBytes);
|
||||
}
|
||||
|
||||
private static SecretKey deriveKey(String masterPassword) throws Exception {
|
||||
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
|
||||
KeySpec spec = new PBEKeySpec(masterPassword.toCharArray(), SALT.getBytes(), 65536, 128);
|
||||
SecretKey tmp = factory.generateSecret(spec);
|
||||
return new SecretKeySpec(tmp.getEncoded(), ALGORITHM);
|
||||
}
|
||||
}
|
||||
36
src/main/java/cz/kamma/term/service/HistoryManager.java
Normal file
36
src/main/java/cz/kamma/term/service/HistoryManager.java
Normal file
@ -0,0 +1,36 @@
|
||||
package cz.kamma.term.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class HistoryManager {
|
||||
private List<String> history = new ArrayList<>();
|
||||
|
||||
public HistoryManager() {
|
||||
// Don't load local history - will be loaded from server
|
||||
}
|
||||
|
||||
public void addRemoteCommand(String command) {
|
||||
if (command == null || command.trim().isEmpty()) return;
|
||||
|
||||
// Remove duplicate if it was the last command
|
||||
if (!history.isEmpty() && history.get(history.size() - 1).equals(command)) {
|
||||
return;
|
||||
}
|
||||
|
||||
history.add(command);
|
||||
// Don't save locally - server handles it via .history
|
||||
}
|
||||
|
||||
public void loadRemoteHistory(List<String> remoteCommands) {
|
||||
if (remoteCommands != null) {
|
||||
history.clear();
|
||||
history.addAll(remoteCommands);
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getHistory() {
|
||||
return history;
|
||||
}
|
||||
}
|
||||
42
src/main/java/cz/kamma/term/service/SettingsManager.java
Normal file
42
src/main/java/cz/kamma/term/service/SettingsManager.java
Normal file
@ -0,0 +1,42 @@
|
||||
package cz.kamma.term.service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import cz.kamma.term.model.AppSettings;
|
||||
|
||||
import java.io.*;
|
||||
|
||||
public class SettingsManager {
|
||||
private static final String FILE_NAME = "settings.json";
|
||||
private final Gson gson = new Gson();
|
||||
private AppSettings settings;
|
||||
|
||||
public SettingsManager() {
|
||||
load();
|
||||
}
|
||||
|
||||
public AppSettings getSettings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
try (Writer writer = new FileWriter(FILE_NAME)) {
|
||||
gson.toJson(settings, writer);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private void load() {
|
||||
File file = new File(FILE_NAME);
|
||||
if (file.exists()) {
|
||||
try (Reader reader = new FileReader(file)) {
|
||||
settings = gson.fromJson(reader, AppSettings.class);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (settings == null) {
|
||||
settings = new AppSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
313
src/main/java/cz/kamma/term/ui/MainFrame.java
Normal file
313
src/main/java/cz/kamma/term/ui/MainFrame.java
Normal file
@ -0,0 +1,313 @@
|
||||
package cz.kamma.term.ui;
|
||||
|
||||
import cz.kamma.term.model.ConnectionInfo;
|
||||
import cz.kamma.term.model.AppSettings;
|
||||
import cz.kamma.term.service.ConnectionManager;
|
||||
import cz.kamma.term.service.EncryptionService;
|
||||
import cz.kamma.term.service.SettingsManager;
|
||||
import cz.kamma.term.util.SystemIdUtil;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public class MainFrame extends JFrame {
|
||||
private static final String MASTER_PASS_FILE = ".master_key";
|
||||
private ConnectionManager connectionManager;
|
||||
private SettingsManager settingsManager;
|
||||
private JTabbedPane tabbedPane;
|
||||
private DefaultListModel<ConnectionInfo> listModel;
|
||||
private JList<ConnectionInfo> connectionList;
|
||||
private String masterPassword;
|
||||
|
||||
public MainFrame() {
|
||||
setTitle("Java SSH Terminal");
|
||||
setSize(1000, 700);
|
||||
setDefaultCloseOperation(EXIT_ON_CLOSE);
|
||||
setLocationRelativeTo(null);
|
||||
|
||||
connectionManager = new ConnectionManager();
|
||||
settingsManager = new SettingsManager();
|
||||
|
||||
if (!tryLoadMasterPassword()) {
|
||||
showMasterPasswordDialog();
|
||||
}
|
||||
|
||||
initUI();
|
||||
}
|
||||
|
||||
private boolean tryLoadMasterPassword() {
|
||||
File file = new File(MASTER_PASS_FILE);
|
||||
if (file.exists()) {
|
||||
try {
|
||||
String encryptedMaster = Files.readString(file.toPath()).trim();
|
||||
masterPassword = EncryptionService.decrypt(encryptedMaster, SystemIdUtil.getMachineId());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
file.delete(); // Corrupted or wrong machine
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void showMasterPasswordDialog() {
|
||||
JPasswordField passF = new JPasswordField();
|
||||
JCheckBox rememberCb = new JCheckBox("Remember on this computer");
|
||||
Object[] message = {
|
||||
"Enter Master Password:", passF,
|
||||
rememberCb
|
||||
};
|
||||
|
||||
int option = JOptionPane.showConfirmDialog(this, message, "Security", JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE);
|
||||
|
||||
if (option == JOptionPane.OK_OPTION) {
|
||||
masterPassword = new String(passF.getPassword());
|
||||
if (masterPassword.isEmpty()) {
|
||||
System.exit(0);
|
||||
}
|
||||
if (rememberCb.isSelected()) {
|
||||
saveMasterPassword();
|
||||
}
|
||||
} else {
|
||||
System.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveMasterPassword() {
|
||||
try {
|
||||
String encryptedMaster = EncryptionService.encrypt(masterPassword, SystemIdUtil.getMachineId());
|
||||
Files.writeString(new File(MASTER_PASS_FILE).toPath(), encryptedMaster);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private void initUI() {
|
||||
// Toolbar
|
||||
JToolBar toolBar = new JToolBar();
|
||||
JButton addButton = new JButton("Add Connection");
|
||||
JButton settingsButton = new JButton("Settings");
|
||||
toolBar.add(addButton);
|
||||
toolBar.add(settingsButton);
|
||||
add(toolBar, BorderLayout.NORTH);
|
||||
|
||||
// Left Panel (Connections)
|
||||
listModel = new DefaultListModel<>();
|
||||
connectionManager.getConnections().forEach(listModel::addElement);
|
||||
connectionList = new JList<>(listModel);
|
||||
connectionList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
|
||||
JScrollPane leftScroll = new JScrollPane(connectionList);
|
||||
leftScroll.setPreferredSize(new Dimension(200, 0));
|
||||
|
||||
// Main Panel (Tabs)
|
||||
tabbedPane = new JTabbedPane();
|
||||
|
||||
JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftScroll, tabbedPane);
|
||||
add(splitPane, BorderLayout.CENTER);
|
||||
|
||||
// Listeners
|
||||
addButton.addActionListener(e -> showAddConnectionDialog(null));
|
||||
settingsButton.addActionListener(e -> showSettingsDialog());
|
||||
|
||||
connectionList.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
checkPopup(e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
checkPopup(e);
|
||||
}
|
||||
|
||||
private void checkPopup(MouseEvent e) {
|
||||
if (e.isPopupTrigger()) {
|
||||
int index = connectionList.locationToIndex(e.getPoint());
|
||||
if (index != -1) {
|
||||
connectionList.setSelectedIndex(index);
|
||||
showContextMenu(e.getComponent(), e.getX(), e.getY(), connectionList.getSelectedValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent e) {
|
||||
if (e.getClickCount() == 2) {
|
||||
ConnectionInfo selected = connectionList.getSelectedValue();
|
||||
if (selected != null) {
|
||||
openTerminal(selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openTerminal(ConnectionInfo info) {
|
||||
TerminalPanel terminal = new TerminalPanel(info, masterPassword, settingsManager.getSettings());
|
||||
tabbedPane.addTab(info.getName(), terminal);
|
||||
int index = tabbedPane.indexOfComponent(terminal);
|
||||
|
||||
// Custom Tab Component with (X) button
|
||||
JPanel pnlTab = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0));
|
||||
pnlTab.setOpaque(false);
|
||||
JLabel lblTitle = new JLabel(info.getName());
|
||||
JButton btnClose = new JButton("x");
|
||||
|
||||
// Style the close button
|
||||
btnClose.setMargin(new Insets(0, 2, 0, 2));
|
||||
btnClose.setBorderPainted(false);
|
||||
btnClose.setContentAreaFilled(false);
|
||||
btnClose.setFocusable(false);
|
||||
btnClose.setFont(new Font("Monospaced", Font.BOLD, 12));
|
||||
|
||||
btnClose.addActionListener(e -> {
|
||||
terminal.disconnect();
|
||||
tabbedPane.remove(terminal);
|
||||
});
|
||||
|
||||
pnlTab.add(lblTitle);
|
||||
pnlTab.add(btnClose);
|
||||
|
||||
tabbedPane.setTabComponentAt(index, pnlTab);
|
||||
tabbedPane.setSelectedIndex(index);
|
||||
}
|
||||
|
||||
private void showContextMenu(Component invoker, int x, int y, ConnectionInfo selected) {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
|
||||
JMenuItem connectItem = new JMenuItem("Connect in new tab");
|
||||
JMenuItem editItem = new JMenuItem("Edit");
|
||||
JMenuItem deleteItem = new JMenuItem("Delete");
|
||||
|
||||
connectItem.addActionListener(e -> openTerminal(selected));
|
||||
editItem.addActionListener(e -> showAddConnectionDialog(selected));
|
||||
deleteItem.addActionListener(e -> {
|
||||
int confirm = JOptionPane.showConfirmDialog(this, "Delete " + selected.getName() + "?", "Confirm", JOptionPane.YES_NO_OPTION);
|
||||
if (confirm == JOptionPane.YES_OPTION) {
|
||||
connectionManager.removeConnection(selected);
|
||||
listModel.removeElement(selected);
|
||||
}
|
||||
});
|
||||
|
||||
menu.add(connectItem);
|
||||
menu.addSeparator();
|
||||
menu.add(editItem);
|
||||
menu.add(deleteItem);
|
||||
menu.show(invoker, x, y);
|
||||
}
|
||||
|
||||
private void showAddConnectionDialog(ConnectionInfo existing) {
|
||||
JTextField nameF = new JTextField(existing != null ? existing.getName() : "");
|
||||
JTextField hostF = new JTextField(existing != null ? existing.getHost() : "");
|
||||
JTextField portF = new JTextField(existing != null ? String.valueOf(existing.getPort()) : "22");
|
||||
JTextField userF = new JTextField(existing != null ? existing.getUsername() : "");
|
||||
JPasswordField passF = new JPasswordField();
|
||||
|
||||
Object[] message = {
|
||||
"Name:", nameF,
|
||||
"Host:", hostF,
|
||||
"Port:", portF,
|
||||
"User:", userF,
|
||||
"Pass (leave empty to keep existing):", passF
|
||||
};
|
||||
|
||||
int option = JOptionPane.showConfirmDialog(null, message,
|
||||
existing == null ? "Add New Connection" : "Edit Connection", JOptionPane.OK_CANCEL_OPTION);
|
||||
|
||||
if (option == JOptionPane.OK_OPTION) {
|
||||
try {
|
||||
String encryptedPass;
|
||||
if (passF.getPassword().length > 0) {
|
||||
encryptedPass = EncryptionService.encrypt(new String(passF.getPassword()), masterPassword);
|
||||
} else if (existing != null) {
|
||||
encryptedPass = existing.getEncryptedPassword();
|
||||
} else {
|
||||
throw new Exception("Password is required for new connection.");
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
existing.setName(nameF.getText());
|
||||
existing.setHost(hostF.getText());
|
||||
existing.setPort(Integer.parseInt(portF.getText()));
|
||||
existing.setUsername(userF.getText());
|
||||
existing.setEncryptedPassword(encryptedPass);
|
||||
connectionManager.save();
|
||||
connectionList.repaint();
|
||||
} else {
|
||||
ConnectionInfo info = new ConnectionInfo(
|
||||
nameF.getText(),
|
||||
hostF.getText(),
|
||||
Integer.parseInt(portF.getText()),
|
||||
userF.getText(),
|
||||
encryptedPass
|
||||
);
|
||||
connectionManager.addConnection(info);
|
||||
listModel.addElement(info);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
JOptionPane.showMessageDialog(this, "Error: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void showSettingsDialog() {
|
||||
AppSettings settings = settingsManager.getSettings();
|
||||
|
||||
String[] fonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames();
|
||||
JComboBox<String> fontCmbo = new JComboBox<>(fonts);
|
||||
fontCmbo.setSelectedItem(settings.getFontName());
|
||||
|
||||
JSpinner sizeSpin = new JSpinner(new SpinnerNumberModel(settings.getFontSize(), 8, 72, 1));
|
||||
|
||||
JButton bgBtn = new JButton("Choose Background");
|
||||
bgBtn.setBackground(new Color(settings.getBackgroundColor()));
|
||||
final int[] bgArr = {settings.getBackgroundColor()};
|
||||
bgBtn.addActionListener(e -> {
|
||||
Color c = JColorChooser.showDialog(this, "Select Background", new Color(bgArr[0]));
|
||||
if (c != null) {
|
||||
bgArr[0] = c.getRGB();
|
||||
bgBtn.setBackground(c);
|
||||
}
|
||||
});
|
||||
|
||||
JButton fgBtn = new JButton("Choose Foreground");
|
||||
fgBtn.setBackground(new Color(settings.getForegroundColor()));
|
||||
final int[] fgArr = {settings.getForegroundColor()};
|
||||
fgBtn.addActionListener(e -> {
|
||||
Color c = JColorChooser.showDialog(this, "Select Foreground", new Color(fgArr[0]));
|
||||
if (c != null) {
|
||||
fgArr[0] = c.getRGB();
|
||||
fgBtn.setBackground(c);
|
||||
}
|
||||
});
|
||||
|
||||
Object[] message = {
|
||||
"Font:", fontCmbo,
|
||||
"Size:", sizeSpin,
|
||||
"Background:", bgBtn,
|
||||
"Foreground:", fgBtn
|
||||
};
|
||||
|
||||
int option = JOptionPane.showConfirmDialog(this, message, "Settings", JOptionPane.OK_CANCEL_OPTION);
|
||||
if (option == JOptionPane.OK_OPTION) {
|
||||
settings.setFontName((String) fontCmbo.getSelectedItem());
|
||||
settings.setFontSize((Integer) sizeSpin.getValue());
|
||||
settings.setBackgroundColor(bgArr[0]);
|
||||
settings.setForegroundColor(fgArr[0]);
|
||||
|
||||
settingsManager.save();
|
||||
|
||||
// Apply to all tabs
|
||||
for (int i = 0; i < tabbedPane.getTabCount(); i++) {
|
||||
Component comp = tabbedPane.getComponentAt(i);
|
||||
if (comp instanceof TerminalPanel) {
|
||||
((TerminalPanel) comp).updateSettings(settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
347
src/main/java/cz/kamma/term/ui/TerminalPanel.java
Normal file
347
src/main/java/cz/kamma/term/ui/TerminalPanel.java
Normal file
@ -0,0 +1,347 @@
|
||||
package cz.kamma.term.ui;
|
||||
|
||||
import com.jcraft.jsch.*;
|
||||
import cz.kamma.term.model.ConnectionInfo;
|
||||
import cz.kamma.term.model.AppSettings;
|
||||
import cz.kamma.term.service.EncryptionService;
|
||||
import cz.kamma.term.service.HistoryManager;
|
||||
import cz.kamma.term.util.AnsiHelper;
|
||||
|
||||
import javax.swing.*;
|
||||
import javax.swing.text.DefaultCaret;
|
||||
import javax.swing.text.NavigationFilter;
|
||||
import javax.swing.text.Position;
|
||||
import javax.swing.text.StyledDocument;
|
||||
import java.awt.*;
|
||||
import java.awt.event.KeyAdapter;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class TerminalPanel extends JPanel {
|
||||
private JTextPane textPane;
|
||||
private Session session;
|
||||
private ChannelShell channel;
|
||||
private PrintStream out;
|
||||
private String masterPassword;
|
||||
private ConnectionInfo connectionInfo;
|
||||
private AppSettings settings;
|
||||
private HistoryManager historyManager;
|
||||
private StringBuilder commandLineBuffer = new StringBuilder();
|
||||
private int historyIndex = -1;
|
||||
private int commandStartPosition = 0;
|
||||
|
||||
public TerminalPanel(ConnectionInfo info, String masterPassword, AppSettings settings) {
|
||||
this.connectionInfo = info;
|
||||
this.masterPassword = masterPassword;
|
||||
this.settings = settings;
|
||||
this.historyManager = new HistoryManager(); // Initialize without loading any history
|
||||
setLayout(new BorderLayout());
|
||||
|
||||
textPane = new JTextPane();
|
||||
textPane.setFocusTraversalKeysEnabled(false);
|
||||
applySettings();
|
||||
textPane.setCaretColor(Color.WHITE);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
DefaultCaret caret = (DefaultCaret) textPane.getCaret();
|
||||
caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE);
|
||||
|
||||
// Prevent moving caret into protected prompt area
|
||||
textPane.setNavigationFilter(new NavigationFilter() {
|
||||
@Override
|
||||
public void setDot(FilterBypass fb, int dot, Position.Bias bias) {
|
||||
if (dot < commandStartPosition) dot = commandStartPosition;
|
||||
fb.setDot(dot, bias);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveDot(FilterBypass fb, int dot, Position.Bias bias) {
|
||||
if (dot < commandStartPosition) dot = commandStartPosition;
|
||||
fb.moveDot(dot, bias);
|
||||
}
|
||||
});
|
||||
|
||||
JScrollPane scrollPane = new JScrollPane(textPane);
|
||||
add(scrollPane, BorderLayout.CENTER);
|
||||
|
||||
textPane.addKeyListener(new KeyAdapter() {
|
||||
@Override
|
||||
public void keyPressed(KeyEvent e) {
|
||||
if (out == null) return;
|
||||
|
||||
// UP arrow - previous command in history
|
||||
if (e.getKeyCode() == KeyEvent.VK_UP) {
|
||||
if (historyManager.getHistory().isEmpty()) {
|
||||
e.consume();
|
||||
return;
|
||||
}
|
||||
if (historyIndex < historyManager.getHistory().size() - 1) {
|
||||
historyIndex++;
|
||||
String command = historyManager.getHistory().get(historyManager.getHistory().size() - 1 - historyIndex);
|
||||
displayCommand(command);
|
||||
}
|
||||
e.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
// DOWN arrow - next command in history
|
||||
if (e.getKeyCode() == KeyEvent.VK_DOWN) {
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
String command = historyManager.getHistory().get(historyManager.getHistory().size() - 1 - historyIndex);
|
||||
displayCommand(command);
|
||||
} else if (historyIndex == 0) {
|
||||
historyIndex = -1;
|
||||
clearCurrentCommand();
|
||||
}
|
||||
e.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
String seq = null;
|
||||
switch (e.getKeyCode()) {
|
||||
case KeyEvent.VK_TAB:
|
||||
out.print("\t");
|
||||
out.flush();
|
||||
e.consume();
|
||||
return;
|
||||
case KeyEvent.VK_RIGHT: seq = "\u001B[C"; break;
|
||||
case KeyEvent.VK_LEFT: seq = "\u001B[D"; break;
|
||||
case KeyEvent.VK_HOME: seq = "\u001B[1~"; break;
|
||||
case KeyEvent.VK_END: seq = "\u001B[4~"; break;
|
||||
case KeyEvent.VK_DELETE:
|
||||
// Protect command prompt - only allow delete if there's command content
|
||||
if (canDeleteCharacter()) {
|
||||
seq = "\u001B[3~";
|
||||
}
|
||||
e.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (seq != null) {
|
||||
out.print(seq);
|
||||
out.flush();
|
||||
e.consume();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void keyTyped(KeyEvent e) {
|
||||
if (out != null) {
|
||||
char c = e.getKeyChar();
|
||||
|
||||
// Handle backspace - protect command prompt
|
||||
if (c == '\b') {
|
||||
if (canDeleteCharacter()) {
|
||||
out.print(c);
|
||||
out.flush();
|
||||
if (commandLineBuffer.length() > 0) {
|
||||
commandLineBuffer.setLength(commandLineBuffer.length() - 1);
|
||||
}
|
||||
}
|
||||
// Don't send backspace if it would delete the prompt
|
||||
e.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send all other characters to server
|
||||
out.print(c);
|
||||
out.flush();
|
||||
|
||||
if (c == '\r' || c == '\n') {
|
||||
// Command sent to server, server will save it to .history
|
||||
// Add to local memory only for history browsing
|
||||
String cmd = commandLineBuffer.toString();
|
||||
if (!cmd.trim().isEmpty()) {
|
||||
historyManager.addRemoteCommand(cmd);
|
||||
}
|
||||
commandLineBuffer.setLength(0);
|
||||
historyIndex = -1;
|
||||
} else if (!Character.isISOControl(c)) {
|
||||
commandLineBuffer.append(c);
|
||||
}
|
||||
}
|
||||
e.consume();
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
private void connect() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
JSch jsch = new JSch();
|
||||
String password = EncryptionService.decrypt(connectionInfo.getEncryptedPassword(), masterPassword);
|
||||
|
||||
session = jsch.getSession(connectionInfo.getUsername(), connectionInfo.getHost(), connectionInfo.getPort());
|
||||
session.setPassword(password);
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
session.connect();
|
||||
|
||||
channel = (ChannelShell) session.openChannel("shell");
|
||||
channel.setPtyType("xterm");
|
||||
|
||||
// Set up input from our pipe
|
||||
PipedInputStream pin = new PipedInputStream(65536);
|
||||
PipedOutputStream pout = new PipedOutputStream(pin);
|
||||
out = new PrintStream(pout, true);
|
||||
channel.setInputStream(pin);
|
||||
|
||||
// Connect first, then read output from channel
|
||||
channel.connect();
|
||||
|
||||
// Get the output stream from the channel and read it
|
||||
InputStream channelInput = channel.getInputStream();
|
||||
|
||||
// Read from channel in a separate thread to ensure we get all output
|
||||
new Thread(() -> {
|
||||
try {
|
||||
byte[] buffer = new byte[1024];
|
||||
int len;
|
||||
while ((len = channelInput.read(buffer)) > 0) {
|
||||
String output = new String(buffer, 0, len);
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
AnsiHelper.appendAnsi(textPane, output, commandStartPosition);
|
||||
// Update prompt boundary only when not typing/navigating history
|
||||
if (commandLineBuffer.length() == 0) {
|
||||
commandStartPosition = textPane.getDocument().getLength();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Connection closed
|
||||
}
|
||||
}).start();
|
||||
|
||||
// Load command history from remote server in a separate thread
|
||||
// Delay to ensure shell initializes first
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(500); // Shorter delay - 500ms should be enough
|
||||
loadRemoteHistory();
|
||||
} catch (InterruptedException ignored) {}
|
||||
}).start();
|
||||
|
||||
// Focus on textPane after successful connection
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
textPane.requestFocusInWindow();
|
||||
textPane.setCaretPosition(textPane.getDocument().getLength());
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
AnsiHelper.appendAnsi(textPane, "\nConnection failed: " + e.getMessage(), 0);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadRemoteHistory() {
|
||||
try {
|
||||
ChannelSftp sftpChannel = (ChannelSftp) session.openChannel("sftp");
|
||||
sftpChannel.connect();
|
||||
|
||||
try {
|
||||
// Try to get home directory first
|
||||
String homeDir = sftpChannel.pwd();
|
||||
|
||||
// Try .bash_history first (bash shell), then .history (zsh/generic)
|
||||
InputStream is = null;
|
||||
|
||||
try {
|
||||
is = sftpChannel.get(homeDir + "/.bash_history");
|
||||
} catch (com.jcraft.jsch.SftpException e1) {
|
||||
try {
|
||||
is = sftpChannel.get(homeDir + "/.history");
|
||||
} catch (com.jcraft.jsch.SftpException e2) {
|
||||
throw e1; // throw original exception
|
||||
}
|
||||
}
|
||||
|
||||
String historyContent = new String(is.readAllBytes());
|
||||
is.close();
|
||||
|
||||
List<String> commands = new ArrayList<>();
|
||||
for (String line : historyContent.split("\n")) {
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
commands.add(trimmed);
|
||||
}
|
||||
}
|
||||
historyManager.loadRemoteHistory(commands);
|
||||
} catch (com.jcraft.jsch.SftpException sftpEx) {
|
||||
if (sftpEx.id == 2) {
|
||||
// File not found - that's okay, history is empty
|
||||
historyManager.loadRemoteHistory(new ArrayList<>());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ignore errors loading history
|
||||
}
|
||||
|
||||
sftpChannel.disconnect();
|
||||
} catch (Exception e) {
|
||||
// SFTP not available, continue without history
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (channel != null && channel.isConnected()) channel.disconnect();
|
||||
if (session != null && session.isConnected()) session.disconnect();
|
||||
}
|
||||
|
||||
public void updateSettings(AppSettings newSettings) {
|
||||
this.settings = newSettings;
|
||||
applySettings();
|
||||
}
|
||||
|
||||
private void applySettings() {
|
||||
textPane.setBackground(new Color(settings.getBackgroundColor()));
|
||||
textPane.setForeground(new Color(settings.getForegroundColor()));
|
||||
textPane.setFont(new Font(settings.getFontName(), Font.PLAIN, settings.getFontSize()));
|
||||
}
|
||||
|
||||
private void displayCommand(String command) {
|
||||
try {
|
||||
StyledDocument doc = (StyledDocument) textPane.getDocument();
|
||||
int docLength = doc.getLength();
|
||||
|
||||
// Remove everything after the prompt boundary
|
||||
if (docLength > commandStartPosition) {
|
||||
doc.remove(commandStartPosition, docLength - commandStartPosition);
|
||||
}
|
||||
|
||||
// Put the history command into the buffer so it can be edited
|
||||
commandLineBuffer.setLength(0);
|
||||
commandLineBuffer.append(command);
|
||||
|
||||
// Add command from history to display
|
||||
AnsiHelper.appendAnsi(textPane, command, commandStartPosition);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private void clearCurrentCommand() {
|
||||
try {
|
||||
StyledDocument doc = (StyledDocument) textPane.getDocument();
|
||||
int docLength = doc.getLength();
|
||||
|
||||
// Remove everything after the prompt boundary
|
||||
if (docLength > commandStartPosition) {
|
||||
doc.remove(commandStartPosition, docLength - commandStartPosition);
|
||||
}
|
||||
commandLineBuffer.setLength(0);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canDeleteCharacter() {
|
||||
// Allow deletion only if the caret is beyond the protected prompt boundary
|
||||
// Standard backspace deletes character at caret - 1.
|
||||
return textPane.getCaretPosition() > commandStartPosition;
|
||||
}
|
||||
}
|
||||
311
src/main/java/cz/kamma/term/util/AnsiHelper.java
Normal file
311
src/main/java/cz/kamma/term/util/AnsiHelper.java
Normal file
@ -0,0 +1,311 @@
|
||||
package cz.kamma.term.util;
|
||||
|
||||
import javax.swing.*;
|
||||
import javax.swing.text.*;
|
||||
import java.awt.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class AnsiHelper {
|
||||
// Comprehensive ANSI escape sequence pattern
|
||||
// Matches: ESC [ params command, ESC ] OSC sequence, and other escape sequences
|
||||
private static final Pattern ANSI_PATTERN = Pattern.compile(
|
||||
"\u001B" + // ESC character
|
||||
"(?:" +
|
||||
"\\[([?0-9;]*)([@-~])|" + // CSI sequences: ESC [ params char
|
||||
"\\].*?(?:\u0007|\u001B\\\\)|" + // OSC sequences
|
||||
"[()][B0UK]" + // Charset selection
|
||||
")"
|
||||
);
|
||||
|
||||
public static void appendAnsi(JTextPane textPane, String text, int protectedLength) {
|
||||
if (text == null || text.isEmpty()) return;
|
||||
|
||||
StyledDocument doc = textPane.getStyledDocument();
|
||||
MutableAttributeSet currentAttrs = new SimpleAttributeSet();
|
||||
StyleConstants.setForeground(currentAttrs, new Color(0, 200, 0)); // Default green
|
||||
StyleConstants.setBackground(currentAttrs, Color.BLACK);
|
||||
|
||||
Matcher matcher = ANSI_PATTERN.matcher(text);
|
||||
int lastEnd = 0;
|
||||
|
||||
try {
|
||||
while (matcher.find()) {
|
||||
// Insert text before the sequence
|
||||
if (matcher.start() > lastEnd) {
|
||||
processPlainText(doc, text.substring(lastEnd, matcher.start()), currentAttrs, protectedLength);
|
||||
}
|
||||
|
||||
String params = matcher.group(1);
|
||||
String command = matcher.group(2);
|
||||
|
||||
if (command != null && !command.isEmpty()) {
|
||||
processCsiSequence(command, params, currentAttrs, doc);
|
||||
}
|
||||
|
||||
lastEnd = matcher.end();
|
||||
}
|
||||
|
||||
// Insert remaining text
|
||||
if (lastEnd < text.length()) {
|
||||
processPlainText(doc, text.substring(lastEnd), currentAttrs, protectedLength);
|
||||
}
|
||||
|
||||
textPane.setCaretPosition(doc.getLength());
|
||||
} catch (BadLocationException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private static void processPlainText(StyledDocument doc, String text, AttributeSet attrs, int protectedLength) throws BadLocationException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
|
||||
if (c == '\r') {
|
||||
// Check if next char is \n (standard Windows/SSH line ending)
|
||||
if (i + 1 < text.length() && text.charAt(i + 1) == '\n') {
|
||||
// \r\n sequence - treat as single newline
|
||||
if (sb.length() > 0) {
|
||||
sb.append('\n');
|
||||
} else {
|
||||
doc.insertString(doc.getLength(), "\n", attrs);
|
||||
}
|
||||
i++; // Skip the \n
|
||||
} else {
|
||||
// Standalone \r - carriage return (move to beginning of line)
|
||||
if (sb.length() > 0) {
|
||||
doc.insertString(doc.getLength(), sb.toString(), attrs);
|
||||
sb.setLength(0);
|
||||
}
|
||||
String content = doc.getText(0, doc.getLength());
|
||||
int lastNewline = content.lastIndexOf('\n');
|
||||
int startToDelete = lastNewline == -1 ? 0 : lastNewline + 1;
|
||||
|
||||
// Respect protected boundary
|
||||
if (startToDelete < protectedLength) {
|
||||
startToDelete = protectedLength;
|
||||
}
|
||||
|
||||
int endToDelete = doc.getLength();
|
||||
if (endToDelete > startToDelete) {
|
||||
doc.remove(startToDelete, endToDelete - startToDelete);
|
||||
}
|
||||
}
|
||||
} else if (c == '\b') {
|
||||
// Backspace: delete previous character
|
||||
if (sb.length() > 0) {
|
||||
doc.insertString(doc.getLength(), sb.toString(), attrs);
|
||||
sb.setLength(0);
|
||||
}
|
||||
// NEVER delete before or at protectedLength
|
||||
if (doc.getLength() > protectedLength) {
|
||||
doc.remove(doc.getLength() - 1, 1);
|
||||
}
|
||||
} else if (c == '\t') {
|
||||
// Tab: convert to spaces (4 spaces)
|
||||
sb.append(" ");
|
||||
} else if (!Character.isISOControl(c)) {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
doc.insertString(doc.getLength(), sb.toString(), attrs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void processCsiSequence(String command, String params, MutableAttributeSet attrs, StyledDocument doc) {
|
||||
if (params == null) params = "";
|
||||
|
||||
switch (command) {
|
||||
case "m": // SGR - Select Graphic Rendition (colors, styles)
|
||||
processSgr(params, attrs);
|
||||
break;
|
||||
case "K": // EL - Erase in Line
|
||||
eraseInLine(doc, params);
|
||||
break;
|
||||
case "J": // ED - Erase in Display
|
||||
eraseInDisplay(doc, params);
|
||||
break;
|
||||
case "H":
|
||||
case "f": // CUP/HVP - Cursor Position
|
||||
// Ignore - JTextPane doesn't support cursor repositioning
|
||||
break;
|
||||
case "A": // CUU - Cursor Up
|
||||
case "B": // CUD - Cursor Down
|
||||
case "C": // CUF - Cursor Forward (Right)
|
||||
case "D": // CUB - Cursor Backward (Left)
|
||||
case "E": // CNL - Cursor Next Line
|
||||
case "F": // CPL - Cursor Previous Line
|
||||
case "G": // CHA - Cursor Horizontal Absolute
|
||||
// Ignore cursor movement - JTextPane handles cursor differently
|
||||
break;
|
||||
case "s": // SCP - Save Cursor Position
|
||||
case "u": // RCP - Restore Cursor Position
|
||||
// Ignore - not needed for output display
|
||||
break;
|
||||
case "h": // SM - Set Mode
|
||||
case "l": // RM - Reset Mode
|
||||
// Handle mode settings (like cursor visibility, alt screen)
|
||||
if (params.contains("25")) {
|
||||
// ?25h = show cursor, ?25l = hide cursor - we'll just display normally
|
||||
}
|
||||
break;
|
||||
case "c": // DA - Device Attributes
|
||||
case "n": // DSR - Device Status Report
|
||||
// Device info requests - ignore
|
||||
break;
|
||||
case "1" :
|
||||
case "2" :
|
||||
case "3" :
|
||||
case "4" :
|
||||
// These look like incomplete sequences, ignore
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void processSgr(String params, MutableAttributeSet attrs) {
|
||||
if (params == null || params.isEmpty()) {
|
||||
resetAttributes(attrs);
|
||||
return;
|
||||
}
|
||||
|
||||
String[] codes = params.split(";");
|
||||
for (String code : codes) {
|
||||
if (code.isEmpty()) continue;
|
||||
|
||||
try {
|
||||
int c = Integer.parseInt(code);
|
||||
|
||||
if (c == 0) {
|
||||
// Reset all attributes
|
||||
resetAttributes(attrs);
|
||||
} else if (c == 1) {
|
||||
// Bold
|
||||
StyleConstants.setBold(attrs, true);
|
||||
} else if (c == 2) {
|
||||
// Dim (reduced intensity)
|
||||
StyleConstants.setBold(attrs, false);
|
||||
} else if (c == 3) {
|
||||
// Italic
|
||||
StyleConstants.setItalic(attrs, true);
|
||||
} else if (c == 4) {
|
||||
// Underline
|
||||
StyleConstants.setUnderline(attrs, true);
|
||||
} else if (c == 5) {
|
||||
// Blink (we'll just show it normally)
|
||||
StyleConstants.setBold(attrs, true);
|
||||
} else if (c == 7) {
|
||||
// Reverse video
|
||||
Color fg = (Color) attrs.getAttribute(StyleConstants.Foreground);
|
||||
Color bg = (Color) attrs.getAttribute(StyleConstants.Background);
|
||||
if (fg != null && bg != null) {
|
||||
StyleConstants.setForeground(attrs, bg);
|
||||
StyleConstants.setBackground(attrs, fg);
|
||||
}
|
||||
} else if (c == 22) {
|
||||
// Normal intensity
|
||||
StyleConstants.setBold(attrs, false);
|
||||
} else if (c == 23) {
|
||||
// No italic
|
||||
StyleConstants.setItalic(attrs, false);
|
||||
} else if (c == 24) {
|
||||
// No underline
|
||||
StyleConstants.setUnderline(attrs, false);
|
||||
} else if (c >= 30 && c <= 37) {
|
||||
// Foreground color (standard)
|
||||
StyleConstants.setForeground(attrs, getAnsiColor(c - 30, false));
|
||||
} else if (c == 39) {
|
||||
// Default foreground
|
||||
StyleConstants.setForeground(attrs, new Color(0, 200, 0));
|
||||
} else if (c >= 40 && c <= 47) {
|
||||
// Background color (standard)
|
||||
StyleConstants.setBackground(attrs, getAnsiColor(c - 40, false));
|
||||
} else if (c == 49) {
|
||||
// Default background
|
||||
StyleConstants.setBackground(attrs, Color.BLACK);
|
||||
} else if (c >= 90 && c <= 97) {
|
||||
// Foreground color (bright)
|
||||
StyleConstants.setForeground(attrs, getAnsiColor(c - 90, true));
|
||||
} else if (c >= 100 && c <= 107) {
|
||||
// Background color (bright)
|
||||
StyleConstants.setBackground(attrs, getAnsiColor(c - 100, true));
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Ignore invalid codes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resetAttributes(MutableAttributeSet attrs) {
|
||||
StyleConstants.setForeground(attrs, new Color(0, 200, 0)); // Default green
|
||||
StyleConstants.setBackground(attrs, Color.BLACK);
|
||||
StyleConstants.setBold(attrs, false);
|
||||
StyleConstants.setItalic(attrs, false);
|
||||
StyleConstants.setUnderline(attrs, false);
|
||||
}
|
||||
|
||||
private static void eraseInLine(StyledDocument doc, String param) {
|
||||
try {
|
||||
// EL 0 = erase from cursor to end of line (default)
|
||||
// EL 1 = erase from start of line to cursor
|
||||
// EL 2 = erase entire line
|
||||
|
||||
String content = doc.getText(0, doc.getLength());
|
||||
int lastNewline = content.lastIndexOf('\n');
|
||||
int lineStart = lastNewline == -1 ? 0 : lastNewline + 1;
|
||||
int lineEnd = doc.getLength();
|
||||
|
||||
if ("2".equals(param)) {
|
||||
// Erase entire line
|
||||
doc.remove(lineStart, lineEnd - lineStart);
|
||||
} else {
|
||||
// Default: erase from cursor to end of line
|
||||
doc.remove(lineStart, lineEnd - lineStart);
|
||||
}
|
||||
} catch (BadLocationException ignored) {}
|
||||
}
|
||||
|
||||
private static void eraseInDisplay(StyledDocument doc, String param) {
|
||||
try {
|
||||
// ED 0 = erase from cursor to end of display (default)
|
||||
// ED 1 = erase from start of display to cursor
|
||||
// ED 2 = erase entire display
|
||||
// ED 3 = erase scrollback buffer
|
||||
|
||||
if ("2".equals(param) || "3".equals(param)) {
|
||||
// For terminal apps: don't clear entire display,
|
||||
// just leave the content as is
|
||||
// Clearing would remove important command output
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
private static Color getAnsiColor(int index, boolean bright) {
|
||||
if (bright) {
|
||||
switch (index) {
|
||||
case 0: return new Color(128, 128, 128); // Bright Black (Gray)
|
||||
case 1: return new Color(255, 85, 85); // Bright Red
|
||||
case 2: return new Color(85, 255, 85); // Bright Green
|
||||
case 3: return new Color(255, 255, 85); // Bright Yellow
|
||||
case 4: return new Color(85, 85, 255); // Bright Blue
|
||||
case 5: return new Color(255, 85, 255); // Bright Magenta
|
||||
case 6: return new Color(85, 255, 255); // Bright Cyan
|
||||
case 7: return new Color(255, 255, 255); // Bright White
|
||||
default: return Color.WHITE;
|
||||
}
|
||||
} else {
|
||||
switch (index) {
|
||||
case 0: return new Color(0, 0, 0); // Black
|
||||
case 1: return new Color(170, 0, 0); // Red
|
||||
case 2: return new Color(0, 170, 0); // Green
|
||||
case 3: return new Color(170, 85, 0); // Yellow
|
||||
case 4: return new Color(0, 0, 170); // Blue
|
||||
case 5: return new Color(170, 0, 170); // Magenta
|
||||
case 6: return new Color(0, 170, 170); // Cyan
|
||||
case 7: return new Color(170, 170, 170); // White
|
||||
default: return new Color(0, 200, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/main/java/cz/kamma/term/util/SystemIdUtil.java
Normal file
30
src/main/java/cz/kamma/term/util/SystemIdUtil.java
Normal file
@ -0,0 +1,30 @@
|
||||
package cz.kamma.term.util;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.util.Enumeration;
|
||||
|
||||
public class SystemIdUtil {
|
||||
public static String getMachineId() {
|
||||
try {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(System.getProperty("os.name"));
|
||||
sb.append(System.getProperty("user.name"));
|
||||
|
||||
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
|
||||
while (networkInterfaces.hasMoreElements()) {
|
||||
NetworkInterface ni = networkInterfaces.nextElement();
|
||||
byte[] hardwareAddress = ni.getHardwareAddress();
|
||||
if (hardwareAddress != null) {
|
||||
for (byte b : hardwareAddress) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
break; // Just use the first MAC address found
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "fallback-machine-id-12345";
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
target/classes/cz/kamma/term/App.class
Normal file
BIN
target/classes/cz/kamma/term/App.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/model/AppSettings.class
Normal file
BIN
target/classes/cz/kamma/term/model/AppSettings.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/model/ConnectionInfo.class
Normal file
BIN
target/classes/cz/kamma/term/model/ConnectionInfo.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/service/ConnectionManager$1.class
Normal file
BIN
target/classes/cz/kamma/term/service/ConnectionManager$1.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/service/ConnectionManager.class
Normal file
BIN
target/classes/cz/kamma/term/service/ConnectionManager.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/service/EncryptionService.class
Normal file
BIN
target/classes/cz/kamma/term/service/EncryptionService.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/service/HistoryManager.class
Normal file
BIN
target/classes/cz/kamma/term/service/HistoryManager.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/service/SettingsManager.class
Normal file
BIN
target/classes/cz/kamma/term/service/SettingsManager.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/ui/MainFrame$1.class
Normal file
BIN
target/classes/cz/kamma/term/ui/MainFrame$1.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/ui/MainFrame.class
Normal file
BIN
target/classes/cz/kamma/term/ui/MainFrame.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/ui/TerminalPanel$1.class
Normal file
BIN
target/classes/cz/kamma/term/ui/TerminalPanel$1.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/ui/TerminalPanel$2.class
Normal file
BIN
target/classes/cz/kamma/term/ui/TerminalPanel$2.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/ui/TerminalPanel.class
Normal file
BIN
target/classes/cz/kamma/term/ui/TerminalPanel.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/util/AnsiHelper.class
Normal file
BIN
target/classes/cz/kamma/term/util/AnsiHelper.class
Normal file
Binary file not shown.
BIN
target/classes/cz/kamma/term/util/SystemIdUtil.class
Normal file
BIN
target/classes/cz/kamma/term/util/SystemIdUtil.class
Normal file
Binary file not shown.
@ -0,0 +1,15 @@
|
||||
cz/kamma/term/service/ConnectionManager.class
|
||||
cz/kamma/term/service/HistoryManager.class
|
||||
cz/kamma/term/ui/MainFrame$1.class
|
||||
cz/kamma/term/model/AppSettings.class
|
||||
cz/kamma/term/util/SystemIdUtil.class
|
||||
cz/kamma/term/service/EncryptionService.class
|
||||
cz/kamma/term/model/ConnectionInfo.class
|
||||
cz/kamma/term/ui/TerminalPanel$2.class
|
||||
cz/kamma/term/ui/TerminalPanel.class
|
||||
cz/kamma/term/service/SettingsManager.class
|
||||
cz/kamma/term/ui/TerminalPanel$1.class
|
||||
cz/kamma/term/service/ConnectionManager$1.class
|
||||
cz/kamma/term/ui/MainFrame.class
|
||||
cz/kamma/term/util/AnsiHelper.class
|
||||
cz/kamma/term/App.class
|
||||
@ -0,0 +1,11 @@
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/util/SystemIdUtil.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/ui/MainFrame.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/service/HistoryManager.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/service/EncryptionService.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/service/SettingsManager.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/service/ConnectionManager.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/model/AppSettings.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/ui/TerminalPanel.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/model/ConnectionInfo.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/util/AnsiHelper.java
|
||||
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/App.java
|
||||
Loading…
x
Reference in New Issue
Block a user