Complete Milestone 4 interactive SSH session UX
This commit is contained in:
@@ -63,15 +63,18 @@ Git:
|
|||||||
|
|
||||||
## Milestone 4 - Interactive SSH Session UX
|
## Milestone 4 - Interactive SSH Session UX
|
||||||
|
|
||||||
Status: In Progress
|
Status: Completed
|
||||||
|
|
||||||
Started:
|
Delivered:
|
||||||
- Milestone 4 draft added to spec
|
- Embedded interactive SSH terminal using `KodoTerm` + vendored `libvterm`
|
||||||
- Interactive SSH terminal panel wiring started (backend output stream + input send path)
|
- Native in-terminal typing for SSH sessions (no separate input box)
|
||||||
- Host-key confirmation request/response signal flow added (`Ask` policy path)
|
- ANSI/color rendering with selectable terminal themes (`Dark`, `Light`, `Solarized Dark`)
|
||||||
- `known_hosts_policy` defaults and profile dialog options updated to include `Ask`
|
- Cross-platform SSH auth path improvements (`ssh-askpass` handling and host-key policy wiring)
|
||||||
|
- Session UX simplification: auto-connect on tab open, disconnect on tab close
|
||||||
|
- Tab-state indicators via tab color and state suffix (`Connecting`, `Connected`, `Disconnected`, `Failed`)
|
||||||
|
- Right-click tab menu for `Disconnect`, `Reconnect`, `Theme`, and `Clear`
|
||||||
|
- Collapsible events panel retained as primary diagnostics surface; inline detail/status banners removed
|
||||||
|
- Terminal behavior polish: better fixed-width font selection, cursor visibility, backspace handling, and terminal-size negotiation stability
|
||||||
|
|
||||||
Pending:
|
Git:
|
||||||
- End-to-end interactive behavior hardening across Linux/macOS/Windows
|
- Tag: `v0-m4-done` (pending push)
|
||||||
- Terminal UX polish (control keys, resize behavior, output formatting)
|
|
||||||
- Additional diagnostics and integration test coverage for reconnect/auth/host-key scenarios
|
|
||||||
|
|||||||
@@ -5,13 +5,11 @@
|
|||||||
|
|
||||||
#include <KodoTerm/KodoTerm.hpp>
|
#include <KodoTerm/KodoTerm.hpp>
|
||||||
|
|
||||||
#include <QClipboard>
|
|
||||||
#include <QComboBox>
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QGuiApplication>
|
#include <QFontDatabase>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
@@ -19,7 +17,6 @@
|
|||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPlainTextEdit>
|
#include <QPlainTextEdit>
|
||||||
#include <QProcessEnvironment>
|
#include <QProcessEnvironment>
|
||||||
#include <QPushButton>
|
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
@@ -28,6 +25,16 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
namespace {
|
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)
|
TerminalTheme themeForName(const QString& themeName)
|
||||||
{
|
{
|
||||||
if (themeName.compare(QStringLiteral("Light"), Qt::CaseInsensitive) == 0) {
|
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)
|
m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive)
|
||||||
== 0),
|
== 0),
|
||||||
m_state(SessionState::Disconnected),
|
m_state(SessionState::Disconnected),
|
||||||
m_statusLabel(nullptr),
|
m_terminalThemeName(QStringLiteral("Dark")),
|
||||||
m_errorLabel(nullptr),
|
|
||||||
m_sshTerminal(nullptr),
|
m_sshTerminal(nullptr),
|
||||||
m_terminalOutput(nullptr),
|
m_terminalOutput(nullptr),
|
||||||
m_eventLog(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_toggleEventsButton(nullptr),
|
||||||
m_detailsPanel(nullptr),
|
|
||||||
m_eventsPanel(nullptr)
|
m_eventsPanel(nullptr)
|
||||||
{
|
{
|
||||||
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
|
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
|
||||||
@@ -83,15 +81,26 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
|||||||
return;
|
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) {
|
if (exitCode == 0) {
|
||||||
setState(SessionState::Disconnected,
|
setState(SessionState::Disconnected,
|
||||||
QStringLiteral("SSH session ended."));
|
QStringLiteral("SSH session ended."));
|
||||||
} else {
|
return;
|
||||||
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."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
connect(m_sshTerminal,
|
||||||
&KodoTerm::cwdChanged,
|
&KodoTerm::cwdChanged,
|
||||||
@@ -170,10 +179,15 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
|
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
|
||||||
|
QTimer::singleShot(0, this, &SessionTab::connectSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionTab::~SessionTab()
|
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()) {
|
if (m_backend != nullptr && m_backendThread != nullptr && m_backendThread->isRunning()) {
|
||||||
QMetaObject::invokeMethod(m_backend, "disconnectSession", Qt::BlockingQueuedConnection);
|
QMetaObject::invokeMethod(m_backend, "disconnectSession", Qt::BlockingQueuedConnection);
|
||||||
m_backendThread->quit();
|
m_backendThread->quit();
|
||||||
@@ -186,8 +200,12 @@ QString SessionTab::tabTitle() const
|
|||||||
return QStringLiteral("%1 (%2)").arg(m_profile.name, stateSuffix());
|
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()) {
|
if (!validateProfileForConnect()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,8 +227,12 @@ void SessionTab::onConnectClicked()
|
|||||||
emit requestConnect(options.value());
|
emit requestConnect(options.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::onDisconnectClicked()
|
void SessionTab::disconnectSession()
|
||||||
{
|
{
|
||||||
|
if (m_state == SessionState::Disconnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (m_useKodoTermForSsh) {
|
if (m_useKodoTermForSsh) {
|
||||||
if (m_sshTerminal != nullptr) {
|
if (m_sshTerminal != nullptr) {
|
||||||
m_sshTerminal->kill();
|
m_sshTerminal->kill();
|
||||||
@@ -222,7 +244,7 @@ void SessionTab::onDisconnectClicked()
|
|||||||
emit requestDisconnect();
|
emit requestDisconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::onReconnectClicked()
|
void SessionTab::reconnectSession()
|
||||||
{
|
{
|
||||||
if (!validateProfileForConnect()) {
|
if (!validateProfileForConnect()) {
|
||||||
return;
|
return;
|
||||||
@@ -248,17 +270,7 @@ void SessionTab::onReconnectClicked()
|
|||||||
emit requestReconnect(options.value());
|
emit requestReconnect(options.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::onCopyErrorClicked()
|
void SessionTab::clearTerminal()
|
||||||
{
|
|
||||||
if (m_lastError.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QGuiApplication::clipboard()->setText(m_lastError);
|
|
||||||
appendEvent(QStringLiteral("Copied last error to clipboard."));
|
|
||||||
}
|
|
||||||
|
|
||||||
void SessionTab::onClearTerminalClicked()
|
|
||||||
{
|
{
|
||||||
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||||
m_sshTerminal->clearScrollback();
|
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)
|
void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
|
||||||
{
|
{
|
||||||
setState(state, message);
|
setState(state, message);
|
||||||
@@ -288,8 +321,7 @@ void SessionTab::onBackendEventLogged(const QString& message)
|
|||||||
void SessionTab::onBackendConnectionError(const QString& displayMessage, const QString& rawMessage)
|
void SessionTab::onBackendConnectionError(const QString& displayMessage, const QString& rawMessage)
|
||||||
{
|
{
|
||||||
m_lastError = rawMessage.isEmpty() ? displayMessage : rawMessage;
|
m_lastError = rawMessage.isEmpty() ? displayMessage : rawMessage;
|
||||||
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(displayMessage));
|
appendEvent(QStringLiteral("Error: %1").arg(displayMessage));
|
||||||
m_copyErrorButton->setEnabled(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionTab::onBackendOutputReceived(const QString& text)
|
void SessionTab::onBackendOutputReceived(const QString& text)
|
||||||
@@ -321,94 +353,27 @@ void SessionTab::setupUi()
|
|||||||
{
|
{
|
||||||
auto* rootLayout = new QVBoxLayout(this);
|
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) {
|
if (m_useKodoTermForSsh) {
|
||||||
m_sshTerminal = new KodoTerm(this);
|
m_sshTerminal = new KodoTerm(this);
|
||||||
QFont terminalFont(QStringLiteral("Monospace"));
|
const QFont terminalFont = defaultTerminalFont();
|
||||||
terminalFont.setStyleHint(QFont::TypeWriter);
|
|
||||||
|
|
||||||
KodoTermConfig config = m_sshTerminal->getConfig();
|
KodoTermConfig config = m_sshTerminal->getConfig();
|
||||||
config.font = terminalFont;
|
config.font = terminalFont;
|
||||||
|
config.textAntialiasing = true;
|
||||||
config.maxScrollback = 12000;
|
config.maxScrollback = 12000;
|
||||||
m_sshTerminal->setConfig(config);
|
m_sshTerminal->setConfig(config);
|
||||||
|
|
||||||
applyTerminalTheme(m_themeSelector->currentText());
|
|
||||||
|
|
||||||
rootLayout->addLayout(detailsHeader);
|
|
||||||
rootLayout->addWidget(m_detailsPanel);
|
|
||||||
rootLayout->addLayout(terminalHeader);
|
|
||||||
rootLayout->addWidget(m_sshTerminal, 1);
|
rootLayout->addWidget(m_sshTerminal, 1);
|
||||||
} else {
|
} else {
|
||||||
m_terminalOutput = new TerminalView(this);
|
m_terminalOutput = new TerminalView(this);
|
||||||
QFont terminalFont(QStringLiteral("Monospace"));
|
m_terminalOutput->setFont(defaultTerminalFont());
|
||||||
terminalFont.setStyleHint(QFont::TypeWriter);
|
|
||||||
m_terminalOutput->setFont(terminalFont);
|
|
||||||
m_terminalOutput->setMinimumHeight(260);
|
m_terminalOutput->setMinimumHeight(260);
|
||||||
m_terminalOutput->setPlaceholderText(
|
m_terminalOutput->setPlaceholderText(
|
||||||
QStringLiteral("Connect, then type directly here to interact with the SSH session."));
|
QStringLiteral("Session is connecting. Type directly here once connected."));
|
||||||
|
|
||||||
rootLayout->addLayout(detailsHeader);
|
|
||||||
rootLayout->addWidget(m_detailsPanel);
|
|
||||||
rootLayout->addLayout(terminalHeader);
|
|
||||||
rootLayout->addWidget(m_terminalOutput, 1);
|
rootLayout->addWidget(m_terminalOutput, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyTerminalTheme(m_terminalThemeName);
|
||||||
|
|
||||||
auto* eventsHeader = new QHBoxLayout();
|
auto* eventsHeader = new QHBoxLayout();
|
||||||
m_toggleEventsButton = new QToolButton(this);
|
m_toggleEventsButton = new QToolButton(this);
|
||||||
m_toggleEventsButton->setCheckable(true);
|
m_toggleEventsButton->setCheckable(true);
|
||||||
@@ -431,16 +396,8 @@ void SessionTab::setupUi()
|
|||||||
rootLayout->addLayout(eventsHeader);
|
rootLayout->addLayout(eventsHeader);
|
||||||
rootLayout->addWidget(m_eventsPanel);
|
rootLayout->addWidget(m_eventsPanel);
|
||||||
|
|
||||||
setPanelExpanded(m_toggleDetailsButton, m_detailsPanel, QStringLiteral("Details"), true);
|
|
||||||
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), false);
|
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,
|
connect(m_toggleEventsButton,
|
||||||
&QToolButton::toggled,
|
&QToolButton::toggled,
|
||||||
this,
|
this,
|
||||||
@@ -449,15 +406,6 @@ void SessionTab::setupUi()
|
|||||||
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
|
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) {
|
if (m_terminalOutput != nullptr) {
|
||||||
connect(m_terminalOutput,
|
connect(m_terminalOutput,
|
||||||
&TerminalView::inputGenerated,
|
&TerminalView::inputGenerated,
|
||||||
@@ -468,11 +416,6 @@ void SessionTab::setupUi()
|
|||||||
this,
|
this,
|
||||||
[this](int columns, int rows) { emit requestTerminalSize(columns, rows); });
|
[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()
|
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
|
||||||
@@ -575,28 +518,11 @@ void SessionTab::appendEvent(const QString& message)
|
|||||||
void SessionTab::setState(SessionState state, const QString& message)
|
void SessionTab::setState(SessionState state, const QString& message)
|
||||||
{
|
{
|
||||||
m_state = state;
|
m_state = state;
|
||||||
|
appendEvent(QStringLiteral("Connection state: %1").arg(message));
|
||||||
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();
|
refreshActionButtons();
|
||||||
emit tabTitleChanged(tabTitle());
|
emit tabTitleChanged(tabTitle());
|
||||||
|
emit tabStateChanged(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
QString SessionTab::stateSuffix() const
|
QString SessionTab::stateSuffix() const
|
||||||
@@ -618,15 +544,6 @@ QString SessionTab::stateSuffix() const
|
|||||||
void SessionTab::refreshActionButtons()
|
void SessionTab::refreshActionButtons()
|
||||||
{
|
{
|
||||||
const bool isConnected = m_state == SessionState::Connected;
|
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) {
|
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
|
||||||
m_sshTerminal->setEnabled(true);
|
m_sshTerminal->setEnabled(true);
|
||||||
@@ -699,7 +616,7 @@ bool SessionTab::startSshTerminal(const SessionConnectOptions& options)
|
|||||||
|
|
||||||
if (keyPath.isEmpty()) {
|
if (keyPath.isEmpty()) {
|
||||||
m_lastError = QStringLiteral("Private key path is required.");
|
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);
|
setState(SessionState::Failed, m_lastError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -729,7 +646,7 @@ bool SessionTab::startSshTerminal(const SessionConnectOptions& options)
|
|||||||
|
|
||||||
if (!m_sshTerminal->start()) {
|
if (!m_sshTerminal->start()) {
|
||||||
m_lastError = QStringLiteral("Failed to start embedded SSH terminal process.");
|
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."));
|
setState(SessionState::Failed, QStringLiteral("Failed to start SSH terminal."));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,9 @@
|
|||||||
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
#include <QPointer>
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
class QLabel;
|
|
||||||
class QComboBox;
|
|
||||||
class QPlainTextEdit;
|
class QPlainTextEdit;
|
||||||
class QPushButton;
|
|
||||||
class QThread;
|
class QThread;
|
||||||
class SessionBackend;
|
class SessionBackend;
|
||||||
class TerminalView;
|
class TerminalView;
|
||||||
@@ -28,9 +24,16 @@ public:
|
|||||||
~SessionTab() override;
|
~SessionTab() override;
|
||||||
|
|
||||||
QString tabTitle() const;
|
QString tabTitle() const;
|
||||||
|
void connectSession();
|
||||||
|
void disconnectSession();
|
||||||
|
void reconnectSession();
|
||||||
|
void clearTerminal();
|
||||||
|
void setTerminalThemeName(const QString& themeName);
|
||||||
|
QString terminalThemeName() const;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void tabTitleChanged(const QString& title);
|
void tabTitleChanged(const QString& title);
|
||||||
|
void tabStateChanged(SessionState state);
|
||||||
void requestConnect(const SessionConnectOptions& options);
|
void requestConnect(const SessionConnectOptions& options);
|
||||||
void requestDisconnect();
|
void requestDisconnect();
|
||||||
void requestReconnect(const SessionConnectOptions& options);
|
void requestReconnect(const SessionConnectOptions& options);
|
||||||
@@ -39,12 +42,6 @@ signals:
|
|||||||
void requestTerminalSize(int columns, int rows);
|
void requestTerminalSize(int columns, int rows);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onConnectClicked();
|
|
||||||
void onDisconnectClicked();
|
|
||||||
void onReconnectClicked();
|
|
||||||
void onCopyErrorClicked();
|
|
||||||
void onClearTerminalClicked();
|
|
||||||
|
|
||||||
void onBackendStateChanged(SessionState state, const QString& message);
|
void onBackendStateChanged(SessionState state, const QString& message);
|
||||||
void onBackendEventLogged(const QString& message);
|
void onBackendEventLogged(const QString& message);
|
||||||
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
||||||
@@ -59,21 +56,12 @@ private:
|
|||||||
SessionState m_state;
|
SessionState m_state;
|
||||||
QString m_lastError;
|
QString m_lastError;
|
||||||
SessionConnectOptions m_lastConnectOptions;
|
SessionConnectOptions m_lastConnectOptions;
|
||||||
|
QString m_terminalThemeName;
|
||||||
|
|
||||||
QLabel* m_statusLabel;
|
|
||||||
QLabel* m_errorLabel;
|
|
||||||
KodoTerm* m_sshTerminal;
|
KodoTerm* m_sshTerminal;
|
||||||
TerminalView* m_terminalOutput;
|
TerminalView* m_terminalOutput;
|
||||||
QPlainTextEdit* m_eventLog;
|
QPlainTextEdit* m_eventLog;
|
||||||
QPushButton* m_connectButton;
|
|
||||||
QPushButton* m_disconnectButton;
|
|
||||||
QPushButton* m_reconnectButton;
|
|
||||||
QPushButton* m_copyErrorButton;
|
|
||||||
QPushButton* m_clearTerminalButton;
|
|
||||||
QComboBox* m_themeSelector;
|
|
||||||
QToolButton* m_toggleDetailsButton;
|
|
||||||
QToolButton* m_toggleEventsButton;
|
QToolButton* m_toggleEventsButton;
|
||||||
QWidget* m_detailsPanel;
|
|
||||||
QWidget* m_eventsPanel;
|
QWidget* m_eventsPanel;
|
||||||
|
|
||||||
void setupUi();
|
void setupUi();
|
||||||
|
|||||||
@@ -2,8 +2,37 @@
|
|||||||
|
|
||||||
#include "session_tab.h"
|
#include "session_tab.h"
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTabBar>
|
||||||
#include <QTabWidget>
|
#include <QTabWidget>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
QColor tabColorForState(SessionState state, const QPalette& palette)
|
||||||
|
{
|
||||||
|
switch (state) {
|
||||||
|
case SessionState::Disconnected:
|
||||||
|
return palette.color(QPalette::WindowText);
|
||||||
|
case SessionState::Connecting:
|
||||||
|
return QColor(QStringLiteral("#9a6700"));
|
||||||
|
case SessionState::Connected:
|
||||||
|
return QColor(QStringLiteral("#2e7d32"));
|
||||||
|
case SessionState::Failed:
|
||||||
|
return QColor(QStringLiteral("#c62828"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return palette.color(QPalette::WindowText);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList terminalThemeNames()
|
||||||
|
{
|
||||||
|
return {QStringLiteral("Dark"), QStringLiteral("Light"), QStringLiteral("Solarized Dark")};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
||||||
: QMainWindow(parent), m_tabs(new QTabWidget(this))
|
: QMainWindow(parent), m_tabs(new QTabWidget(this))
|
||||||
{
|
{
|
||||||
@@ -16,12 +45,61 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
|||||||
this,
|
this,
|
||||||
[this](int index) {
|
[this](int index) {
|
||||||
QWidget* tab = m_tabs->widget(index);
|
QWidget* tab = m_tabs->widget(index);
|
||||||
|
if (auto* sessionTab = qobject_cast<SessionTab*>(tab)) {
|
||||||
|
sessionTab->disconnectSession();
|
||||||
|
}
|
||||||
m_tabs->removeTab(index);
|
m_tabs->removeTab(index);
|
||||||
delete tab;
|
delete tab;
|
||||||
if (m_tabs->count() == 0) {
|
if (m_tabs->count() == 0) {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
m_tabs->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
connect(m_tabs->tabBar(),
|
||||||
|
&QWidget::customContextMenuRequested,
|
||||||
|
this,
|
||||||
|
[this](const QPoint& pos) {
|
||||||
|
const int index = m_tabs->tabBar()->tabAt(pos);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* tab = qobject_cast<SessionTab*>(m_tabs->widget(index));
|
||||||
|
if (tab == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMenu menu(this);
|
||||||
|
QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect"));
|
||||||
|
QAction* reconnectAction = menu.addAction(QStringLiteral("Reconnect"));
|
||||||
|
menu.addSeparator();
|
||||||
|
QMenu* themeMenu = menu.addMenu(QStringLiteral("Theme"));
|
||||||
|
QList<QAction*> themeActions;
|
||||||
|
const QString currentTheme = tab->terminalThemeName();
|
||||||
|
for (const QString& themeName : terminalThemeNames()) {
|
||||||
|
QAction* themeAction = themeMenu->addAction(themeName);
|
||||||
|
themeAction->setCheckable(true);
|
||||||
|
themeAction->setChecked(
|
||||||
|
themeName.compare(currentTheme, Qt::CaseInsensitive) == 0);
|
||||||
|
themeActions.append(themeAction);
|
||||||
|
}
|
||||||
|
QAction* clearAction = menu.addAction(QStringLiteral("Clear"));
|
||||||
|
QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
|
||||||
|
if (chosen == disconnectAction) {
|
||||||
|
tab->disconnectSession();
|
||||||
|
} else if (chosen == reconnectAction) {
|
||||||
|
tab->reconnectSession();
|
||||||
|
} else if (chosen == clearAction) {
|
||||||
|
tab->clearTerminal();
|
||||||
|
} else {
|
||||||
|
for (QAction* themeAction : themeActions) {
|
||||||
|
if (chosen == themeAction) {
|
||||||
|
tab->setTerminalThemeName(themeAction->text());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setCentralWidget(m_tabs);
|
setCentralWidget(m_tabs);
|
||||||
addSessionTab(profile);
|
addSessionTab(profile);
|
||||||
@@ -32,11 +110,25 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
|||||||
auto* tab = new SessionTab(profile, this);
|
auto* tab = new SessionTab(profile, this);
|
||||||
const int index = m_tabs->addTab(tab, tab->tabTitle());
|
const int index = m_tabs->addTab(tab, tab->tabTitle());
|
||||||
m_tabs->setCurrentIndex(index);
|
m_tabs->setCurrentIndex(index);
|
||||||
|
m_tabs->tabBar()->setTabTextColor(
|
||||||
|
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
|
||||||
|
|
||||||
connect(tab,
|
connect(tab,
|
||||||
&SessionTab::tabTitleChanged,
|
&SessionTab::tabTitleChanged,
|
||||||
this,
|
this,
|
||||||
[this, tab](const QString& title) { updateTabTitle(tab, title); });
|
[this, tab](const QString& title) { updateTabTitle(tab, title); });
|
||||||
|
connect(tab,
|
||||||
|
&SessionTab::tabStateChanged,
|
||||||
|
this,
|
||||||
|
[this, tab](SessionState state) {
|
||||||
|
for (int i = 0; i < m_tabs->count(); ++i) {
|
||||||
|
if (m_tabs->widget(i) == tab) {
|
||||||
|
m_tabs->tabBar()->setTabTextColor(
|
||||||
|
i, tabColorForState(state, m_tabs->palette()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
|
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
|
||||||
|
|||||||
Reference in New Issue
Block a user