Complete Milestone 4 interactive SSH session UX

This commit is contained in:
Keith Smith
2026-03-01 11:00:31 -07:00
parent 776ddc1a53
commit 2b25f805cd
4 changed files with 190 additions and 190 deletions

View File

@@ -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;
}