Allow direct terminal typing and collapsible session panels

This commit is contained in:
Keith Smith
2026-03-01 09:58:21 -07:00
parent 614d31fa71
commit 2b4f498259
5 changed files with 208 additions and 52 deletions

View File

@@ -27,6 +27,8 @@ add_executable(orbithub
src/session_backend_factory.h
src/session_tab.cpp
src/session_tab.h
src/terminal_view.cpp
src/terminal_view.h
src/session_window.cpp
src/session_window.h
src/ssh_session_backend.cpp

View File

@@ -1,6 +1,7 @@
#include "session_tab.h"
#include "session_backend_factory.h"
#include "terminal_view.h"
#include <QClipboard>
#include <QDateTime>
@@ -17,6 +18,7 @@
#include <QPushButton>
#include <QTextCursor>
#include <QThread>
#include <QToolButton>
#include <QVBoxLayout>
#include <memory>
@@ -30,14 +32,16 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
m_statusLabel(nullptr),
m_errorLabel(nullptr),
m_terminalOutput(nullptr),
m_terminalInput(nullptr),
m_eventLog(nullptr),
m_connectButton(nullptr),
m_disconnectButton(nullptr),
m_reconnectButton(nullptr),
m_copyErrorButton(nullptr),
m_sendInputButton(nullptr),
m_clearTerminalButton(nullptr)
m_clearTerminalButton(nullptr),
m_toggleDetailsButton(nullptr),
m_toggleEventsButton(nullptr),
m_detailsPanel(nullptr),
m_eventsPanel(nullptr)
{
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
qRegisterMetaType<SessionState>("SessionState");
@@ -164,17 +168,6 @@ void SessionTab::onCopyErrorClicked()
appendEvent(QStringLiteral("Copied last error to clipboard."));
}
void SessionTab::onSendInputClicked()
{
const QString input = m_terminalInput->text();
if (input.isEmpty()) {
return;
}
emit requestInput(input + QStringLiteral("\n"));
m_terminalInput->clear();
}
void SessionTab::onClearTerminalClicked()
{
m_terminalOutput->clear();
@@ -230,25 +223,35 @@ void SessionTab::setupUi()
{
auto* rootLayout = new QVBoxLayout(this);
auto* profileLabel = new QLabel(QStringLiteral("Profile: %1").arg(m_profile.name), 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)),
this);
auto* authLabel = new QLabel(QStringLiteral("Auth: %1").arg(m_profile.authMode), this);
m_detailsPanel);
auto* authLabel = new QLabel(QStringLiteral("Auth: %1").arg(m_profile.authMode), m_detailsPanel);
m_statusLabel = new QLabel(this);
m_errorLabel = new QLabel(QStringLiteral("Last Error: None"), this);
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"), this);
m_disconnectButton = new QPushButton(QStringLiteral("Disconnect"), this);
m_reconnectButton = new QPushButton(QStringLiteral("Reconnect"), this);
m_copyErrorButton = new QPushButton(QStringLiteral("Copy Error"), this);
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);
@@ -256,6 +259,13 @@ void SessionTab::setupUi()
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_clearTerminalButton = new QPushButton(QStringLiteral("Clear"), this);
@@ -263,49 +273,71 @@ void SessionTab::setupUi()
terminalHeader->addStretch();
terminalHeader->addWidget(m_clearTerminalButton);
m_terminalOutput = new QPlainTextEdit(this);
m_terminalOutput->setReadOnly(true);
m_terminalOutput = new TerminalView(this);
m_terminalOutput->setMaximumBlockCount(4000);
QFont terminalFont(QStringLiteral("Monospace"));
terminalFont.setStyleHint(QFont::TypeWriter);
m_terminalOutput->setFont(terminalFont);
m_terminalOutput->setMinimumHeight(220);
m_terminalOutput->setMinimumHeight(260);
m_terminalOutput->setPlaceholderText(
QStringLiteral("Connect, then type directly here to interact with the SSH session."));
auto* terminalInputRow = new QHBoxLayout();
m_terminalInput = new QLineEdit(this);
m_terminalInput->setPlaceholderText(QStringLiteral("Type command and press Enter..."));
m_sendInputButton = new QPushButton(QStringLiteral("Send"), this);
terminalInputRow->addWidget(m_terminalInput, 1);
terminalInputRow->addWidget(m_sendInputButton);
auto* eventsHeader = new QHBoxLayout();
m_toggleEventsButton = new QToolButton(this);
m_toggleEventsButton->setCheckable(true);
eventsHeader->addWidget(m_toggleEventsButton);
eventsHeader->addStretch();
auto* eventHeader = new QLabel(QStringLiteral("Session Events"), this);
m_eventLog = new QPlainTextEdit(this);
m_eventsPanel = new QWidget(this);
auto* eventsLayout = new QVBoxLayout(m_eventsPanel);
eventsLayout->setContentsMargins(0, 0, 0, 0);
auto* eventTitle = new QLabel(QStringLiteral("Session Events"), m_eventsPanel);
m_eventLog = new QPlainTextEdit(m_eventsPanel);
m_eventLog->setReadOnly(true);
m_eventLog->setPlaceholderText(QStringLiteral("Session event log..."));
m_eventLog->setMinimumHeight(140);
rootLayout->addWidget(profileLabel);
rootLayout->addWidget(endpointLabel);
rootLayout->addWidget(authLabel);
rootLayout->addWidget(m_statusLabel);
rootLayout->addWidget(m_errorLabel);
rootLayout->addLayout(actionRow);
eventsLayout->addWidget(eventTitle);
eventsLayout->addWidget(m_eventLog);
rootLayout->addLayout(detailsHeader);
rootLayout->addWidget(m_detailsPanel);
rootLayout->addLayout(terminalHeader);
rootLayout->addWidget(m_terminalOutput, 1);
rootLayout->addLayout(terminalInputRow);
rootLayout->addWidget(eventHeader);
rootLayout->addWidget(m_eventLog, 1);
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,
[this](bool expanded) {
setPanelExpanded(
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_sendInputButton, &QPushButton::clicked, this, &SessionTab::onSendInputClicked);
connect(m_terminalInput, &QLineEdit::returnPressed, this, &SessionTab::onSendInputClicked);
connect(m_clearTerminalButton,
&QPushButton::clicked,
this,
&SessionTab::onClearTerminalClicked);
connect(m_terminalOutput,
&TerminalView::inputGenerated,
this,
[this](const QString& input) { emit requestInput(input); });
}
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
@@ -455,6 +487,26 @@ void SessionTab::refreshActionButtons()
|| m_state == SessionState::Disconnected);
m_copyErrorButton->setEnabled(!m_lastError.isEmpty());
m_terminalInput->setEnabled(isConnected);
m_sendInputButton->setEnabled(isConnected);
m_terminalOutput->setEnabled(isConnected);
if (isConnected) {
m_terminalOutput->setFocus();
}
}
void SessionTab::setPanelExpanded(QToolButton* button,
QWidget* panel,
const QString& name,
bool expanded)
{
if (button == nullptr || panel == nullptr) {
return;
}
button->blockSignals(true);
button->setChecked(expanded);
button->blockSignals(false);
panel->setVisible(expanded);
button->setText(expanded ? QStringLiteral("Hide %1").arg(name)
: QStringLiteral("Show %1").arg(name));
}

