513 lines
17 KiB
C++
513 lines
17 KiB
C++
#include "session_tab.h"
|
|
|
|
#include "session_backend_factory.h"
|
|
#include "terminal_view.h"
|
|
|
|
#include <QClipboard>
|
|
#include <QDateTime>
|
|
#include <QFileDialog>
|
|
#include <QFileInfo>
|
|
#include <QFont>
|
|
#include <QGuiApplication>
|
|
#include <QHBoxLayout>
|
|
#include <QInputDialog>
|
|
#include <QLabel>
|
|
#include <QLineEdit>
|
|
#include <QMessageBox>
|
|
#include <QPlainTextEdit>
|
|
#include <QPushButton>
|
|
#include <QTextCursor>
|
|
#include <QThread>
|
|
#include <QToolButton>
|
|
#include <QVBoxLayout>
|
|
|
|
#include <memory>
|
|
|
|
SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
|
: QWidget(parent),
|
|
m_profile(profile),
|
|
m_backendThread(new QThread(this)),
|
|
m_backend(nullptr),
|
|
m_state(SessionState::Disconnected),
|
|
m_statusLabel(nullptr),
|
|
m_errorLabel(nullptr),
|
|
m_terminalOutput(nullptr),
|
|
m_eventLog(nullptr),
|
|
m_connectButton(nullptr),
|
|
m_disconnectButton(nullptr),
|
|
m_reconnectButton(nullptr),
|
|
m_copyErrorButton(nullptr),
|
|
m_clearTerminalButton(nullptr),
|
|
m_toggleDetailsButton(nullptr),
|
|
m_toggleEventsButton(nullptr),
|
|
m_detailsPanel(nullptr),
|
|
m_eventsPanel(nullptr)
|
|
{
|
|
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
|
|
qRegisterMetaType<SessionState>("SessionState");
|
|
|
|
setupUi();
|
|
|
|
std::unique_ptr<SessionBackend> backend = createSessionBackend(m_profile);
|
|
m_backend = backend.release();
|
|
m_backend->moveToThread(m_backendThread);
|
|
|
|
connect(m_backendThread, &QThread::finished, m_backend, &QObject::deleteLater);
|
|
connect(this,
|
|
&SessionTab::requestConnect,
|
|
m_backend,
|
|
&SessionBackend::connectSession,
|
|
Qt::QueuedConnection);
|
|
connect(this,
|
|
&SessionTab::requestDisconnect,
|
|
m_backend,
|
|
&SessionBackend::disconnectSession,
|
|
Qt::QueuedConnection);
|
|
connect(this,
|
|
&SessionTab::requestReconnect,
|
|
m_backend,
|
|
&SessionBackend::reconnectSession,
|
|
Qt::QueuedConnection);
|
|
connect(this,
|
|
&SessionTab::requestInput,
|
|
m_backend,
|
|
&SessionBackend::sendInput,
|
|
Qt::QueuedConnection);
|
|
connect(this,
|
|
&SessionTab::requestHostKeyConfirmation,
|
|
m_backend,
|
|
&SessionBackend::confirmHostKey,
|
|
Qt::QueuedConnection);
|
|
|
|
connect(m_backend,
|
|
&SessionBackend::stateChanged,
|
|
this,
|
|
&SessionTab::onBackendStateChanged,
|
|
Qt::QueuedConnection);
|
|
connect(m_backend,
|
|
&SessionBackend::eventLogged,
|
|
this,
|
|
&SessionTab::onBackendEventLogged,
|
|
Qt::QueuedConnection);
|
|
connect(m_backend,
|
|
&SessionBackend::connectionError,
|
|
this,
|
|
&SessionTab::onBackendConnectionError,
|
|
Qt::QueuedConnection);
|
|
connect(m_backend,
|
|
&SessionBackend::outputReceived,
|
|
this,
|
|
&SessionTab::onBackendOutputReceived,
|
|
Qt::QueuedConnection);
|
|
connect(m_backend,
|
|
&SessionBackend::hostKeyConfirmationRequested,
|
|
this,
|
|
&SessionTab::onBackendHostKeyConfirmationRequested,
|
|
Qt::QueuedConnection);
|
|
|
|
m_backendThread->start();
|
|
|
|
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
|
|
}
|
|
|
|
SessionTab::~SessionTab()
|
|
{
|
|
if (m_backend != nullptr && m_backendThread->isRunning()) {
|
|
QMetaObject::invokeMethod(m_backend, "disconnectSession", Qt::BlockingQueuedConnection);
|
|
}
|
|
|
|
m_backendThread->quit();
|
|
m_backendThread->wait(2000);
|
|
}
|
|
|
|
QString SessionTab::tabTitle() const
|
|
{
|
|
return QStringLiteral("%1 (%2)").arg(m_profile.name, stateSuffix());
|
|
}
|
|
|
|
void SessionTab::onConnectClicked()
|
|
{
|
|
if (!validateProfileForConnect()) {
|
|
return;
|
|
}
|
|
|
|
const std::optional<SessionConnectOptions> options = buildConnectOptions();
|
|
if (!options.has_value()) {
|
|
return;
|
|
}
|
|
|
|
emit requestConnect(options.value());
|
|
}
|
|
|
|
void SessionTab::onDisconnectClicked()
|
|
{
|
|
emit requestDisconnect();
|
|
}
|
|
|
|
void SessionTab::onReconnectClicked()
|
|
{
|
|
if (!validateProfileForConnect()) {
|
|
return;
|
|
}
|
|
|
|
const std::optional<SessionConnectOptions> options = buildConnectOptions();
|
|
if (!options.has_value()) {
|
|
return;
|
|
}
|
|
|
|
emit requestReconnect(options.value());
|
|
}
|
|
|
|
void SessionTab::onCopyErrorClicked()
|
|
{
|
|
if (m_lastError.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
QGuiApplication::clipboard()->setText(m_lastError);
|
|
appendEvent(QStringLiteral("Copied last error to clipboard."));
|
|
}
|
|
|
|
void SessionTab::onClearTerminalClicked()
|
|
{
|
|
m_terminalOutput->clear();
|
|
}
|
|
|
|
void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
|
|
{
|
|
setState(state, message);
|
|
}
|
|
|
|
void SessionTab::onBackendEventLogged(const QString& message)
|
|
{
|
|
appendEvent(message);
|
|
}
|
|
|
|
void SessionTab::onBackendConnectionError(const QString& displayMessage, const QString& rawMessage)
|
|
{
|
|
m_lastError = rawMessage.isEmpty() ? displayMessage : rawMessage;
|
|
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(displayMessage));
|
|
m_copyErrorButton->setEnabled(true);
|
|
}
|
|
|
|
void SessionTab::onBackendOutputReceived(const QString& text)
|
|
{
|
|
if (text.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
QTextCursor cursor = m_terminalOutput->textCursor();
|
|
cursor.movePosition(QTextCursor::End);
|
|
cursor.insertText(text);
|
|
m_terminalOutput->setTextCursor(cursor);
|
|
m_terminalOutput->ensureCursorVisible();
|
|
}
|
|
|
|
void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
|
|
{
|
|
const QString question = prompt.isEmpty()
|
|
? QStringLiteral("Unknown SSH host key. Do you trust this host?")
|
|
: prompt;
|
|
|
|
const QMessageBox::StandardButton reply = QMessageBox::question(
|
|
this,
|
|
QStringLiteral("SSH Host Key Confirmation"),
|
|
QStringLiteral("%1\n\nTrust and continue?").arg(question),
|
|
QMessageBox::Yes | QMessageBox::No,
|
|
QMessageBox::No);
|
|
|
|
emit requestHostKeyConfirmation(reply == QMessageBox::Yes);
|
|
}
|
|
|
|
void SessionTab::setupUi()
|
|
{
|
|
auto* rootLayout = new QVBoxLayout(this);
|
|
|
|
auto* detailsHeader = new QHBoxLayout();
|
|
m_toggleDetailsButton = new QToolButton(this);
|
|
m_toggleDetailsButton->setCheckable(true);
|
|
detailsHeader->addWidget(m_toggleDetailsButton);
|
|
detailsHeader->addStretch();
|
|
|
|
m_detailsPanel = new QWidget(this);
|
|
auto* detailsLayout = new QVBoxLayout(m_detailsPanel);
|
|
detailsLayout->setContentsMargins(0, 0, 0, 0);
|
|
|
|
auto* profileLabel = new QLabel(QStringLiteral("Profile: %1").arg(m_profile.name), m_detailsPanel);
|
|
auto* endpointLabel = new QLabel(
|
|
QStringLiteral("Endpoint: %1://%2@%3:%4")
|
|
.arg(m_profile.protocol,
|
|
m_profile.username.isEmpty() ? QStringLiteral("<none>") : m_profile.username,
|
|
m_profile.host,
|
|
QString::number(m_profile.port)),
|
|
m_detailsPanel);
|
|
auto* authLabel = new QLabel(QStringLiteral("Auth: %1").arg(m_profile.authMode), m_detailsPanel);
|
|
|
|
m_statusLabel = new QLabel(m_detailsPanel);
|
|
m_errorLabel = new QLabel(QStringLiteral("Last Error: None"), m_detailsPanel);
|
|
m_errorLabel->setWordWrap(true);
|
|
|
|
auto* actionRow = new QHBoxLayout();
|
|
m_connectButton = new QPushButton(QStringLiteral("Connect"), m_detailsPanel);
|
|
m_disconnectButton = new QPushButton(QStringLiteral("Disconnect"), m_detailsPanel);
|
|
m_reconnectButton = new QPushButton(QStringLiteral("Reconnect"), m_detailsPanel);
|
|
m_copyErrorButton = new QPushButton(QStringLiteral("Copy Error"), m_detailsPanel);
|
|
|
|
actionRow->addWidget(m_connectButton);
|
|
actionRow->addWidget(m_disconnectButton);
|
|
actionRow->addWidget(m_reconnectButton);
|
|
actionRow->addWidget(m_copyErrorButton);
|
|
actionRow->addStretch();
|
|
|
|
detailsLayout->addWidget(profileLabel);
|
|
detailsLayout->addWidget(endpointLabel);
|
|
detailsLayout->addWidget(authLabel);
|
|
detailsLayout->addWidget(m_statusLabel);
|
|
detailsLayout->addWidget(m_errorLabel);
|
|
detailsLayout->addLayout(actionRow);
|
|
|
|
auto* terminalHeader = new QHBoxLayout();
|
|
auto* terminalLabel = new QLabel(QStringLiteral("SSH Terminal"), this);
|
|
m_clearTerminalButton = new QPushButton(QStringLiteral("Clear"), this);
|
|
terminalHeader->addWidget(terminalLabel);
|
|
terminalHeader->addStretch();
|
|
terminalHeader->addWidget(m_clearTerminalButton);
|
|
|
|
m_terminalOutput = new TerminalView(this);
|
|
m_terminalOutput->setMaximumBlockCount(4000);
|
|
QFont terminalFont(QStringLiteral("Monospace"));
|
|
terminalFont.setStyleHint(QFont::TypeWriter);
|
|
m_terminalOutput->setFont(terminalFont);
|
|
m_terminalOutput->setMinimumHeight(260);
|
|
m_terminalOutput->setPlaceholderText(
|
|
QStringLiteral("Connect, then type directly here to interact with the SSH session."));
|
|
|
|
auto* eventsHeader = new QHBoxLayout();
|
|
m_toggleEventsButton = new QToolButton(this);
|
|
m_toggleEventsButton->setCheckable(true);
|
|
eventsHeader->addWidget(m_toggleEventsButton);
|
|
eventsHeader->addStretch();
|
|
|
|
m_eventsPanel = new QWidget(this);
|
|
auto* eventsLayout = new QVBoxLayout(m_eventsPanel);
|
|
eventsLayout->setContentsMargins(0, 0, 0, 0);
|
|
|
|
auto* eventTitle = new QLabel(QStringLiteral("Session Events"), m_eventsPanel);
|
|
m_eventLog = new QPlainTextEdit(m_eventsPanel);
|
|
m_eventLog->setReadOnly(true);
|
|
m_eventLog->setPlaceholderText(QStringLiteral("Session event log..."));
|
|
m_eventLog->setMinimumHeight(140);
|
|
|
|
eventsLayout->addWidget(eventTitle);
|
|
eventsLayout->addWidget(m_eventLog);
|
|
|
|
rootLayout->addLayout(detailsHeader);
|
|
rootLayout->addWidget(m_detailsPanel);
|
|
rootLayout->addLayout(terminalHeader);
|
|
rootLayout->addWidget(m_terminalOutput, 1);
|
|
rootLayout->addLayout(eventsHeader);
|
|
rootLayout->addWidget(m_eventsPanel);
|
|
|
|
setPanelExpanded(m_toggleDetailsButton, m_detailsPanel, QStringLiteral("Details"), true);
|
|
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), false);
|
|
|
|
connect(m_toggleDetailsButton,
|
|
&QToolButton::toggled,
|
|
this,
|
|
[this](bool expanded) {
|
|
setPanelExpanded(
|
|
m_toggleDetailsButton, m_detailsPanel, QStringLiteral("Details"), expanded);
|
|
});
|
|
connect(m_toggleEventsButton,
|
|
&QToolButton::toggled,
|
|
this,
|
|
[this](bool expanded) {
|
|
setPanelExpanded(
|
|
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
|
|
});
|
|
|
|
connect(m_connectButton, &QPushButton::clicked, this, &SessionTab::onConnectClicked);
|
|
connect(m_disconnectButton, &QPushButton::clicked, this, &SessionTab::onDisconnectClicked);
|
|
connect(m_reconnectButton, &QPushButton::clicked, this, &SessionTab::onReconnectClicked);
|
|
connect(m_copyErrorButton, &QPushButton::clicked, this, &SessionTab::onCopyErrorClicked);
|
|
connect(m_clearTerminalButton,
|
|
&QPushButton::clicked,
|
|
this,
|
|
&SessionTab::onClearTerminalClicked);
|
|
connect(m_terminalOutput,
|
|
&TerminalView::inputGenerated,
|
|
this,
|
|
[this](const QString& input) { emit requestInput(input); });
|
|
}
|
|
|
|
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
|
|
{
|
|
SessionConnectOptions options;
|
|
options.knownHostsPolicy = m_profile.knownHostsPolicy;
|
|
|
|
if (m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) != 0) {
|
|
return options;
|
|
}
|
|
|
|
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
|
|
bool accepted = false;
|
|
const QString password = QInputDialog::getText(this,
|
|
QStringLiteral("SSH Password"),
|
|
QStringLiteral("Password for %1@%2:")
|
|
.arg(m_profile.username, m_profile.host),
|
|
QLineEdit::Password,
|
|
QString(),
|
|
&accepted);
|
|
if (!accepted) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
if (password.isEmpty()) {
|
|
QMessageBox::warning(this,
|
|
QStringLiteral("Connect"),
|
|
QStringLiteral("Password is required for password authentication."));
|
|
return std::nullopt;
|
|
}
|
|
|
|
options.password = password;
|
|
return options;
|
|
}
|
|
|
|
QString keyPath = m_profile.privateKeyPath.trimmed();
|
|
if (keyPath.isEmpty()) {
|
|
keyPath = QFileDialog::getOpenFileName(this,
|
|
QStringLiteral("Select Private Key"),
|
|
QString(),
|
|
QStringLiteral("All Files (*)"));
|
|
if (keyPath.isEmpty()) {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
|
|
if (!QFileInfo::exists(keyPath)) {
|
|
QMessageBox::warning(this,
|
|
QStringLiteral("Connect"),
|
|
QStringLiteral("Private key file not found: %1").arg(keyPath));
|
|
return std::nullopt;
|
|
}
|
|
|
|
options.privateKeyPath = keyPath;
|
|
return options;
|
|
}
|
|
|
|
bool SessionTab::validateProfileForConnect()
|
|
{
|
|
if (m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) != 0) {
|
|
return true;
|
|
}
|
|
|
|
if (m_profile.host.trimmed().isEmpty()) {
|
|
QMessageBox::warning(this,
|
|
QStringLiteral("Connect"),
|
|
QStringLiteral("SSH host is required."));
|
|
return false;
|
|
}
|
|
|
|
if (m_profile.username.trimmed().isEmpty()) {
|
|
QMessageBox::warning(this,
|
|
QStringLiteral("Connect"),
|
|
QStringLiteral("SSH username is required."));
|
|
return false;
|
|
}
|
|
|
|
if (m_profile.port < 1 || m_profile.port > 65535) {
|
|
QMessageBox::warning(this,
|
|
QStringLiteral("Connect"),
|
|
QStringLiteral("SSH port must be between 1 and 65535."));
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void SessionTab::appendEvent(const QString& message)
|
|
{
|
|
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
|
|
m_eventLog->appendPlainText(QStringLiteral("[%1] %2").arg(timestamp, message));
|
|
}
|
|
|
|
void SessionTab::setState(SessionState state, const QString& message)
|
|
{
|
|
m_state = state;
|
|
|
|
QString style;
|
|
switch (state) {
|
|
case SessionState::Disconnected:
|
|
style = QStringLiteral("border: 1px solid #8a8a8a; background-color: #efefef; padding: 6px;");
|
|
break;
|
|
case SessionState::Connecting:
|
|
style = QStringLiteral("border: 1px solid #a5a5a5; background-color: #fff3cd; padding: 6px;");
|
|
break;
|
|
case SessionState::Connected:
|
|
style = QStringLiteral("border: 1px solid #3c763d; background-color: #dff0d8; padding: 6px;");
|
|
break;
|
|
case SessionState::Failed:
|
|
style = QStringLiteral("border: 1px solid #a94442; background-color: #f2dede; padding: 6px;");
|
|
break;
|
|
}
|
|
|
|
m_statusLabel->setStyleSheet(style);
|
|
m_statusLabel->setText(QStringLiteral("Connection State: %1").arg(message));
|
|
|
|
refreshActionButtons();
|
|
emit tabTitleChanged(tabTitle());
|
|
}
|
|
|
|
QString SessionTab::stateSuffix() const
|
|
{
|
|
switch (m_state) {
|
|
case SessionState::Disconnected:
|
|
return QStringLiteral("Disconnected");
|
|
case SessionState::Connecting:
|
|
return QStringLiteral("Connecting");
|
|
case SessionState::Connected:
|
|
return QStringLiteral("Connected");
|
|
case SessionState::Failed:
|
|
return QStringLiteral("Failed");
|
|
}
|
|
|
|
return QStringLiteral("Unknown");
|
|
}
|
|
|
|
void SessionTab::refreshActionButtons()
|
|
{
|
|
const bool isConnected = m_state == SessionState::Connected;
|
|
const bool canConnect = m_state == SessionState::Disconnected || m_state == SessionState::Failed;
|
|
|
|
m_connectButton->setEnabled(canConnect);
|
|
m_disconnectButton->setEnabled(m_state == SessionState::Connected
|
|
|| m_state == SessionState::Connecting);
|
|
m_reconnectButton->setEnabled(m_state == SessionState::Connected
|
|
|| m_state == SessionState::Failed
|
|
|| m_state == SessionState::Disconnected);
|
|
m_copyErrorButton->setEnabled(!m_lastError.isEmpty());
|
|
|
|
m_terminalOutput->setEnabled(isConnected);
|
|
if (isConnected) {
|
|
m_terminalOutput->setFocus();
|
|
}
|
|
}
|
|
|
|
void SessionTab::setPanelExpanded(QToolButton* button,
|
|
QWidget* panel,
|
|
const QString& name,
|
|
bool expanded)
|
|
{
|
|
if (button == nullptr || panel == nullptr) {
|
|
return;
|
|
}
|
|
|
|
button->blockSignals(true);
|
|
button->setChecked(expanded);
|
|
button->blockSignals(false);
|
|
|
|
panel->setVisible(expanded);
|
|
button->setText(expanded ? QStringLiteral("Hide %1").arg(name)
|
|
: QStringLiteral("Show %1").arg(name));
|
|
}
|