Complete Milestone 4 interactive SSH session UX
This commit is contained in:
@@ -63,15 +63,18 @@ Git:
|
||||
|
||||
## Milestone 4 - Interactive SSH Session UX
|
||||
|
||||
Status: In Progress
|
||||
Status: Completed
|
||||
|
||||
Started:
|
||||
- Milestone 4 draft added to spec
|
||||
- Interactive SSH terminal panel wiring started (backend output stream + input send path)
|
||||
- Host-key confirmation request/response signal flow added (`Ask` policy path)
|
||||
- `known_hosts_policy` defaults and profile dialog options updated to include `Ask`
|
||||
Delivered:
|
||||
- Embedded interactive SSH terminal using `KodoTerm` + vendored `libvterm`
|
||||
- Native in-terminal typing for SSH sessions (no separate input box)
|
||||
- ANSI/color rendering with selectable terminal themes (`Dark`, `Light`, `Solarized Dark`)
|
||||
- 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:
|
||||
- End-to-end interactive behavior hardening across Linux/macOS/Windows
|
||||
- Terminal UX polish (control keys, resize behavior, output formatting)
|
||||
- Additional diagnostics and integration test coverage for reconnect/auth/host-key scenarios
|
||||
Git:
|
||||
- Tag: `v0-m4-done` (pending push)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,9 @@
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include <QPointer>
|
||||
#include <optional>
|
||||
|
||||
class QLabel;
|
||||
class QComboBox;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class QThread;
|
||||
class SessionBackend;
|
||||
class TerminalView;
|
||||
@@ -28,9 +24,16 @@ public:
|
||||
~SessionTab() override;
|
||||
|
||||
QString tabTitle() const;
|
||||
void connectSession();
|
||||
void disconnectSession();
|
||||
void reconnectSession();
|
||||
void clearTerminal();
|
||||
void setTerminalThemeName(const QString& themeName);
|
||||
QString terminalThemeName() const;
|
||||
|
||||
signals:
|
||||
void tabTitleChanged(const QString& title);
|
||||
void tabStateChanged(SessionState state);
|
||||
void requestConnect(const SessionConnectOptions& options);
|
||||
void requestDisconnect();
|
||||
void requestReconnect(const SessionConnectOptions& options);
|
||||
@@ -39,12 +42,6 @@ signals:
|
||||
void requestTerminalSize(int columns, int rows);
|
||||
|
||||
private slots:
|
||||
void onConnectClicked();
|
||||
void onDisconnectClicked();
|
||||
void onReconnectClicked();
|
||||
void onCopyErrorClicked();
|
||||
void onClearTerminalClicked();
|
||||
|
||||
void onBackendStateChanged(SessionState state, const QString& message);
|
||||
void onBackendEventLogged(const QString& message);
|
||||
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
|
||||
@@ -59,21 +56,12 @@ private:
|
||||
SessionState m_state;
|
||||
QString m_lastError;
|
||||
SessionConnectOptions m_lastConnectOptions;
|
||||
QString m_terminalThemeName;
|
||||
|
||||
QLabel* m_statusLabel;
|
||||
QLabel* m_errorLabel;
|
||||
KodoTerm* m_sshTerminal;
|
||||
TerminalView* m_terminalOutput;
|
||||
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;
|
||||
QWidget* m_detailsPanel;
|
||||
QWidget* m_eventsPanel;
|
||||
|
||||
void setupUi();
|
||||
|
||||
@@ -2,8 +2,37 @@
|
||||
|
||||
#include "session_tab.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QColor>
|
||||
#include <QMenu>
|
||||
#include <QPalette>
|
||||
#include <QStringList>
|
||||
#include <QTabBar>
|
||||
#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)
|
||||
: QMainWindow(parent), m_tabs(new QTabWidget(this))
|
||||
{
|
||||
@@ -16,12 +45,61 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
|
||||
this,
|
||||
[this](int index) {
|
||||
QWidget* tab = m_tabs->widget(index);
|
||||
if (auto* sessionTab = qobject_cast<SessionTab*>(tab)) {
|
||||
sessionTab->disconnectSession();
|
||||
}
|
||||
m_tabs->removeTab(index);
|
||||
delete tab;
|
||||
if (m_tabs->count() == 0) {
|
||||
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);
|
||||
addSessionTab(profile);
|
||||
@@ -32,11 +110,25 @@ void SessionWindow::addSessionTab(const Profile& profile)
|
||||
auto* tab = new SessionTab(profile, this);
|
||||
const int index = m_tabs->addTab(tab, tab->tabTitle());
|
||||
m_tabs->setCurrentIndex(index);
|
||||
m_tabs->tabBar()->setTabTextColor(
|
||||
index, tabColorForState(SessionState::Disconnected, m_tabs->palette()));
|
||||
|
||||
connect(tab,
|
||||
&SessionTab::tabTitleChanged,
|
||||
this,
|
||||
[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)
|
||||
|
||||
Reference in New Issue
Block a user