diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b8023a2..c25d72f 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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) diff --git a/src/session_tab.cpp b/src/session_tab.cpp index ba4313d..1b4e5b3 100644 --- a/src/session_tab.cpp +++ b/src/session_tab.cpp @@ -5,13 +5,11 @@ #include -#include -#include #include #include #include #include -#include +#include #include #include #include @@ -19,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -28,6 +25,16 @@ #include 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"); @@ -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("") : 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 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; } diff --git a/src/session_tab.h b/src/session_tab.h index 67d1025..0ac4f40 100644 --- a/src/session_tab.h +++ b/src/session_tab.h @@ -6,13 +6,9 @@ #include -#include #include -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(); diff --git a/src/session_window.cpp b/src/session_window.cpp index 93c81d6..9b99ae0 100644 --- a/src/session_window.cpp +++ b/src/session_window.cpp @@ -2,8 +2,37 @@ #include "session_tab.h" +#include +#include +#include +#include +#include +#include #include +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(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(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 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)