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

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

View File

@@ -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_lastError = QStringLiteral("ssh exited with code %1").arg(exitCode);
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(m_lastError)); appendEvent(QStringLiteral("Error: %1").arg(m_lastError));
setState(SessionState::Failed, setState(SessionState::Failed,
QStringLiteral("SSH session exited unexpectedly.")); 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;
} }

View File

@@ -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();

View File

@@ -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)