Added a listener for incoming traffic from the server to the application
This commit is contained in:
parent
b08e459843
commit
e7f012dc5f
6 changed files with 148 additions and 82 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
package com.coder.client;
|
package com.coder.client;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
|
@ -7,6 +9,8 @@ import javax.swing.JOptionPane;
|
||||||
|
|
||||||
import com.coder.client.LoginDialog.LoginDialogAction;
|
import com.coder.client.LoginDialog.LoginDialogAction;
|
||||||
import com.coder.server.CoderServer;
|
import com.coder.server.CoderServer;
|
||||||
|
import com.coder.server.message.CoderMessage;
|
||||||
|
import com.coder.server.message.ProjectListData;
|
||||||
|
|
||||||
import zutil.log.LogUtil;
|
import zutil.log.LogUtil;
|
||||||
|
|
||||||
|
|
@ -16,15 +20,13 @@ public class CoderClient extends Thread{
|
||||||
private String url;
|
private String url;
|
||||||
private int port;
|
private int port;
|
||||||
private Session session;
|
private Session session;
|
||||||
private ClientWindow window;
|
private ProjectEditorWindow projectEditorWindow;
|
||||||
private LoginDialog loginDialog;
|
|
||||||
|
|
||||||
public CoderClient(String url, int port) {
|
public CoderClient(String url, int port) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
|
|
||||||
this.window = new ClientWindow("CoderClient");
|
this.projectEditorWindow = new ProjectEditorWindow("CoderClient");
|
||||||
|
|
||||||
|
|
||||||
super.start();
|
super.start();
|
||||||
}
|
}
|
||||||
|
|
@ -32,18 +34,37 @@ public class CoderClient extends Thread{
|
||||||
public void run(){
|
public void run(){
|
||||||
//save the previously typed user name
|
//save the previously typed user name
|
||||||
String username = "";
|
String username = "";
|
||||||
|
//keep track of the number of connection retries to the server
|
||||||
|
int connectionRetries = 0;
|
||||||
while(true){
|
while(true){
|
||||||
//close previou session if applicable
|
//close previous session if applicable
|
||||||
closeCurrentSession();
|
closeCurrentSession();
|
||||||
|
|
||||||
//setup a new session
|
//setup a new session
|
||||||
|
if(connectionRetries > 0){
|
||||||
|
logger.info("This is reconnection try number: " + connectionRetries);
|
||||||
|
}
|
||||||
this.session = Session.setupConnection(url, port);
|
this.session = Session.setupConnection(url, port);
|
||||||
if(session == null){
|
if(session == null){
|
||||||
logger.info("Could not setup a connection to " + url + ":" + port);
|
logger.info("Could not setup a connection to " + url + ":" + port);
|
||||||
continue;
|
connectionRetries++;
|
||||||
|
if(connectionRetries > 5){
|
||||||
|
//stop trying to connect
|
||||||
|
logger.severe("Was not able to conenct to the remote host.");
|
||||||
|
break;
|
||||||
|
}else{
|
||||||
|
//wait for awhile and try to connect one more
|
||||||
|
logger.info("Will retry to connect once more in 2 seconds.");
|
||||||
|
try {
|
||||||
|
Thread.sleep(2000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//ask for username and passwor din a dialog window
|
//ask for username and password in a dialog window
|
||||||
LoginDialog loginDialog = new LoginDialog(username, null);
|
LoginDialog loginDialog = new LoginDialog(username, null);
|
||||||
loginDialog.setVisible(true); //blocking
|
loginDialog.setVisible(true); //blocking
|
||||||
loginDialog.dispose();
|
loginDialog.dispose();
|
||||||
|
|
@ -58,15 +79,35 @@ public class CoderClient extends Thread{
|
||||||
boolean authenticated = session.authenticate(username, password);
|
boolean authenticated = session.authenticate(username, password);
|
||||||
if(!authenticated){
|
if(!authenticated){
|
||||||
JOptionPane.showMessageDialog(null, "Wrong username or password", "Authentication Failed", JOptionPane.INFORMATION_MESSAGE);
|
JOptionPane.showMessageDialog(null, "Wrong username or password", "Authentication Failed", JOptionPane.INFORMATION_MESSAGE);
|
||||||
System.out.println("Authentication failed");
|
logger.info("Authentication failed: wrong username or password");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//resister a message listener to the session
|
||||||
|
session.addCoderMessageReceivedListener(new CoderMessageReceivedListener() {
|
||||||
|
@Override
|
||||||
|
public void projectListRspReceived(Map<String, ProjectListData> projectListRsp) {
|
||||||
|
//TODO
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
//start receiving traffic from the server
|
//start receiving traffic from the server
|
||||||
session.start();
|
session.start();
|
||||||
|
|
||||||
//show the user the main GUI
|
//add a listener to forward messages from the editor GUI to the server
|
||||||
this.window.setVisible(true);
|
this.projectEditorWindow.addMessageSentListener(new GUIMessageSentListener(){
|
||||||
|
@Override
|
||||||
|
public void sendMessage(CoderMessage msg) {
|
||||||
|
try {
|
||||||
|
session.send(msg);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.log(Level.SEVERE, "could not forward message from editor to the server", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//show the user the editor GUI
|
||||||
|
this.projectEditorWindow.setVisible(true);
|
||||||
try {Thread.sleep(1000);} catch (InterruptedException e) {}
|
try {Thread.sleep(1000);} catch (InterruptedException e) {}
|
||||||
|
|
||||||
//wait here until the session is closed for some reason
|
//wait here until the session is closed for some reason
|
||||||
|
|
@ -75,12 +116,13 @@ public class CoderClient extends Thread{
|
||||||
}
|
}
|
||||||
|
|
||||||
//hide the main GUI
|
//hide the main GUI
|
||||||
logger.info("The socket was closed. terminating.");
|
logger.info("The socket was closed.");
|
||||||
this.window.setVisible(false);
|
this.projectEditorWindow.setVisible(false);
|
||||||
Thread.yield();
|
Thread.yield();
|
||||||
}
|
}
|
||||||
|
logger.info("The program till now terminate");
|
||||||
closeCurrentSession();
|
closeCurrentSession();
|
||||||
this.window.dispose();
|
this.projectEditorWindow.dispose();
|
||||||
System.exit(0);
|
System.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package com.coder.client;
|
package com.coder.client;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.coder.server.message.ProjectListData;
|
||||||
|
|
||||||
public interface CoderMessageReceivedListener {
|
public interface CoderMessageReceivedListener {
|
||||||
|
|
||||||
void msgReceived(Class msgClass, Object authenticationChallenge);
|
void projectListRspReceived(Map<String, ProjectListData> projectListRsp);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
src/com/coder/client/GUIMessageSentListener.java
Normal file
9
src/com/coder/client/GUIMessageSentListener.java
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.coder.client;
|
||||||
|
|
||||||
|
import com.coder.server.message.CoderMessage;
|
||||||
|
|
||||||
|
public interface GUIMessageSentListener {
|
||||||
|
|
||||||
|
void sendMessage(CoderMessage msg);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -20,87 +20,84 @@ import javax.swing.border.LineBorder;
|
||||||
|
|
||||||
public class LoginDialog extends JDialog {
|
public class LoginDialog extends JDialog {
|
||||||
private static final long serialVersionUID = 9196349269499229433L;
|
private static final long serialVersionUID = 9196349269499229433L;
|
||||||
private JTextField tfUsername;
|
public static enum LoginDialogAction{
|
||||||
private JPasswordField pfPassword;
|
|
||||||
private JLabel lbUsername;
|
|
||||||
private JLabel lbPassword;
|
|
||||||
private JButton btnLogin;
|
|
||||||
private JButton btnCancel;
|
|
||||||
private LoginDialogAction loginAction = LoginDialogAction.CANCEL;
|
|
||||||
|
|
||||||
public static enum LoginDialogAction{
|
|
||||||
LOGIN,
|
LOGIN,
|
||||||
CANCEL
|
CANCEL
|
||||||
}
|
}
|
||||||
|
private LoginDialogAction loginAction = LoginDialogAction.CANCEL;
|
||||||
|
private JTextField usernameTextField;
|
||||||
|
private JPasswordField passwordPasswordField;
|
||||||
|
private JButton loginButton;
|
||||||
|
private JButton cancelButton;
|
||||||
|
|
||||||
public LoginDialog(String username, Frame parent) {
|
public LoginDialog(String username, Frame parent) {
|
||||||
super(parent, "Login", true);
|
super(parent, "Login", true);
|
||||||
//
|
|
||||||
JPanel panel = new JPanel(new GridBagLayout());
|
JPanel fieldPanel = new JPanel(new GridBagLayout());
|
||||||
GridBagConstraints cs = new GridBagConstraints();
|
GridBagConstraints gridBagConstraints = new GridBagConstraints();
|
||||||
|
|
||||||
cs.fill = GridBagConstraints.HORIZONTAL;
|
gridBagConstraints.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
|
||||||
lbUsername = new JLabel("Username: ");
|
JLabel usernameLabel = new JLabel("Username: ");
|
||||||
cs.gridx = 0;
|
gridBagConstraints.gridx = 0;
|
||||||
cs.gridy = 0;
|
gridBagConstraints.gridy = 0;
|
||||||
cs.gridwidth = 1;
|
gridBagConstraints.gridwidth = 1;
|
||||||
panel.add(lbUsername, cs);
|
fieldPanel.add(usernameLabel, gridBagConstraints);
|
||||||
|
|
||||||
tfUsername = new JTextField(20);
|
usernameTextField = new JTextField(20);
|
||||||
tfUsername.setText(username);
|
usernameTextField.setText(username);
|
||||||
cs.gridx = 1;
|
gridBagConstraints.gridx = 1;
|
||||||
cs.gridy = 0;
|
gridBagConstraints.gridy = 0;
|
||||||
cs.gridwidth = 2;
|
gridBagConstraints.gridwidth = 2;
|
||||||
panel.add(tfUsername, cs);
|
fieldPanel.add(usernameTextField, gridBagConstraints);
|
||||||
|
|
||||||
lbPassword = new JLabel("Password: ");
|
JLabel passwordLabel = new JLabel("Password: ");
|
||||||
cs.gridx = 0;
|
gridBagConstraints.gridx = 0;
|
||||||
cs.gridy = 1;
|
gridBagConstraints.gridy = 1;
|
||||||
cs.gridwidth = 1;
|
gridBagConstraints.gridwidth = 1;
|
||||||
panel.add(lbPassword, cs);
|
fieldPanel.add(passwordLabel, gridBagConstraints);
|
||||||
|
|
||||||
pfPassword = new JPasswordField(20);
|
passwordPasswordField = new JPasswordField(20);
|
||||||
cs.gridx = 1;
|
gridBagConstraints.gridx = 1;
|
||||||
cs.gridy = 1;
|
gridBagConstraints.gridy = 1;
|
||||||
cs.gridwidth = 2;
|
gridBagConstraints.gridwidth = 2;
|
||||||
panel.add(pfPassword, cs);
|
fieldPanel.add(passwordPasswordField, gridBagConstraints);
|
||||||
panel.setBorder(new LineBorder(Color.GRAY));
|
fieldPanel.setBorder(new LineBorder(Color.GRAY));
|
||||||
|
|
||||||
btnLogin = new JButton("Login");
|
loginButton = new JButton("Login");
|
||||||
|
|
||||||
btnCancel = new JButton("Cancel");
|
cancelButton = new JButton("Cancel");
|
||||||
|
|
||||||
JPanel bp = new JPanel();
|
JPanel buttonPanel = new JPanel();
|
||||||
bp.add(btnLogin);
|
buttonPanel.add(loginButton);
|
||||||
bp.add(btnCancel);
|
buttonPanel.add(cancelButton);
|
||||||
|
|
||||||
getContentPane().add(panel, BorderLayout.CENTER);
|
getContentPane().add(fieldPanel, BorderLayout.CENTER);
|
||||||
getContentPane().add(bp, BorderLayout.PAGE_END);
|
getContentPane().add(buttonPanel, BorderLayout.PAGE_END);
|
||||||
|
|
||||||
|
|
||||||
btnLogin.addActionListener(new ActionListener() {
|
loginButton.addActionListener(new ActionListener() {
|
||||||
@Override
|
@Override
|
||||||
public void actionPerformed(ActionEvent e) {
|
public void actionPerformed(ActionEvent e) {
|
||||||
loginAction = LoginDialogAction.LOGIN;
|
loginAction = LoginDialogAction.LOGIN;
|
||||||
dispose();
|
dispose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
btnCancel.addActionListener(new ActionListener() {
|
cancelButton.addActionListener(new ActionListener() {
|
||||||
@Override
|
@Override
|
||||||
public void actionPerformed(ActionEvent e) {
|
public void actionPerformed(ActionEvent e) {
|
||||||
loginAction = LoginDialogAction.CANCEL;
|
loginAction = LoginDialogAction.CANCEL;
|
||||||
dispose();
|
dispose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
tfUsername.addKeyListener(new KeyListener(){
|
usernameTextField.addKeyListener(new KeyListener(){
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void keyPressed(KeyEvent e) {
|
public void keyPressed(KeyEvent e) {
|
||||||
if(e.getKeyCode() == KeyEvent.VK_ENTER){
|
if(e.getKeyCode() == KeyEvent.VK_ENTER){
|
||||||
btnLogin.doClick();
|
loginButton.doClick();
|
||||||
}else if(e.getKeyCode() == KeyEvent.VK_ESCAPE){
|
}else if(e.getKeyCode() == KeyEvent.VK_ESCAPE){
|
||||||
btnCancel.doClick();
|
cancelButton.doClick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,14 +112,14 @@ public class LoginDialog extends JDialog {
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
pfPassword.addKeyListener(new KeyListener(){
|
passwordPasswordField.addKeyListener(new KeyListener(){
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void keyPressed(KeyEvent e) {
|
public void keyPressed(KeyEvent e) {
|
||||||
if(e.getKeyCode() == KeyEvent.VK_ENTER){
|
if(e.getKeyCode() == KeyEvent.VK_ENTER){
|
||||||
btnLogin.doClick();
|
loginButton.doClick();
|
||||||
}else if(e.getKeyCode() == KeyEvent.VK_ESCAPE){
|
}else if(e.getKeyCode() == KeyEvent.VK_ESCAPE){
|
||||||
btnCancel.doClick();
|
cancelButton.doClick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +140,7 @@ public class LoginDialog extends JDialog {
|
||||||
|
|
||||||
//move focus to the password field if already a username has been defined. must be done after pack()
|
//move focus to the password field if already a username has been defined. must be done after pack()
|
||||||
if(username.isEmpty() == false){
|
if(username.isEmpty() == false){
|
||||||
pfPassword.requestFocusInWindow();
|
passwordPasswordField.requestFocusInWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
setResizable(false);
|
setResizable(false);
|
||||||
|
|
@ -151,11 +148,11 @@ public class LoginDialog extends JDialog {
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getUsername() {
|
public String getUsername() {
|
||||||
return tfUsername.getText().trim();
|
return usernameTextField.getText().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public char[] getPassword() {
|
public char[] getPassword() {
|
||||||
return pfPassword.getPassword();
|
return passwordPasswordField.getPassword();
|
||||||
}
|
}
|
||||||
|
|
||||||
public LoginDialogAction getAction() {
|
public LoginDialogAction getAction() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.coder.client;
|
package com.coder.client;
|
||||||
|
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
|
|
@ -15,9 +16,11 @@ import javax.swing.event.TreeSelectionListener;
|
||||||
import javax.swing.tree.DefaultMutableTreeNode;
|
import javax.swing.tree.DefaultMutableTreeNode;
|
||||||
import javax.swing.tree.TreeSelectionModel;
|
import javax.swing.tree.TreeSelectionModel;
|
||||||
|
|
||||||
|
import com.coder.server.message.CoderMessage;
|
||||||
|
|
||||||
import zutil.log.LogUtil;
|
import zutil.log.LogUtil;
|
||||||
|
|
||||||
public class ClientWindow extends JFrame implements TreeSelectionListener {
|
public class ProjectEditorWindow extends JFrame implements TreeSelectionListener {
|
||||||
private static final long serialVersionUID = -5486192344225335322L;
|
private static final long serialVersionUID = -5486192344225335322L;
|
||||||
public static final Logger logger = LogUtil.getLogger();
|
public static final Logger logger = LogUtil.getLogger();
|
||||||
|
|
||||||
|
|
@ -26,8 +29,9 @@ public class ClientWindow extends JFrame implements TreeSelectionListener {
|
||||||
private JTextArea codingArea;
|
private JTextArea codingArea;
|
||||||
private JButton compileButton;
|
private JButton compileButton;
|
||||||
private JButton runButton;
|
private JButton runButton;
|
||||||
|
private HashSet<GUIMessageSentListener> GUIMessageSentListeners;
|
||||||
|
|
||||||
public ClientWindow(String title){
|
public ProjectEditorWindow(String title){
|
||||||
super(title);
|
super(title);
|
||||||
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||||
Dimension minimumSize = new Dimension(500,300);
|
Dimension minimumSize = new Dimension(500,300);
|
||||||
|
|
@ -84,4 +88,19 @@ public class ClientWindow extends JFrame implements TreeSelectionListener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void addMessageSentListener(GUIMessageSentListener listener) {
|
||||||
|
this.GUIMessageSentListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMessage(CoderMessage msg){
|
||||||
|
for(GUIMessageSentListener listener : GUIMessageSentListeners){
|
||||||
|
listener.sendMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProjectName(String projectName) {
|
||||||
|
// TODO Auto-generated method stub
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import java.io.BufferedOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
|
import java.net.SocketException;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
import java.security.InvalidKeyException;
|
import java.security.InvalidKeyException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
@ -76,7 +77,7 @@ public class Session extends Thread {
|
||||||
close();
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while(true){
|
while(isConnected()){
|
||||||
CoderMessage msg;
|
CoderMessage msg;
|
||||||
try {
|
try {
|
||||||
msg = in.readGenericObject();
|
msg = in.readGenericObject();
|
||||||
|
|
@ -160,6 +161,7 @@ public class Session extends Thread {
|
||||||
logger.log(Level.INFO, "Received AuthenticationChallenge");
|
logger.log(Level.INFO, "Received AuthenticationChallenge");
|
||||||
|
|
||||||
// Setting up encryption
|
// Setting up encryption
|
||||||
|
/*
|
||||||
logger.info("Setting up encryption");
|
logger.info("Setting up encryption");
|
||||||
String hashedPassword = Hasher.PBKDF2(clearTextPassword, username, AUTH_HASH_ITERATIONS);
|
String hashedPassword = Hasher.PBKDF2(clearTextPassword, username, AUTH_HASH_ITERATIONS);
|
||||||
String key = Hasher.PBKDF2(hashedPassword, msg.AuthenticationChallenge.salt, AUTH_HASH_ITERATIONS);
|
String key = Hasher.PBKDF2(hashedPassword, msg.AuthenticationChallenge.salt, AUTH_HASH_ITERATIONS);
|
||||||
|
|
@ -177,7 +179,8 @@ public class Session extends Thread {
|
||||||
out.enableMetaData(false);
|
out.enableMetaData(false);
|
||||||
|
|
||||||
///////////// ENCRYPTED CONNECTION //////////////////////
|
///////////// ENCRYPTED CONNECTION //////////////////////
|
||||||
|
*/
|
||||||
|
|
||||||
//Send AuthenticationRsp
|
//Send AuthenticationRsp
|
||||||
CoderMessage authRsp = new CoderMessage();
|
CoderMessage authRsp = new CoderMessage();
|
||||||
authRsp.AuthenticationRsp = new AuthenticationRspMsg();
|
authRsp.AuthenticationRsp = new AuthenticationRspMsg();
|
||||||
|
|
@ -206,17 +209,9 @@ public class Session extends Thread {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleMessage(CoderMessage msg){
|
private void handleMessage(CoderMessage msg){
|
||||||
for(Field field : CoderMessage.class.getDeclaredFields()){
|
if(msg.ProjectListRsp != null){
|
||||||
Object obj = null;
|
for(CoderMessageReceivedListener listener : messageReceivedlisteners){
|
||||||
try {
|
listener.projectListRspReceived(msg.ProjectListRsp);
|
||||||
obj = field.get(msg);
|
|
||||||
} catch (IllegalArgumentException | IllegalAccessException e) {
|
|
||||||
logger.warning("CoderMessage field " + field.getName() + " was skipped");
|
|
||||||
}
|
|
||||||
if(obj != null){
|
|
||||||
for(CoderMessageReceivedListener listener : messageReceivedlisteners){
|
|
||||||
listener.msgReceived(field.getClass(), obj);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue