Complete Milestone 4 interactive SSH session UX
This commit is contained in:
@@ -5,13 +5,11 @@
|
||||
|
||||
#include <KodoTerm/KodoTerm.hpp>
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QComboBox>
|
||||
#include <QDateTime>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QFontDatabase>
|
||||
#include <QHBoxLayout>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
@@ -19,7 +17,6 @@
|
||||
#include <QMessageBox>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QPushButton>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
@@ -28,6 +25,16 @@
|
||||
#include <memory>
|
||||
|
||||
namespace {
|
||||
QFont defaultTerminalFont()
|
||||
{
|
||||
QFont font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||
font.setStyleHint(QFont::Monospace);
|
||||
font.setFixedPitch(true);
|
||||
font.setKerning(false);
|
||||
font.setLetterSpacing(QFont::AbsoluteSpacing, 0.0);
|
||||
return font;
|
||||
}
|
||||
|
||||
TerminalTheme themeForName(const QString& themeName)
|
||||
{
|
||||
if (themeName.compare(QStringLiteral("Light"), Qt::CaseInsensitive) == 0) {
|
||||
@@ -53,20 +60,11 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
||||
m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive)
|
||||
== 0),
|
||||
m_state(SessionState::Disconnected),
|
||||
m_statusLabel(nullptr),
|
||||
m_errorLabel(nullptr),
|
||||
m_terminalThemeName(QStringLiteral("Dark")),
|
||||
m_sshTerminal(nullptr),
|
||||
m_terminalOutput(nullptr),
|
||||
m_eventLog(nullptr),
|
||||
m_connectButton(nullptr),
|
||||
m_disconnectButton(nullptr),
|
||||
m_reconnectButton(nullptr),
|
||||
m_copyErrorButton(nullptr),
|
||||
m_clearTerminalButton(nullptr),
|
||||
m_themeSelector(nullptr),
|
||||
m_toggleDetailsButton(nullptr),
|
||||
m_toggleEventsButton(nullptr),
|
||||
m_detailsPanel(nullptr),
|
||||
m_eventsPanel(nullptr)
|
||||
{
|
||||
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
|
||||
@@ -83,15 +81,26 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_state == SessionState::Connected) {
|
||||
if (exitCode != 0) {
|
||||
appendEvent(QStringLiteral("SSH session closed with exit code %1.")
|
||||
.arg(exitCode));
|
||||
}
|
||||
setState(SessionState::Disconnected,
|
||||
QStringLiteral("SSH session closed."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitCode == 0) {
|
||||
setState(SessionState::Disconnected,
|
||||
QStringLiteral("SSH session ended."));
|
||||
} else {
|
||||
m_lastError = QStringLiteral("ssh exited with code %1").arg(exitCode);
|
||||
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(m_lastError));
|
||||
setState(SessionState::Failed,
|
||||
QStringLiteral("SSH session exited unexpectedly."));
|
||||
return;
|
||||
}
|
||||
|
||||
m_lastError = QStringLiteral("ssh exited with code %1").arg(exitCode);
|
||||
appendEvent(QStringLiteral("Error: %1").arg(m_lastError));
|
||||
setState(SessionState::Failed,
|
||||
QStringLiteral("SSH session exited unexpectedly."));
|
||||
});
|
||||
connect(m_sshTerminal,
|
||||
&KodoTerm::cwdChanged,
|
||||
@@ -170,10 +179,15 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
||||
}
|
||||
|
||||
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
|
||||
QTimer::singleShot(0, this, &SessionTab::connectSession);
|
||||
}
|
||||
|
||||
SessionTab::~SessionTab()
|
||||
{
|
||||
if (m_useKodoTermForSsh && m_sshTerminal != nullptr && m_state != SessionState::Disconnected) {
|
||||
m_sshTerminal->kill();
|
||||
}
|
||||
|
||||
if (m_backend != nullptr && m_backendThread != nullptr && m_backendThread->isRunning()) {
|
||||
QMetaObject::invokeMethod(m_backend, "disconnectSession", Qt::BlockingQueuedConnection);
|
||||
m_backendThread->quit();
|
||||
@@ -186,8 +200,12 @@ QString SessionTab::tabTitle() const
|
||||
return QStringLiteral("%1 (%2)").arg(m_profile.name, stateSuffix());
|
||||
}
|
||||
|
||||
void SessionTab::onConnectClicked()
|
||||
void SessionTab::connectSession()
|
||||
{
|
||||
if (m_state == SessionState::Connecting || m_state == SessionState::Connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateProfileForConnect()) {
|
||||
return;
|
||||
}
|
||||
@@ -209,8 +227,12 @@ void SessionTab::onConnectClicked()
|
||||
emit requestConnect(options.value());
|
||||
}
|
||||
|
||||
void SessionTab::onDisconnectClicked()
|
||||
void SessionTab::disconnectSession()
|
||||
{
|
||||
if (m_state == SessionState::Disconnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_useKodoTermForSsh) {
|
||||
if (m_sshTerminal != nullptr) {
|
||||
m_sshTerminal->kill();
|
||||
@@ -222,7 +244,7 @@ void SessionTab::onDisconnectClicked()
|
||||
emit requestDisconnect();
|
||||
}
|
||||
|
||||
void SessionTab::onReconnectClicked()
|
||||
void SessionTab::reconnectSession()
|
||||
{
|
||||
if (!validateProfileForConnect()) {
|
||||
return;
|
||||
@@ -248,17 +270,7 @@ void SessionTab::onReconnectClicked()
|
||||
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()
|
||||
void SessionTab::clearTerminal()
|
||||
{
|
||||
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||
m_sshTerminal->clearScrollback();
|
||||
@@ -275,6 +287,27 @@ void SessionTab::onClearTerminalClicked()
|
||||
}
|
||||
}
|
||||
|
||||
void SessionTab::setTerminalThemeName(const QString& themeName)
|
||||
{
|
||||
const QString normalized = themeName.trimmed();
|
||||
if (normalized.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_terminalThemeName.compare(normalized, Qt::CaseInsensitive) == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_terminalThemeName = normalized;
|
||||
applyTerminalTheme(m_terminalThemeName);
|
||||
appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName));
|
||||
}
|
||||
|
||||
QString SessionTab::terminalThemeName() const
|
||||
{
|
||||
return m_terminalThemeName;
|
||||
}
|
||||
|
||||
void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
|
||||
{
|
||||
setState(state, message);
|
||||
@@ -288,8 +321,7 @@ void SessionTab::onBackendEventLogged(const QString& 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);
|
||||
appendEvent(QStringLiteral("Error: %1").arg(displayMessage));
|
||||
}
|
||||
|
||||
void SessionTab::onBackendOutputReceived(const QString& text)
|
||||
@@ -321,94 +353,27 @@ 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_themeSelector = new QComboBox(this);
|
||||
m_themeSelector->addItems({QStringLiteral("Dark"),
|
||||
QStringLiteral("Light"),
|
||||
QStringLiteral("Solarized Dark")});
|
||||
m_themeSelector->setCurrentText(QStringLiteral("Dark"));
|
||||
m_clearTerminalButton = new QPushButton(QStringLiteral("Clear"), this);
|
||||
terminalHeader->addWidget(terminalLabel);
|
||||
terminalHeader->addStretch();
|
||||
terminalHeader->addWidget(new QLabel(QStringLiteral("Theme"), this));
|
||||
terminalHeader->addWidget(m_themeSelector);
|
||||
terminalHeader->addWidget(m_clearTerminalButton);
|
||||
|
||||
if (m_useKodoTermForSsh) {
|
||||
m_sshTerminal = new KodoTerm(this);
|
||||
QFont terminalFont(QStringLiteral("Monospace"));
|
||||
terminalFont.setStyleHint(QFont::TypeWriter);
|
||||
const QFont terminalFont = defaultTerminalFont();
|
||||
|
||||
KodoTermConfig config = m_sshTerminal->getConfig();
|
||||
config.font = terminalFont;
|
||||
config.textAntialiasing = true;
|
||||
config.maxScrollback = 12000;
|
||||
m_sshTerminal->setConfig(config);
|
||||
|
||||
applyTerminalTheme(m_themeSelector->currentText());
|
||||
|
||||
rootLayout->addLayout(detailsHeader);
|
||||
rootLayout->addWidget(m_detailsPanel);
|
||||
rootLayout->addLayout(terminalHeader);
|
||||
rootLayout->addWidget(m_sshTerminal, 1);
|
||||
} else {
|
||||
m_terminalOutput = new TerminalView(this);
|
||||
QFont terminalFont(QStringLiteral("Monospace"));
|
||||
terminalFont.setStyleHint(QFont::TypeWriter);
|
||||
m_terminalOutput->setFont(terminalFont);
|
||||
m_terminalOutput->setFont(defaultTerminalFont());
|
||||
m_terminalOutput->setMinimumHeight(260);
|
||||
m_terminalOutput->setPlaceholderText(
|
||||
QStringLiteral("Connect, then type directly here to interact with the SSH session."));
|
||||
|
||||
rootLayout->addLayout(detailsHeader);
|
||||
rootLayout->addWidget(m_detailsPanel);
|
||||
rootLayout->addLayout(terminalHeader);
|
||||
QStringLiteral("Session is connecting. Type directly here once connected."));
|
||||
rootLayout->addWidget(m_terminalOutput, 1);
|
||||
}
|
||||
|
||||
applyTerminalTheme(m_terminalThemeName);
|
||||
|
||||
auto* eventsHeader = new QHBoxLayout();
|
||||
m_toggleEventsButton = new QToolButton(this);
|
||||
m_toggleEventsButton->setCheckable(true);
|
||||
@@ -431,16 +396,8 @@ void SessionTab::setupUi()
|
||||
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,
|
||||
@@ -449,15 +406,6 @@ void SessionTab::setupUi()
|
||||
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);
|
||||
|
||||
if (m_terminalOutput != nullptr) {
|
||||
connect(m_terminalOutput,
|
||||
&TerminalView::inputGenerated,
|
||||
@@ -468,11 +416,6 @@ void SessionTab::setupUi()
|
||||
this,
|
||||
[this](int columns, int rows) { emit requestTerminalSize(columns, rows); });
|
||||
}
|
||||
|
||||
connect(m_themeSelector,
|
||||
&QComboBox::currentTextChanged,
|
||||
this,
|
||||
[this](const QString& themeName) { applyTerminalTheme(themeName); });
|
||||
}
|
||||
|
||||
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
|
||||
@@ -575,28 +518,11 @@ void SessionTab::appendEvent(const QString& 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));
|
||||
appendEvent(QStringLiteral("Connection state: %1").arg(message));
|
||||
|
||||
refreshActionButtons();
|
||||
emit tabTitleChanged(tabTitle());
|
||||
emit tabStateChanged(state);
|
||||
}
|
||||
|
||||
QString SessionTab::stateSuffix() const
|
||||
@@ -618,15 +544,6 @@ QString SessionTab::stateSuffix() const
|
||||
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());
|
||||
|
||||
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||
m_sshTerminal->setEnabled(true);
|
||||
@@ -699,7 +616,7 @@ bool SessionTab::startSshTerminal(const SessionConnectOptions& options)
|
||||
|
||||
if (keyPath.isEmpty()) {
|
||||
m_lastError = QStringLiteral("Private key path is required.");
|
||||
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(m_lastError));
|
||||
appendEvent(QStringLiteral("Error: %1").arg(m_lastError));
|
||||
setState(SessionState::Failed, m_lastError);
|
||||
return false;
|
||||
}
|
||||
@@ -729,7 +646,7 @@ bool SessionTab::startSshTerminal(const SessionConnectOptions& options)
|
||||
|
||||
if (!m_sshTerminal->start()) {
|
||||
m_lastError = QStringLiteral("Failed to start embedded SSH terminal process.");
|
||||
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(m_lastError));
|
||||
appendEvent(QStringLiteral("Error: %1").arg(m_lastError));
|
||||
setState(SessionState::Failed, QStringLiteral("Failed to start SSH terminal."));
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user