View File

@@ -6,14 +6,16 @@
#include <QWidget>
#include <QPointer>
#include <optional>
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
class QThread;
class SessionBackend;
class TerminalView;
class QToolButton;
class SessionTab : public QWidget
{
@@ -38,7 +40,6 @@ private slots:
void onDisconnectClicked();
void onReconnectClicked();
void onCopyErrorClicked();
void onSendInputClicked();
void onClearTerminalClicked();
void onBackendStateChanged(SessionState state, const QString& message);
@@ -56,15 +57,17 @@ private:
QLabel* m_statusLabel;
QLabel* m_errorLabel;
QPlainTextEdit* m_terminalOutput;
QLineEdit* m_terminalInput;
TerminalView* m_terminalOutput;
QPlainTextEdit* m_eventLog;
QPushButton* m_connectButton;
QPushButton* m_disconnectButton;
QPushButton* m_reconnectButton;
QPushButton* m_copyErrorButton;
QPushButton* m_sendInputButton;
QPushButton* m_clearTerminalButton;
QToolButton* m_toggleDetailsButton;
QToolButton* m_toggleEventsButton;
QWidget* m_detailsPanel;
QWidget* m_eventsPanel;
void setupUi();
std::optional<SessionConnectOptions> buildConnectOptions();
@@ -73,6 +76,7 @@ private:
void setState(SessionState state, const QString& message);
QString stateSuffix() const;
void refreshActionButtons();
void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded);
};
#endif

78
src/terminal_view.cpp Normal file
View File

@@ -0,0 +1,78 @@
#include "terminal_view.h"
#include <QApplication>
#include <QClipboard>
#include <QKeyEvent>
TerminalView::TerminalView(QWidget* parent) : QPlainTextEdit(parent)
{
setReadOnly(true);
setUndoRedoEnabled(false);
}
void TerminalView::keyPressEvent(QKeyEvent* event)
{
if (event == nullptr) {
return;
}
const Qt::KeyboardModifiers modifiers = event->modifiers();
if (modifiers == Qt::ControlModifier) {
switch (event->key()) {
case Qt::Key_C:
emit inputGenerated(QStringLiteral("\x03"));
return;
case Qt::Key_D:
emit inputGenerated(QStringLiteral("\x04"));
return;
case Qt::Key_L:
emit inputGenerated(QStringLiteral("\x0c"));
return;
case Qt::Key_V: {
const QString clipboardText = QApplication::clipboard()->text();
if (!clipboardText.isEmpty()) {
emit inputGenerated(clipboardText);
}
return;
}
default:
break;
}
}
switch (event->key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
emit inputGenerated(QStringLiteral("\n"));
return;
case Qt::Key_Backspace:
emit inputGenerated(QStringLiteral("\x7f"));
return;
case Qt::Key_Tab:
emit inputGenerated(QStringLiteral("\t"));
return;
case Qt::Key_Left:
emit inputGenerated(QStringLiteral("\x1b[D"));
return;
case Qt::Key_Right:
emit inputGenerated(QStringLiteral("\x1b[C"));
return;
case Qt::Key_Up:
emit inputGenerated(QStringLiteral("\x1b[A"));
return;
case Qt::Key_Down:
emit inputGenerated(QStringLiteral("\x1b[B"));
return;
default:
break;
}
const QString text = event->text();
if (!text.isEmpty()) {
emit inputGenerated(text);
return;
}
QPlainTextEdit::keyPressEvent(event);
}

20
src/terminal_view.h Normal file
View File

@@ -0,0 +1,20 @@
#ifndef ORBITHUB_TERMINAL_VIEW_H
#define ORBITHUB_TERMINAL_VIEW_H
#include <QPlainTextEdit>
class TerminalView : public QPlainTextEdit
{
Q_OBJECT
public:
explicit TerminalView(QWidget* parent = nullptr);
signals:
void inputGenerated(const QString& input);
protected:
void keyPressEvent(QKeyEvent* event) override;
};
#endif