Integrate KodoTerm for SSH terminal sessions

This commit is contained in:
Keith Smith
2026-03-01 10:36:06 -07:00
parent c3369b8e48
commit 776ddc1a53
503 changed files with 22870 additions and 95 deletions

View File

@@ -4,6 +4,8 @@
int main(int argc, char* argv[])
{
Q_INIT_RESOURCE(KodoTermThemes);
QApplication app(argc, argv);
ProfilesWindow window;

View File

@@ -3,6 +3,8 @@
#include "session_backend_factory.h"
#include "terminal_view.h"
#include <KodoTerm/KodoTerm.hpp>
#include <QClipboard>
#include <QComboBox>
#include <QDateTime>
@@ -16,21 +18,44 @@
#include <QLineEdit>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QProcessEnvironment>
#include <QPushButton>
#include <QThread>
#include <QTimer>
#include <QToolButton>
#include <QVBoxLayout>
#include <memory>
namespace {
TerminalTheme themeForName(const QString& themeName)
{
if (themeName.compare(QStringLiteral("Light"), Qt::CaseInsensitive) == 0) {
return TerminalTheme::loadKonsoleTheme(
QStringLiteral(":/KodoTermThemes/konsole/BlackOnWhite.colorscheme"));
}
if (themeName.compare(QStringLiteral("Solarized Dark"), Qt::CaseInsensitive) == 0) {
return TerminalTheme::loadKonsoleTheme(
QStringLiteral(":/KodoTermThemes/konsole/Solarized.colorscheme"));
}
return TerminalTheme::loadKonsoleTheme(
QStringLiteral(":/KodoTermThemes/konsole/Breeze.colorscheme"));
}
}
SessionTab::SessionTab(const Profile& profile, QWidget* parent)
: QWidget(parent),
m_profile(profile),
m_backendThread(new QThread(this)),
m_backendThread(nullptr),
m_backend(nullptr),
m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive)
== 0),
m_state(SessionState::Disconnected),
m_statusLabel(nullptr),
m_errorLabel(nullptr),
m_sshTerminal(nullptr),
m_terminalOutput(nullptr),
m_eventLog(nullptr),
m_connectButton(nullptr),
@@ -49,81 +74,111 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
setupUi();
std::unique_ptr<SessionBackend> backend = createSessionBackend(m_profile);
m_backend = backend.release();
m_backend->moveToThread(m_backendThread);
if (m_useKodoTermForSsh) {
connect(m_sshTerminal,
&KodoTerm::finished,
this,
[this](int exitCode, int) {
if (m_state == SessionState::Disconnected) {
return;
}
connect(m_backendThread, &QThread::finished, m_backend, &QObject::deleteLater);
connect(this,
&SessionTab::requestConnect,
m_backend,
&SessionBackend::connectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestDisconnect,
m_backend,
&SessionBackend::disconnectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestReconnect,
m_backend,
&SessionBackend::reconnectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestInput,
m_backend,
&SessionBackend::sendInput,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestHostKeyConfirmation,
m_backend,
&SessionBackend::confirmHostKey,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestTerminalSize,
m_backend,
&SessionBackend::updateTerminalSize,
Qt::QueuedConnection);
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."));
}
});
connect(m_sshTerminal,
&KodoTerm::cwdChanged,
this,
[this](const QString& cwd) {
if (!cwd.trimmed().isEmpty()) {
appendEvent(QStringLiteral("Remote cwd: %1").arg(cwd));
}
});
} else {
m_backendThread = new QThread(this);
connect(m_backend,
&SessionBackend::stateChanged,
this,
&SessionTab::onBackendStateChanged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::eventLogged,
this,
&SessionTab::onBackendEventLogged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::connectionError,
this,
&SessionTab::onBackendConnectionError,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::outputReceived,
this,
&SessionTab::onBackendOutputReceived,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::hostKeyConfirmationRequested,
this,
&SessionTab::onBackendHostKeyConfirmationRequested,
Qt::QueuedConnection);
std::unique_ptr<SessionBackend> backend = createSessionBackend(m_profile);
m_backend = backend.release();
m_backend->moveToThread(m_backendThread);
m_backendThread->start();
connect(m_backendThread, &QThread::finished, m_backend, &QObject::deleteLater);
connect(this,
&SessionTab::requestConnect,
m_backend,
&SessionBackend::connectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestDisconnect,
m_backend,
&SessionBackend::disconnectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestReconnect,
m_backend,
&SessionBackend::reconnectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestInput,
m_backend,
&SessionBackend::sendInput,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestHostKeyConfirmation,
m_backend,
&SessionBackend::confirmHostKey,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestTerminalSize,
m_backend,
&SessionBackend::updateTerminalSize,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::stateChanged,
this,
&SessionTab::onBackendStateChanged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::eventLogged,
this,
&SessionTab::onBackendEventLogged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::connectionError,
this,
&SessionTab::onBackendConnectionError,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::outputReceived,
this,
&SessionTab::onBackendOutputReceived,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::hostKeyConfirmationRequested,
this,
&SessionTab::onBackendHostKeyConfirmationRequested,
Qt::QueuedConnection);
m_backendThread->start();
}
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
}
SessionTab::~SessionTab()
{
if (m_backend != nullptr && m_backendThread->isRunning()) {
if (m_backend != nullptr && m_backendThread != nullptr && m_backendThread->isRunning()) {
QMetaObject::invokeMethod(m_backend, "disconnectSession", Qt::BlockingQueuedConnection);
m_backendThread->quit();
m_backendThread->wait(2000);
}
m_backendThread->quit();
m_backendThread->wait(2000);
}
QString SessionTab::tabTitle() const
@@ -142,11 +197,28 @@ void SessionTab::onConnectClicked()
return;
}
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
if (!startSshTerminal(options.value())) {
return;
}
return;
}
emit requestConnect(options.value());
}
void SessionTab::onDisconnectClicked()
{
if (m_useKodoTermForSsh) {
if (m_sshTerminal != nullptr) {
m_sshTerminal->kill();
}
setState(SessionState::Disconnected, QStringLiteral("Session disconnected."));
return;
}
emit requestDisconnect();
}
@@ -161,6 +233,18 @@ void SessionTab::onReconnectClicked()
return;
}
m_lastConnectOptions = options.value();
if (m_useKodoTermForSsh) {
if (m_sshTerminal != nullptr) {
m_sshTerminal->kill();
}
QTimer::singleShot(50,
this,
[this, options]() { startSshTerminal(options.value()); });
return;
}
emit requestReconnect(options.value());
}
@@ -176,12 +260,19 @@ void SessionTab::onCopyErrorClicked()
void SessionTab::onClearTerminalClicked()
{
m_terminalOutput->clear();
if (m_state == SessionState::Connected) {
// Ask the remote shell to repaint a prompt after local clear.
emit requestInput(QStringLiteral("\x0c"));
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->clearScrollback();
m_sshTerminal->setFocus();
return;
}
if (m_terminalOutput != nullptr) {
m_terminalOutput->clear();
if (m_state == SessionState::Connected) {
emit requestInput(QStringLiteral("\x0c"));
}
m_terminalOutput->setFocus();
}
m_terminalOutput->setFocus();
}
void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
@@ -203,7 +294,7 @@ void SessionTab::onBackendConnectionError(const QString& displayMessage, const Q
void SessionTab::onBackendOutputReceived(const QString& text)
{
if (text.isEmpty()) {
if (text.isEmpty() || m_terminalOutput == nullptr) {
return;
}
@@ -276,7 +367,9 @@ void SessionTab::setupUi()
auto* terminalHeader = new QHBoxLayout();
auto* terminalLabel = new QLabel(QStringLiteral("SSH Terminal"), this);
m_themeSelector = new QComboBox(this);
m_themeSelector->addItems(TerminalView::themeNames());
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);
@@ -285,13 +378,36 @@ void SessionTab::setupUi()
terminalHeader->addWidget(m_themeSelector);
terminalHeader->addWidget(m_clearTerminalButton);
m_terminalOutput = new TerminalView(this);
QFont terminalFont(QStringLiteral("Monospace"));
terminalFont.setStyleHint(QFont::TypeWriter);
m_terminalOutput->setFont(terminalFont);
m_terminalOutput->setMinimumHeight(260);
m_terminalOutput->setPlaceholderText(
QStringLiteral("Connect, then type directly here to interact with the SSH session."));
if (m_useKodoTermForSsh) {
m_sshTerminal = new KodoTerm(this);
QFont terminalFont(QStringLiteral("Monospace"));
terminalFont.setStyleHint(QFont::TypeWriter);
KodoTermConfig config = m_sshTerminal->getConfig();
config.font = terminalFont;
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->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);
rootLayout->addWidget(m_terminalOutput, 1);
}
auto* eventsHeader = new QHBoxLayout();
m_toggleEventsButton = new QToolButton(this);
@@ -312,10 +428,6 @@ void SessionTab::setupUi()
eventsLayout->addWidget(eventTitle);
eventsLayout->addWidget(m_eventLog);
rootLayout->addLayout(detailsHeader);
rootLayout->addWidget(m_detailsPanel);
rootLayout->addLayout(terminalHeader);
rootLayout->addWidget(m_terminalOutput, 1);
rootLayout->addLayout(eventsHeader);
rootLayout->addWidget(m_eventsPanel);
@@ -345,18 +457,22 @@ void SessionTab::setupUi()
&QPushButton::clicked,
this,
&SessionTab::onClearTerminalClicked);
connect(m_terminalOutput,
&TerminalView::inputGenerated,
this,
[this](const QString& input) { emit requestInput(input); });
connect(m_terminalOutput,
&TerminalView::terminalSizeChanged,
this,
[this](int columns, int rows) { emit requestTerminalSize(columns, rows); });
if (m_terminalOutput != nullptr) {
connect(m_terminalOutput,
&TerminalView::inputGenerated,
this,
[this](const QString& input) { emit requestInput(input); });
connect(m_terminalOutput,
&TerminalView::terminalSizeChanged,
this,
[this](int columns, int rows) { emit requestTerminalSize(columns, rows); });
}
connect(m_themeSelector,
&QComboBox::currentTextChanged,
m_terminalOutput,
&TerminalView::setThemeName);
this,
[this](const QString& themeName) { applyTerminalTheme(themeName); });
}
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
@@ -368,6 +484,12 @@ std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
return options;
}
if (m_useKodoTermForSsh
&& m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
// Password is entered directly in terminal prompt.
return options;
}
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
bool accepted = false;
const QString password = QInputDialog::getText(this,
@@ -506,8 +628,16 @@ void SessionTab::refreshActionButtons()
|| m_state == SessionState::Disconnected);
m_copyErrorButton->setEnabled(!m_lastError.isEmpty());
m_terminalOutput->setEnabled(isConnected);
m_terminalOutput->setFocus();
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
m_sshTerminal->setEnabled(true);
m_sshTerminal->setFocus();
return;
}
if (m_terminalOutput != nullptr) {
m_terminalOutput->setEnabled(isConnected);
m_terminalOutput->setFocus();
}
}
void SessionTab::setPanelExpanded(QToolButton* button,
@@ -527,3 +657,97 @@ void SessionTab::setPanelExpanded(QToolButton* button,
button->setText(expanded ? QStringLiteral("Hide %1").arg(name)
: QStringLiteral("Show %1").arg(name));
}
bool SessionTab::startSshTerminal(const SessionConnectOptions& options)
{
if (m_sshTerminal == nullptr) {
return false;
}
QStringList args;
args << QStringLiteral("-tt") << QStringLiteral("-p") << QString::number(m_profile.port)
<< QStringLiteral("-o") << QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveCountMax=2");
const QString policy = options.knownHostsPolicy.trimmed().isEmpty()
? m_profile.knownHostsPolicy.trimmed()
: options.knownHostsPolicy.trimmed();
if (policy.compare(QStringLiteral("Ignore"), Qt::CaseInsensitive) == 0) {
#ifdef Q_OS_WIN
const QString knownHostsNullDevice = QStringLiteral("NUL");
#else
const QString knownHostsNullDevice = QStringLiteral("/dev/null");
#endif
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=no")
<< QStringLiteral("-o")
<< QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsNullDevice);
} else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new");
} else if (policy.compare(QStringLiteral("Ask"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=ask");
} else {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes");
}
if (m_profile.authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
QString keyPath = options.privateKeyPath.trimmed();
if (keyPath.isEmpty()) {
keyPath = m_profile.privateKeyPath.trimmed();
}
if (keyPath.isEmpty()) {
m_lastError = QStringLiteral("Private key path is required.");
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(m_lastError));
setState(SessionState::Failed, m_lastError);
return false;
}
args << QStringLiteral("-i") << keyPath;
}
const QString target = m_profile.username.trimmed().isEmpty()
? m_profile.host.trimmed()
: QStringLiteral("%1@%2").arg(m_profile.username.trimmed(), m_profile.host.trimmed());
args << target;
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
if (!env.contains(QStringLiteral("TERM"))) {
env.insert(QStringLiteral("TERM"), QStringLiteral("xterm-256color"));
}
if (!env.contains(QStringLiteral("COLORTERM"))) {
env.insert(QStringLiteral("COLORTERM"), QStringLiteral("truecolor"));
}
m_sshTerminal->setProgram(QStringLiteral("ssh"));
m_sshTerminal->setArguments(args);
m_sshTerminal->setProcessEnvironment(env);
appendEvent(QStringLiteral("Launching SSH terminal session."));
setState(SessionState::Connecting, QStringLiteral("Starting SSH terminal..."));
if (!m_sshTerminal->start()) {
m_lastError = QStringLiteral("Failed to start embedded SSH terminal process.");
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(m_lastError));
setState(SessionState::Failed, QStringLiteral("Failed to start SSH terminal."));
return false;
}
setState(SessionState::Connected, QStringLiteral("SSH session established."));
return true;
}
void SessionTab::applyTerminalTheme(const QString& themeName)
{
if (m_useKodoTermForSsh) {
if (m_sshTerminal != nullptr) {
m_sshTerminal->setTheme(themeForName(themeName));
}
return;
}
if (m_terminalOutput != nullptr) {
m_terminalOutput->setThemeName(themeName);
}
}

View File

@@ -17,6 +17,7 @@ class QThread;
class SessionBackend;
class TerminalView;
class QToolButton;
class KodoTerm;
class SessionTab : public QWidget
{
@@ -54,11 +55,14 @@ private:
Profile m_profile;
QThread* m_backendThread;
SessionBackend* m_backend;
bool m_useKodoTermForSsh;
SessionState m_state;
QString m_lastError;
SessionConnectOptions m_lastConnectOptions;
QLabel* m_statusLabel;
QLabel* m_errorLabel;
KodoTerm* m_sshTerminal;
TerminalView* m_terminalOutput;
QPlainTextEdit* m_eventLog;
QPushButton* m_connectButton;
@@ -80,6 +84,8 @@ private:
QString stateSuffix() const;
void refreshActionButtons();
void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded);
bool startSshTerminal(const SessionConnectOptions& options);
void applyTerminalTheme(const QString& themeName);
};
#endif

View File

@@ -40,6 +40,7 @@ private:
ThemePalette m_palette;
QString m_pendingEscape;
QString m_rawHistory;
bool m_bold;
bool m_hasFgColor;
bool m_hasBgColor;
@@ -56,6 +57,7 @@ private:
void handleSgrSequence(const QString& params);
void appendTextChunk(const QString& text);
QColor paletteColor(bool background, int index, bool bright) const;
void processData(const QString& data, bool storeInHistory);
int terminalColumns() const;
int terminalRows() const;
void emitTerminalSize();