3 Commits

Author SHA1 Message Date
Keith Smith
ae9928782d docs: close out milestone 8 and set milestone 9 as current 2026-03-03 20:16:43 -07:00
Keith Smith
2485ffb14f docs: clarify Qt6 LGPLv3 licensing links in README 2026-03-03 20:13:12 -07:00
Keith Smith
eadcdd7f10 Milestone 8 UX: folder tree workflows, about dialog, and app icon polish 2026-03-03 20:07:41 -07:00
20 changed files with 1930 additions and 133 deletions

View File

@@ -76,11 +76,17 @@ set(WITH_WINPR_TOOLS OFF CACHE BOOL "" FORCE)
add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL) add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL)
add_executable(orbithub add_executable(orbithub
src/about_dialog.cpp
src/about_dialog.h
src/app_icon.cpp
src/app_icon.h
src/main.cpp src/main.cpp
src/profile_dialog.cpp src/profile_dialog.cpp
src/profile_dialog.h src/profile_dialog.h
src/profile_repository.cpp src/profile_repository.cpp
src/profile_repository.h src/profile_repository.h
src/profiles_tree_widget.cpp
src/profiles_tree_widget.h
src/profiles_window.cpp src/profiles_window.cpp
src/profiles_window.h src/profiles_window.h
src/session_backend.h src/session_backend.h

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# OrbitHub
OrbitHub is a cross-platform native desktop app for managing and launching remote sessions from one place.
It is implemented in C++17 with Qt6 Widgets and built with CMake.
Supported target platforms:
- Windows
- Linux
- macOS
## Current Status
OrbitHub is in active development.
- Milestones completed: M0-M5 and M8
- Current milestone: Milestone 9 (Packaging and Distribution)
- Deferred milestone: Milestone 6 (VNC Fully Working)
- Latest checkpoint tag: `v0-m8-wip1`
- VNC implementation milestone (M6) is currently deferred
Progress and milestone details:
- [docs/PROGRESS.md](docs/PROGRESS.md)
## Implemented Features
### Profile Management
- SQLite-backed profile storage
- Create, edit, delete profiles
- Protocol-aware profile validation (SSH/RDP/VNC)
- Profile search and sorting
- Tags support
- Folder/subfolder support
- `List` and `Folders` profile views
- Right-click profile tree actions:
- New Folder
- New Connection
- Drag-and-drop profile moves between folders with persistence
### Session Experience
- Multi-tab session window
- Auto-connect on tab open
- Disconnect on tab close
- Session state indicators on tabs
- Timestamped event log with filtering and export
### SSH
- Embedded interactive terminal (in-app typing)
- Theme support (`Dark`, `Light`, `Solarized Dark`)
- Password and private-key auth flows
- Known-hosts policy support
### RDP
- Embedded in-window RDP rendering surface (no external launcher)
- Keyboard/mouse input forwarding
- Resize handling and resolution renegotiation
- Domain-aware authentication support
- RDP security/performance profile options
### App UX
- App icon and themed About dialog
- `File` menu:
- New Profile
- New Folder
- Quit
- `Help` menu:
- About OrbitHub
## Build and Run
Detailed platform instructions:
- [docs/BUILDING.md](docs/BUILDING.md)
Quick start (Linux/macOS with Ninja):
```bash
cmake -S . -B build -G Ninja
cmake --build build
./build/orbithub
```
## Dependencies
Core dependencies:
- Qt 6 (Widgets, SQL)
- CMake 3.21+
- C++17 toolchain
Protocol/runtime dependencies:
- SSH client (`ssh`) available on `PATH` for SSH sessions
Bundled/vendored third-party components:
- KodoTerm
- libvterm
- FreeRDP/WinPR
## Licensing
Project license:
- MIT (see [LICENSE](LICENSE))
License links:
- MIT License: <https://opensource.org/licenses/MIT>
- GNU LGPLv3: <https://www.gnu.org/licenses/lgpl-3.0.html>
- Apache License 2.0: <https://www.apache.org/licenses/LICENSE-2.0>
Important third-party license notes:
- Qt6 is dynamically linked in this project build setup.
- Qt6 is used under LGPLv3 terms in this project build setup.
- KodoTerm and libvterm are MIT-licensed.
- FreeRDP/WinPR is Apache-2.0 licensed.
Repository license files:
- Project: [LICENSE](LICENSE)
- KodoTerm: [third_party/KodoTerm/LICENSE](third_party/KodoTerm/LICENSE)
- FreeRDP: [third_party/FreeRDP/LICENSE](third_party/FreeRDP/LICENSE)
See in-app `Help -> About OrbitHub` for license links and third-party inventory.
## Repository Structure
- `src/` - application source code
- `docs/` - build guide, spec, and progress tracking
- `third_party/` - vendored third-party dependencies
- `build/` - local build output (generated)
## Notes
- Passwords are requested at connect time and are not stored in the profile database.
- This repository currently prioritizes integrated SSH and RDP workflows while VNC implementation is pending.

View File

@@ -100,11 +100,11 @@ Delivered:
- Pulled FreeRDP source for integration planning and API review - Pulled FreeRDP source for integration planning and API review
Git: Git:
- Tag: pending (awaiting explicit approval before tagging/pushing) - Tag: `v0-m5-done`
## Milestone 6 - VNC Fully Working ## Milestone 6 - VNC Fully Working
Status: Planned Status: Deferred (temporarily postponed)
Planned Scope: Planned Scope:
- Replace current unsupported VNC path with complete VNC implementation - Replace current unsupported VNC path with complete VNC implementation
@@ -124,12 +124,28 @@ Planned Scope:
## Milestone 8 - Profile and Session UX Completion ## Milestone 8 - Profile and Session UX Completion
Status: Planned Status: Completed
Planned Scope: Delivered:
- Complete protocol-aware profile validation and UX polish - Added profile `tags` field to storage + schema migration and profile editor UX
- Add/persist session UI preferences and default behaviors - Added profile `folder_path` field + nested folder/subfolder profile view mode
- Improve events/diagnostics visibility for long-running session usage - Added profile tree context actions (`New Folder`, `New Connection`) and drag-to-folder profile moves with persistence
- Added `Help -> About OrbitHub` dialog with third-party library inventory and MIT/Apache-2.0 license links
- Extended profile search to include tags/folder path and added profile sort controls (`Name`, `Protocol`, `Host`)
- Persisted profile list UX preferences (`search text`, `view mode`, protocol/tag filters, `sort order`) across app restarts
- Added protocol-aware profile validation/normalization for SSH/RDP/VNC (repository + dialog)
- Improved profile form protocol UX hints and SSH private-key path validation
- Added session events filtering and tab-context actions (`Show/Hide Events`, `Copy Events`, `Clear Events`)
- Added session diagnostics QoL: severity quick-filter (`All/Warnings/Errors`) and `Export Events` action
- Persisted session UI defaults (`terminal theme`, `events panel visibility`) for new tabs/windows
- Added profile quick filters (`Protocol`, `Tag`) with persistence to speed profile browsing
Validation:
- Local build verification passed (`cmake --build build`)
- No automated tests are currently configured in CTest
Git:
- Tag: Pending user approval (`v0-m8-done`)
## Milestone 9 - Packaging and Distribution ## Milestone 9 - Packaging and Distribution

92
src/about_dialog.cpp Normal file
View File

@@ -0,0 +1,92 @@
#include "about_dialog.h"
#include <QApplication>
#include <QCoreApplication>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QTextBrowser>
#include <QVBoxLayout>
AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent)
{
setWindowTitle(QStringLiteral("About OrbitHub"));
setWindowIcon(QApplication::windowIcon());
resize(760, 560);
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 16, 16, 16);
layout->setSpacing(12);
auto* headerRow = new QHBoxLayout();
headerRow->setSpacing(12);
auto* iconLabel = new QLabel(this);
iconLabel->setFixedSize(80, 80);
iconLabel->setPixmap(QApplication::windowIcon().pixmap(80, 80));
iconLabel->setAlignment(Qt::AlignCenter);
auto* titleColumn = new QVBoxLayout();
titleColumn->setSpacing(4);
auto* title = new QLabel(QStringLiteral("<h1 style='margin:0'>OrbitHub</h1>"), this);
auto* subtitle = new QLabel(
QStringLiteral("Unified remote session manager for SSH, RDP, and VNC workflows."),
this);
subtitle->setWordWrap(true);
subtitle->setStyleSheet(QStringLiteral("color: palette(mid);"));
const QString version = QCoreApplication::applicationVersion().trimmed().isEmpty()
? QStringLiteral("Development build")
: QCoreApplication::applicationVersion().trimmed();
auto* buildLine = new QLabel(
QStringLiteral("Version: %1 | Qt runtime linked dynamically").arg(version),
this);
buildLine->setStyleSheet(QStringLiteral("color: palette(mid);"));
titleColumn->addWidget(title);
titleColumn->addWidget(subtitle);
titleColumn->addWidget(buildLine);
titleColumn->addStretch();
headerRow->addWidget(iconLabel, 0, Qt::AlignTop);
headerRow->addLayout(titleColumn, 1);
auto* browser = new QTextBrowser(this);
browser->setOpenExternalLinks(true);
browser->setStyleSheet(QStringLiteral(
"QTextBrowser { border: 1px solid palette(midlight); border-radius: 8px; padding: 8px; }"));
browser->setHtml(QStringLiteral(R"(
<h3 style="margin-top:0">Third-Party Libraries</h3>
<p>OrbitHub uses the following external libraries:</p>
<table cellspacing="0" cellpadding="6" border="1" style="border-collapse:collapse; width:100%;">
<tr><th align="left">Library</th><th align="left">License</th><th align="left">Upstream</th></tr>
<tr><td>Qt 6 (Widgets / SQL)</td><td>LGPLv3 / GPLv3 / Commercial</td><td><a href="https://www.qt.io/licensing">qt.io/licensing</a></td></tr>
<tr><td>KodoTerm</td><td>MIT</td><td><a href="https://github.com/diegoiast/KodoTerm">github.com/diegoiast/KodoTerm</a></td></tr>
<tr><td>libvterm</td><td>MIT</td><td><a href="https://github.com/neovim/libvterm">github.com/neovim/libvterm</a></td></tr>
<tr><td>FreeRDP / WinPR</td><td>Apache License 2.0</td><td><a href="https://github.com/FreeRDP/FreeRDP">github.com/FreeRDP/FreeRDP</a></td></tr>
</table>
<h3>License Links</h3>
<ul>
<li><a href="https://www.gnu.org/licenses/lgpl-3.0.html">GNU LGPLv3 (gnu.org)</a></li>
<li><a href="https://opensource.org/licenses/MIT">MIT License (opensource.org)</a></li>
<li><a href="https://www.apache.org/licenses/LICENSE-2.0">Apache License 2.0 (apache.org)</a></li>
</ul>
<h3>License Files In This Repository</h3>
<ul>
<li><code>third_party/KodoTerm/LICENSE</code></li>
<li><code>third_party/FreeRDP/LICENSE</code></li>
<li><code>LICENSE</code> (project license)</li>
</ul>
)"));
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
layout->addLayout(headerRow);
layout->addWidget(browser, 1);
layout->addWidget(buttons);
}

14
src/about_dialog.h Normal file
View File

@@ -0,0 +1,14 @@
#ifndef ORBITHUB_ABOUT_DIALOG_H
#define ORBITHUB_ABOUT_DIALOG_H
#include <QDialog>
class AboutDialog : public QDialog
{
Q_OBJECT
public:
explicit AboutDialog(QWidget* parent = nullptr);
};
#endif

101
src/app_icon.cpp Normal file
View File

@@ -0,0 +1,101 @@
#include "app_icon.h"
#include <QBrush>
#include <QColor>
#include <QLinearGradient>
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
namespace {
QPixmap renderIconPixmap(int size)
{
QPixmap pixmap(size, size);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
const qreal s = static_cast<qreal>(size);
const QRectF badgeRect(0.06 * s, 0.06 * s, 0.88 * s, 0.88 * s);
const qreal badgeRadius = 0.19 * s;
QLinearGradient badgeGradient(badgeRect.topLeft(), badgeRect.bottomRight());
badgeGradient.setColorAt(0.0, QColor(QStringLiteral("#0B1220")));
badgeGradient.setColorAt(1.0, QColor(QStringLiteral("#111827")));
QPainterPath badgePath;
badgePath.addRoundedRect(badgeRect, badgeRadius, badgeRadius);
painter.fillPath(badgePath, badgeGradient);
const QPointF center(0.5 * s, 0.5 * s);
const QRectF orbitRect(0.14 * s, 0.26 * s, 0.72 * s, 0.44 * s);
// Draw orbit behind monitor first.
QPen orbitBackPen(QColor(QStringLiteral("#38BDF8")));
orbitBackPen.setWidthF(0.06 * s);
orbitBackPen.setCapStyle(Qt::RoundCap);
painter.setPen(orbitBackPen);
painter.setBrush(Qt::NoBrush);
QTransform orbitTransform;
orbitTransform.translate(center.x(), center.y());
orbitTransform.rotate(-20.0);
orbitTransform.translate(-center.x(), -center.y());
painter.setTransform(orbitTransform);
painter.drawEllipse(orbitRect);
painter.resetTransform();
const QRectF monitorRect(0.2 * s, 0.2 * s, 0.6 * s, 0.44 * s);
const QRectF screenRect(0.24 * s, 0.24 * s, 0.52 * s, 0.34 * s);
const QRectF standStemRect(0.46 * s, 0.64 * s, 0.08 * s, 0.1 * s);
const QRectF standBaseRect(0.34 * s, 0.74 * s, 0.32 * s, 0.08 * s);
QPainterPath monitorPath;
monitorPath.addRoundedRect(monitorRect, 0.07 * s, 0.07 * s);
painter.fillPath(monitorPath, QColor(QStringLiteral("#1F2937")));
painter.setPen(QPen(QColor(QStringLiteral("#4B5563")), 0.016 * s));
painter.drawPath(monitorPath);
QLinearGradient screenGradient(screenRect.topLeft(), screenRect.bottomRight());
screenGradient.setColorAt(0.0, QColor(QStringLiteral("#22D3EE")));
screenGradient.setColorAt(0.55, QColor(QStringLiteral("#38BDF8")));
screenGradient.setColorAt(1.0, QColor(QStringLiteral("#0EA5E9")));
painter.fillRect(screenRect, screenGradient);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(QStringLiteral("#9CA3AF")));
painter.drawRoundedRect(standStemRect, 0.02 * s, 0.02 * s);
painter.setBrush(QColor(QStringLiteral("#6B7280")));
painter.drawRoundedRect(standBaseRect, 0.03 * s, 0.03 * s);
// Orbit front segment over monitor for depth.
QPen orbitFrontPen(QColor(QStringLiteral("#A3E635")));
orbitFrontPen.setWidthF(0.06 * s);
orbitFrontPen.setCapStyle(Qt::RoundCap);
painter.setPen(orbitFrontPen);
painter.setBrush(Qt::NoBrush);
painter.setTransform(orbitTransform);
painter.drawArc(orbitRect, 212 * 16, 126 * 16);
painter.drawArc(orbitRect, 6 * 16, 24 * 16);
painter.resetTransform();
// Small indicator star.
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(QStringLiteral("#E5E7EB")));
painter.drawEllipse(QPointF(0.77 * s, 0.2 * s), 0.02 * s, 0.02 * s);
return pixmap;
}
}
QIcon createOrbitHubAppIcon()
{
QIcon icon;
const int sizes[] = {16, 24, 32, 48, 64, 128, 256};
for (const int size : sizes) {
icon.addPixmap(renderIconPixmap(size));
}
return icon;
}

8
src/app_icon.h Normal file
View File

@@ -0,0 +1,8 @@
#ifndef ORBITHUB_APP_ICON_H
#define ORBITHUB_APP_ICON_H
#include <QIcon>
QIcon createOrbitHubAppIcon();
#endif

View File

@@ -1,3 +1,4 @@
#include "app_icon.h"
#include "profiles_window.h" #include "profiles_window.h"
#include <QApplication> #include <QApplication>
@@ -7,6 +8,9 @@ int main(int argc, char* argv[])
Q_INIT_RESOURCE(KodoTermThemes); Q_INIT_RESOURCE(KodoTermThemes);
QApplication app(argc, argv); QApplication app(argc, argv);
app.setOrganizationName(QStringLiteral("FireBugIT"));
app.setApplicationName(QStringLiteral("OrbitHub"));
app.setWindowIcon(createOrbitHubAppIcon());
ProfilesWindow window; ProfilesWindow window;
window.show(); window.show();

View File

@@ -3,6 +3,7 @@
#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
@@ -24,6 +25,28 @@ int standardPortForProtocol(const QString& protocol)
} }
return 22; // SSH default return 22; // SSH default
} }
QString normalizedProtocol(const QString& protocol)
{
if (protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
if (protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("VNC");
}
return QStringLiteral("SSH");
}
QString normalizedAuthMode(const QString& protocol, const QString& authMode)
{
if (protocol != QStringLiteral("SSH")) {
return QStringLiteral("Password");
}
if (authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Private Key");
}
return QStringLiteral("Password");
}
} }
ProfileDialog::ProfileDialog(QWidget* parent) ProfileDialog::ProfileDialog(QWidget* parent)
@@ -33,15 +56,18 @@ ProfileDialog::ProfileDialog(QWidget* parent)
m_portInput(new QSpinBox(this)), m_portInput(new QSpinBox(this)),
m_usernameInput(new QLineEdit(this)), m_usernameInput(new QLineEdit(this)),
m_domainInput(new QLineEdit(this)), m_domainInput(new QLineEdit(this)),
m_tagsInput(new QLineEdit(this)),
m_protocolInput(new QComboBox(this)), m_protocolInput(new QComboBox(this)),
m_authModeInput(new QComboBox(this)), m_authModeInput(new QComboBox(this)),
m_privateKeyPathInput(new QLineEdit(this)), m_privateKeyPathInput(new QLineEdit(this)),
m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)), m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)),
m_knownHostsPolicyInput(new QComboBox(this)), m_knownHostsPolicyInput(new QComboBox(this)),
m_rdpSecurityModeInput(new QComboBox(this)), m_rdpSecurityModeInput(new QComboBox(this)),
m_rdpPerformanceProfileInput(new QComboBox(this)) m_rdpPerformanceProfileInput(new QComboBox(this)),
m_protocolHint(new QLabel(this)),
m_folderHint(new QLabel(this))
{ {
resize(520, 340); resize(560, 360);
auto* layout = new QVBoxLayout(this); auto* layout = new QVBoxLayout(this);
auto* form = new QFormLayout(); auto* form = new QFormLayout();
@@ -52,6 +78,7 @@ ProfileDialog::ProfileDialog(QWidget* parent)
m_portInput->setValue(22); m_portInput->setValue(22);
m_usernameInput->setPlaceholderText(QStringLiteral("deploy")); m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
m_domainInput->setPlaceholderText(QStringLiteral("CONTOSO")); m_domainInput->setPlaceholderText(QStringLiteral("CONTOSO"));
m_tagsInput->setPlaceholderText(QStringLiteral("prod, linux, db"));
m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")}); m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")});
m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")}); m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")});
@@ -107,6 +134,7 @@ ProfileDialog::ProfileDialog(QWidget* parent)
form->addRow(QStringLiteral("Port"), m_portInput); form->addRow(QStringLiteral("Port"), m_portInput);
form->addRow(QStringLiteral("Username"), m_usernameInput); form->addRow(QStringLiteral("Username"), m_usernameInput);
form->addRow(QStringLiteral("Domain"), m_domainInput); form->addRow(QStringLiteral("Domain"), m_domainInput);
form->addRow(QStringLiteral("Tags"), m_tagsInput);
form->addRow(QStringLiteral("Protocol"), m_protocolInput); form->addRow(QStringLiteral("Protocol"), m_protocolInput);
form->addRow(QStringLiteral("Auth Mode"), m_authModeInput); form->addRow(QStringLiteral("Auth Mode"), m_authModeInput);
form->addRow(QStringLiteral("Private Key"), privateKeyRow); form->addRow(QStringLiteral("Private Key"), privateKeyRow);
@@ -118,12 +146,16 @@ ProfileDialog::ProfileDialog(QWidget* parent)
QStringLiteral("Passwords are requested at connect time and are not stored."), QStringLiteral("Passwords are requested at connect time and are not stored."),
this); this);
note->setWordWrap(true); note->setWordWrap(true);
m_protocolHint->setWordWrap(true);
m_folderHint->setWordWrap(true);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addLayout(form); layout->addLayout(form);
layout->addWidget(m_protocolHint);
layout->addWidget(m_folderHint);
layout->addWidget(note); layout->addWidget(note);
layout->addWidget(buttons); layout->addWidget(buttons);
@@ -135,6 +167,12 @@ void ProfileDialog::setDialogTitle(const QString& title)
setWindowTitle(title); setWindowTitle(title);
} }
void ProfileDialog::setDefaultFolderPath(const QString& folderPath)
{
m_defaultFolderPath = folderPath.trimmed();
refreshAuthFields();
}
void ProfileDialog::setProfile(const Profile& profile) void ProfileDialog::setProfile(const Profile& profile)
{ {
m_nameInput->setText(profile.name); m_nameInput->setText(profile.name);
@@ -142,6 +180,8 @@ void ProfileDialog::setProfile(const Profile& profile)
m_portInput->setValue(profile.port > 0 ? profile.port : 22); m_portInput->setValue(profile.port > 0 ? profile.port : 22);
m_usernameInput->setText(profile.username); m_usernameInput->setText(profile.username);
m_domainInput->setText(profile.domain); m_domainInput->setText(profile.domain);
m_defaultFolderPath = profile.folderPath.trimmed();
m_tagsInput->setText(profile.tags);
m_privateKeyPathInput->setText(profile.privateKeyPath); m_privateKeyPathInput->setText(profile.privateKeyPath);
const int protocolIndex = m_protocolInput->findText(profile.protocol); const int protocolIndex = m_protocolInput->findText(profile.protocol);
@@ -169,18 +209,31 @@ void ProfileDialog::setProfile(const Profile& profile)
Profile ProfileDialog::profile() const Profile ProfileDialog::profile() const
{ {
Profile profile; Profile profile;
const QString protocol = normalizedProtocol(m_protocolInput->currentText());
const QString authMode = normalizedAuthMode(protocol, m_authModeInput->currentText());
profile.id = -1; profile.id = -1;
profile.name = m_nameInput->text().trimmed(); profile.name = m_nameInput->text().trimmed();
profile.host = m_hostInput->text().trimmed(); profile.host = m_hostInput->text().trimmed();
profile.port = m_portInput->value(); profile.port = m_portInput->value();
profile.username = m_usernameInput->text().trimmed(); profile.username = m_usernameInput->text().trimmed();
profile.domain = m_domainInput->text().trimmed(); profile.domain = protocol == QStringLiteral("RDP") ? m_domainInput->text().trimmed() : QString();
profile.protocol = m_protocolInput->currentText(); profile.folderPath = m_defaultFolderPath.trimmed();
profile.authMode = m_authModeInput->currentText(); profile.tags = m_tagsInput->text().trimmed();
profile.privateKeyPath = m_privateKeyPathInput->text().trimmed(); profile.protocol = protocol;
profile.knownHostsPolicy = m_knownHostsPolicyInput->currentText(); profile.authMode = authMode;
profile.rdpSecurityMode = m_rdpSecurityModeInput->currentText(); profile.privateKeyPath = (protocol == QStringLiteral("SSH")
profile.rdpPerformanceProfile = m_rdpPerformanceProfileInput->currentText(); && authMode == QStringLiteral("Private Key"))
? m_privateKeyPathInput->text().trimmed()
: QString();
profile.knownHostsPolicy = protocol == QStringLiteral("SSH") ? m_knownHostsPolicyInput->currentText()
: QStringLiteral("Ask");
profile.rdpSecurityMode = protocol == QStringLiteral("RDP")
? m_rdpSecurityModeInput->currentText()
: QStringLiteral("Negotiate");
profile.rdpPerformanceProfile = protocol == QStringLiteral("RDP")
? m_rdpPerformanceProfileInput->currentText()
: QStringLiteral("Balanced");
return profile; return profile;
} }
@@ -209,14 +262,40 @@ void ProfileDialog::accept()
return; return;
} }
if (protocol == QStringLiteral("SSH")
&& m_authModeInput->currentText() == QStringLiteral("Private Key")) {
const QString privateKeyPath = m_privateKeyPathInput->text().trimmed();
if (privateKeyPath.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Private key path is required for SSH private key authentication."));
return;
}
if (!QFileInfo::exists(privateKeyPath)) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Private key file does not exist: %1").arg(privateKeyPath));
return;
}
}
QDialog::accept(); QDialog::accept();
} }
void ProfileDialog::refreshAuthFields() void ProfileDialog::refreshAuthFields()
{ {
const bool isSsh = m_protocolInput->currentText() == QStringLiteral("SSH"); const QString protocol = normalizedProtocol(m_protocolInput->currentText());
const bool isRdp = m_protocolInput->currentText() == QStringLiteral("RDP"); const bool isSsh = protocol == QStringLiteral("SSH");
const bool isPrivateKey = m_authModeInput->currentText() == QStringLiteral("Private Key"); const bool isRdp = protocol == QStringLiteral("RDP");
const bool isVnc = protocol == QStringLiteral("VNC");
const QString normalizedMode = normalizedAuthMode(protocol, m_authModeInput->currentText());
if (normalizedMode != m_authModeInput->currentText()) {
const QSignalBlocker blocker(m_authModeInput);
m_authModeInput->setCurrentText(normalizedMode);
}
const bool isPrivateKey = normalizedMode == QStringLiteral("Private Key");
m_authModeInput->setEnabled(isSsh); m_authModeInput->setEnabled(isSsh);
m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey); m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey);
@@ -225,4 +304,22 @@ void ProfileDialog::refreshAuthFields()
m_domainInput->setEnabled(isRdp); m_domainInput->setEnabled(isRdp);
m_rdpSecurityModeInput->setEnabled(isRdp); m_rdpSecurityModeInput->setEnabled(isRdp);
m_rdpPerformanceProfileInput->setEnabled(isRdp); m_rdpPerformanceProfileInput->setEnabled(isRdp);
if (isSsh) {
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
m_protocolHint->setText(
QStringLiteral("SSH: username is required. Choose Password or Private Key auth."));
} else if (isRdp) {
m_usernameInput->setPlaceholderText(QStringLiteral("Administrator"));
m_protocolHint->setText(
QStringLiteral("RDP: username and password are required. Domain is optional."));
} else if (isVnc) {
m_usernameInput->setPlaceholderText(QStringLiteral("optional"));
m_protocolHint->setText(
QStringLiteral("VNC: host and port are required. Username/domain are optional and ignored by most servers."));
}
m_folderHint->setText(m_defaultFolderPath.isEmpty()
? QStringLiteral("Target folder: root")
: QStringLiteral("Target folder: %1").arg(m_defaultFolderPath));
} }

View File

@@ -6,6 +6,7 @@
#include <QDialog> #include <QDialog>
class QComboBox; class QComboBox;
class QLabel;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class QSpinBox; class QSpinBox;
@@ -18,6 +19,7 @@ public:
explicit ProfileDialog(QWidget* parent = nullptr); explicit ProfileDialog(QWidget* parent = nullptr);
void setDialogTitle(const QString& title); void setDialogTitle(const QString& title);
void setDefaultFolderPath(const QString& folderPath);
void setProfile(const Profile& profile); void setProfile(const Profile& profile);
Profile profile() const; Profile profile() const;
@@ -30,6 +32,7 @@ private:
QSpinBox* m_portInput; QSpinBox* m_portInput;
QLineEdit* m_usernameInput; QLineEdit* m_usernameInput;
QLineEdit* m_domainInput; QLineEdit* m_domainInput;
QLineEdit* m_tagsInput;
QComboBox* m_protocolInput; QComboBox* m_protocolInput;
QComboBox* m_authModeInput; QComboBox* m_authModeInput;
QLineEdit* m_privateKeyPathInput; QLineEdit* m_privateKeyPathInput;
@@ -37,6 +40,9 @@ private:
QComboBox* m_knownHostsPolicyInput; QComboBox* m_knownHostsPolicyInput;
QComboBox* m_rdpSecurityModeInput; QComboBox* m_rdpSecurityModeInput;
QComboBox* m_rdpPerformanceProfileInput; QComboBox* m_rdpPerformanceProfileInput;
QLabel* m_protocolHint;
QLabel* m_folderHint;
QString m_defaultFolderPath;
void refreshAuthFields(); void refreshAuthFields();
}; };

View File

@@ -7,6 +7,7 @@
#include <QSqlQuery> #include <QSqlQuery>
#include <QStandardPaths> #include <QStandardPaths>
#include <QVariant> #include <QVariant>
#include <QStringList>
namespace { namespace {
QString buildDatabasePath() QString buildDatabasePath()
@@ -52,21 +53,130 @@ QString normalizedRdpPerformanceProfile(const QString& value)
return QStringLiteral("Balanced"); return QStringLiteral("Balanced");
} }
QString normalizedProtocol(const QString& value)
{
const QString protocol = value.trimmed();
if (protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("RDP");
}
if (protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("VNC");
}
return QStringLiteral("SSH");
}
QString normalizedAuthMode(const QString& protocol, const QString& value)
{
if (protocol != QStringLiteral("SSH")) {
return QStringLiteral("Password");
}
const QString authMode = value.trimmed();
if (authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Private Key");
}
return QStringLiteral("Password");
}
QString normalizedKnownHostsPolicy(const QString& value)
{
const QString policy = value.trimmed();
if (policy.compare(QStringLiteral("Strict"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Strict");
}
if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Accept New");
}
if (policy.compare(QStringLiteral("Ignore"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("Ignore");
}
return QStringLiteral("Ask");
}
QString normalizedFolderPath(const QString& value)
{
QString path = value.trimmed();
path.replace(QChar::fromLatin1('\\'), QChar::fromLatin1('/'));
const QStringList rawParts = path.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
QStringList normalized;
for (const QString& rawPart : rawParts) {
const QString part = rawPart.trimmed();
if (!part.isEmpty()) {
normalized.push_back(part);
}
}
return normalized.join(QStringLiteral("/"));
}
QString normalizedTags(const QString& value)
{
const QStringList rawTokens = value.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
QStringList normalized;
QSet<QString> dedupe;
for (const QString& token : rawTokens) {
const QString trimmed = token.trimmed();
if (trimmed.isEmpty()) {
continue;
}
const QString key = trimmed.toLower();
if (dedupe.contains(key)) {
continue;
}
dedupe.insert(key);
normalized.push_back(trimmed);
}
return normalized.join(QStringLiteral(", "));
}
QString orderByClause(ProfileSortOrder sortOrder)
{
switch (sortOrder) {
case ProfileSortOrder::ProtocolAsc:
return QStringLiteral("ORDER BY lower(protocol) ASC, lower(name) ASC, id ASC");
case ProfileSortOrder::HostAsc:
return QStringLiteral("ORDER BY lower(host) ASC, lower(name) ASC, id ASC");
case ProfileSortOrder::NameAsc:
default:
return QStringLiteral("ORDER BY lower(name) ASC, id ASC");
}
}
QString nonNullTrimmed(const QString& value)
{
const QString trimmed = value.trimmed();
return trimmed.isNull() ? QStringLiteral("") : trimmed;
}
void bindProfileFields(QSqlQuery& query, const Profile& profile) void bindProfileFields(QSqlQuery& query, const Profile& profile)
{ {
query.addBindValue(profile.name.trimmed()); const QString protocol = normalizedProtocol(profile.protocol);
query.addBindValue(profile.host.trimmed()); const QString authMode = normalizedAuthMode(protocol, profile.authMode);
const bool isSsh = protocol == QStringLiteral("SSH");
const bool isRdp = protocol == QStringLiteral("RDP");
query.addBindValue(nonNullTrimmed(profile.name));
query.addBindValue(nonNullTrimmed(profile.host));
query.addBindValue(profile.port); query.addBindValue(profile.port);
query.addBindValue(profile.username.trimmed()); query.addBindValue(nonNullTrimmed(profile.username));
query.addBindValue(profile.domain.trimmed()); query.addBindValue(isRdp ? nonNullTrimmed(profile.domain) : QStringLiteral(""));
query.addBindValue(profile.protocol.trimmed()); query.addBindValue(nonNullTrimmed(normalizedFolderPath(profile.folderPath)));
query.addBindValue(profile.authMode.trimmed()); query.addBindValue(protocol);
query.addBindValue(profile.privateKeyPath.trimmed()); query.addBindValue(authMode);
query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty() query.addBindValue((isSsh && authMode == QStringLiteral("Private Key"))
? QStringLiteral("Ask") ? nonNullTrimmed(profile.privateKeyPath)
: profile.knownHostsPolicy.trimmed()); : QStringLiteral(""));
query.addBindValue(normalizedRdpSecurityMode(profile.rdpSecurityMode)); query.addBindValue(isSsh ? normalizedKnownHostsPolicy(profile.knownHostsPolicy)
query.addBindValue(normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)); : QStringLiteral("Ask"));
query.addBindValue(isRdp ? normalizedRdpSecurityMode(profile.rdpSecurityMode)
: QStringLiteral("Negotiate"));
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
: QStringLiteral("Balanced"));
query.addBindValue(normalizedTags(profile.tags));
} }
Profile profileFromQuery(const QSqlQuery& query) Profile profileFromQuery(const QSqlQuery& query)
@@ -78,22 +188,67 @@ Profile profileFromQuery(const QSqlQuery& query)
profile.port = query.value(3).toInt(); profile.port = query.value(3).toInt();
profile.username = query.value(4).toString(); profile.username = query.value(4).toString();
profile.domain = query.value(5).toString(); profile.domain = query.value(5).toString();
profile.protocol = query.value(6).toString(); profile.folderPath = normalizedFolderPath(query.value(6).toString());
profile.authMode = query.value(7).toString(); profile.protocol = normalizedProtocol(query.value(7).toString());
profile.privateKeyPath = query.value(8).toString(); profile.authMode = normalizedAuthMode(profile.protocol, query.value(8).toString());
profile.knownHostsPolicy = query.value(9).toString(); profile.privateKeyPath = profile.authMode == QStringLiteral("Private Key")
if (profile.knownHostsPolicy.isEmpty()) { ? query.value(9).toString().trimmed()
profile.knownHostsPolicy = QStringLiteral("Ask"); : QString();
} profile.knownHostsPolicy = profile.protocol == QStringLiteral("SSH")
profile.rdpSecurityMode = normalizedRdpSecurityMode(query.value(10).toString()); ? normalizedKnownHostsPolicy(query.value(10).toString())
profile.rdpPerformanceProfile = normalizedRdpPerformanceProfile(query.value(11).toString()); : QStringLiteral("Ask");
profile.rdpSecurityMode = profile.protocol == QStringLiteral("RDP")
? normalizedRdpSecurityMode(query.value(11).toString())
: QStringLiteral("Negotiate");
profile.rdpPerformanceProfile = profile.protocol == QStringLiteral("RDP")
? normalizedRdpPerformanceProfile(query.value(12).toString())
: QStringLiteral("Balanced");
profile.tags = normalizedTags(query.value(13).toString());
return profile; return profile;
} }
bool isProfileValid(const Profile& profile) bool isProfileValid(const Profile& profile, QString* error)
{ {
return !profile.name.trimmed().isEmpty() && !profile.host.trimmed().isEmpty() if (profile.name.trimmed().isEmpty()) {
&& profile.port >= 1 && profile.port <= 65535; if (error != nullptr) {
*error = QStringLiteral("Profile name is required.");
}
return false;
}
if (profile.host.trimmed().isEmpty()) {
if (error != nullptr) {
*error = QStringLiteral("Host is required.");
}
return false;
}
if (profile.port < 1 || profile.port > 65535) {
if (error != nullptr) {
*error = QStringLiteral("Port must be between 1 and 65535.");
}
return false;
}
const QString protocol = normalizedProtocol(profile.protocol);
if ((protocol == QStringLiteral("SSH") || protocol == QStringLiteral("RDP"))
&& profile.username.trimmed().isEmpty()) {
if (error != nullptr) {
*error = QStringLiteral("Username is required for %1 profiles.").arg(protocol);
}
return false;
}
const QString authMode = normalizedAuthMode(protocol, profile.authMode);
if (protocol == QStringLiteral("SSH") && authMode == QStringLiteral("Private Key")
&& profile.privateKeyPath.trimmed().isEmpty()) {
if (error != nullptr) {
*error = QStringLiteral("Private key path is required for SSH private key authentication.");
}
return false;
}
return true;
} }
} }
@@ -125,7 +280,60 @@ QString ProfileRepository::lastError() const
return m_lastError; return m_lastError;
} }
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const std::vector<QString> ProfileRepository::listFolders() const
{
std::vector<QString> result;
if (!QSqlDatabase::contains(m_connectionName)) {
return result;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("SELECT path FROM profile_folders ORDER BY lower(path) ASC"));
if (!query.exec()) {
setLastError(query.lastError().text());
return result;
}
while (query.next()) {
const QString normalized = normalizedFolderPath(query.value(0).toString());
if (!normalized.isEmpty()) {
result.push_back(normalized);
}
}
return result;
}
bool ProfileRepository::createFolder(const QString& folderPath) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
const QString normalized = normalizedFolderPath(folderPath);
if (normalized.isEmpty()) {
setLastError(QStringLiteral("Folder path is required."));
return false;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("INSERT OR IGNORE INTO profile_folders(path) VALUES (?)"));
query.addBindValue(normalized);
if (!query.exec()) {
setLastError(query.lastError().text());
return false;
}
return true;
}
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery,
ProfileSortOrder sortOrder) const
{ {
std::vector<Profile> result; std::vector<Profile> result;
@@ -136,20 +344,23 @@ std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery)
setLastError(QString()); setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
const QString orderBy = orderByClause(sortOrder);
if (searchQuery.trimmed().isEmpty()) { if (searchQuery.trimmed().isEmpty()) {
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles " "FROM profiles ")
"ORDER BY lower(name) ASC, id ASC")); + orderBy);
} else { } else {
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles " "FROM profiles "
"WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) " "WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) OR lower(tags) LIKE lower(?) OR lower(folder_path) LIKE lower(?) ")
"ORDER BY lower(name) ASC, id ASC")); + orderBy);
const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%"); const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%");
query.addBindValue(search); query.addBindValue(search);
query.addBindValue(search); query.addBindValue(search);
query.addBindValue(search);
query.addBindValue(search);
} }
if (!query.exec()) { if (!query.exec()) {
@@ -174,7 +385,7 @@ std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile " "SELECT id, name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags "
"FROM profiles WHERE id = ?")); "FROM profiles WHERE id = ?"));
query.addBindValue(id); query.addBindValue(id);
@@ -198,15 +409,16 @@ std::optional<Profile> ProfileRepository::createProfile(const Profile& profile)
setLastError(QString()); setLastError(QString());
if (!isProfileValid(profile)) { QString validationError;
setLastError(QStringLiteral("Name, host, and a valid port are required.")); if (!isProfileValid(profile, &validationError)) {
setLastError(validationError);
return std::nullopt; return std::nullopt;
} }
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"INSERT INTO profiles(name, host, port, username, domain, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile) " "INSERT INTO profiles(name, host, port, username, domain, folder_path, protocol, auth_mode, private_key_path, known_hosts_policy, rdp_security_mode, rdp_performance_profile, tags) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")); "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"));
bindProfileFields(query, profile); bindProfileFields(query, profile);
if (!query.exec()) { if (!query.exec()) {
@@ -227,15 +439,17 @@ bool ProfileRepository::updateProfile(const Profile& profile) const
setLastError(QString()); setLastError(QString());
if (profile.id < 0 || !isProfileValid(profile)) { QString validationError;
setLastError(QStringLiteral("Invalid profile data.")); if (profile.id < 0 || !isProfileValid(profile, &validationError)) {
setLastError(validationError.isEmpty() ? QStringLiteral("Invalid profile data.")
: validationError);
return false; return false;
} }
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral( query.prepare(QStringLiteral(
"UPDATE profiles " "UPDATE profiles "
"SET name = ?, host = ?, port = ?, username = ?, domain = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ?, rdp_security_mode = ?, rdp_performance_profile = ? " "SET name = ?, host = ?, port = ?, username = ?, domain = ?, folder_path = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ?, rdp_security_mode = ?, rdp_performance_profile = ?, tags = ? "
"WHERE id = ?")); "WHERE id = ?"));
bindProfileFields(query, profile); bindProfileFields(query, profile);
query.addBindValue(profile.id); query.addBindValue(profile.id);
@@ -287,12 +501,14 @@ bool ProfileRepository::initializeDatabase()
"port INTEGER NOT NULL DEFAULT 22," "port INTEGER NOT NULL DEFAULT 22,"
"username TEXT NOT NULL DEFAULT ''," "username TEXT NOT NULL DEFAULT '',"
"domain TEXT NOT NULL DEFAULT ''," "domain TEXT NOT NULL DEFAULT '',"
"folder_path TEXT NOT NULL DEFAULT '',"
"protocol TEXT NOT NULL DEFAULT 'SSH'," "protocol TEXT NOT NULL DEFAULT 'SSH',"
"auth_mode TEXT NOT NULL DEFAULT 'Password'," "auth_mode TEXT NOT NULL DEFAULT 'Password',"
"private_key_path TEXT NOT NULL DEFAULT ''," "private_key_path TEXT NOT NULL DEFAULT '',"
"known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'," "known_hosts_policy TEXT NOT NULL DEFAULT 'Ask',"
"rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'," "rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate',"
"rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'" "rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced',"
"tags TEXT NOT NULL DEFAULT ''"
")")); ")"));
if (!created) { if (!created) {
@@ -300,6 +516,15 @@ bool ProfileRepository::initializeDatabase()
return false; return false;
} }
const bool foldersCreated = query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS profile_folders ("
"path TEXT PRIMARY KEY NOT NULL"
")"));
if (!foldersCreated) {
m_initError = query.lastError().text();
return false;
}
if (!ensureProfileSchema()) { if (!ensureProfileSchema()) {
m_initError = m_lastError; m_initError = m_lastError;
return false; return false;
@@ -336,12 +561,14 @@ bool ProfileRepository::ensureProfileSchema() const
{QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")}, {QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")},
{QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("domain"), QStringLiteral("ALTER TABLE profiles ADD COLUMN domain TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("domain"), QStringLiteral("ALTER TABLE profiles ADD COLUMN domain TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("folder_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")}, {QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")},
{QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")}, {QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")},
{QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")}, {QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'")}, {QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'")},
{QStringLiteral("rdp_security_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'")}, {QStringLiteral("rdp_security_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_security_mode TEXT NOT NULL DEFAULT 'Negotiate'")},
{QStringLiteral("rdp_performance_profile"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'")}}; {QStringLiteral("rdp_performance_profile"), QStringLiteral("ALTER TABLE profiles ADD COLUMN rdp_performance_profile TEXT NOT NULL DEFAULT 'Balanced'")},
{QStringLiteral("tags"), QStringLiteral("ALTER TABLE profiles ADD COLUMN tags TEXT NOT NULL DEFAULT ''")}};
for (const ColumnDef& column : required) { for (const ColumnDef& column : required) {
if (columns.contains(column.name)) { if (columns.contains(column.name)) {
@@ -355,6 +582,15 @@ bool ProfileRepository::ensureProfileSchema() const
} }
} }
QSqlQuery ensureFolders(QSqlDatabase::database(m_connectionName));
if (!ensureFolders.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS profile_folders ("
"path TEXT PRIMARY KEY NOT NULL"
")"))) {
setLastError(ensureFolders.lastError().text());
return false;
}
setLastError(QString()); setLastError(QString());
return true; return true;
} }

View File

@@ -15,12 +15,20 @@ struct Profile
int port = 22; int port = 22;
QString username; QString username;
QString domain; QString domain;
QString folderPath;
QString protocol = QStringLiteral("SSH"); QString protocol = QStringLiteral("SSH");
QString authMode = QStringLiteral("Password"); QString authMode = QStringLiteral("Password");
QString privateKeyPath; QString privateKeyPath;
QString knownHostsPolicy = QStringLiteral("Ask"); QString knownHostsPolicy = QStringLiteral("Ask");
QString rdpSecurityMode = QStringLiteral("Negotiate"); QString rdpSecurityMode = QStringLiteral("Negotiate");
QString rdpPerformanceProfile = QStringLiteral("Balanced"); QString rdpPerformanceProfile = QStringLiteral("Balanced");
QString tags;
};
enum class ProfileSortOrder {
NameAsc,
ProtocolAsc,
HostAsc,
}; };
class ProfileRepository class ProfileRepository
@@ -32,7 +40,10 @@ public:
QString initError() const; QString initError() const;
QString lastError() const; QString lastError() const;
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const; std::vector<Profile> listProfiles(const QString& searchQuery = QString(),
ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const;
std::vector<QString> listFolders() const;
bool createFolder(const QString& folderPath) const;
std::optional<Profile> getProfile(qint64 id) const; std::optional<Profile> getProfile(qint64 id) const;
std::optional<Profile> createProfile(const Profile& profile) const; std::optional<Profile> createProfile(const Profile& profile) const;
bool updateProfile(const Profile& profile) const; bool updateProfile(const Profile& profile) const;

View File

@@ -0,0 +1,11 @@
#include "profiles_tree_widget.h"
#include <QDropEvent>
ProfilesTreeWidget::ProfilesTreeWidget(QWidget* parent) : QTreeWidget(parent) {}
void ProfilesTreeWidget::dropEvent(QDropEvent* event)
{
QTreeWidget::dropEvent(event);
emit itemsDropped();
}

View File

@@ -0,0 +1,22 @@
#ifndef ORBITHUB_PROFILES_TREE_WIDGET_H
#define ORBITHUB_PROFILES_TREE_WIDGET_H
#include <QTreeWidget>
class QDropEvent;
class ProfilesTreeWidget : public QTreeWidget
{
Q_OBJECT
public:
explicit ProfilesTreeWidget(QWidget* parent = nullptr);
signals:
void itemsDropped();
protected:
void dropEvent(QDropEvent* event) override;
};
#endif

View File

@@ -1,40 +1,112 @@
#include "profiles_window.h" #include "profiles_window.h"
#include "about_dialog.h"
#include "profile_dialog.h" #include "profile_dialog.h"
#include "profile_repository.h" #include "profile_repository.h"
#include "profiles_tree_widget.h"
#include "session_window.h" #include "session_window.h"
#include <QAction>
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QComboBox>
#include <QHeaderView>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QListWidget> #include <QInputDialog>
#include <QListWidgetItem> #include <QMenu>
#include <QMenuBar>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QSet>
#include <QSettings>
#include <QSignalBlocker>
#include <QApplication>
#include <QStringList>
#include <QStyle>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QVariant> #include <QVariant>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
namespace { namespace {
QString formatProfileListItem(const Profile& profile) constexpr int kProfileIdRole = Qt::UserRole;
constexpr int kFolderPathRole = Qt::UserRole + 1;
QString normalizeFolderPathForView(const QString& value)
{ {
return QStringLiteral("%1 [%2 %3:%4]") QString path = value.trimmed();
.arg(profile.name, profile.protocol, profile.host, QString::number(profile.port)); path.replace(QChar::fromLatin1('\\'), QChar::fromLatin1('/'));
const QStringList rawParts = path.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
QStringList normalized;
for (const QString& rawPart : rawParts) {
const QString part = rawPart.trimmed();
if (!part.isEmpty()) {
normalized.push_back(part);
}
}
return normalized.join(QStringLiteral("/"));
}
QString joinFolderPath(const QString& parentFolder, const QString& childName)
{
const QString parent = normalizeFolderPathForView(parentFolder);
const QString child = normalizeFolderPathForView(childName);
if (child.isEmpty()) {
return parent;
}
if (parent.isEmpty()) {
return child;
}
return QStringLiteral("%1/%2").arg(parent, child);
}
QStringList splitFolderPath(const QString& folderPath)
{
const QString normalized = normalizeFolderPathForView(folderPath);
if (normalized.isEmpty()) {
return {};
}
return normalized.split(QChar::fromLatin1('/'), Qt::SkipEmptyParts);
}
bool profileHasTag(const Profile& profile, const QString& requestedTag)
{
const QString needle = requestedTag.trimmed();
if (needle.isEmpty()) {
return true;
}
const QStringList tags = profile.tags.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
for (const QString& tag : tags) {
if (tag.trimmed().compare(needle, Qt::CaseInsensitive) == 0) {
return true;
}
}
return false;
} }
} }
ProfilesWindow::ProfilesWindow(QWidget* parent) ProfilesWindow::ProfilesWindow(QWidget* parent)
: QMainWindow(parent), : QMainWindow(parent),
m_searchBox(nullptr), m_searchBox(nullptr),
m_profilesList(nullptr), m_viewModeBox(nullptr),
m_sortBox(nullptr),
m_protocolFilterBox(nullptr),
m_tagFilterBox(nullptr),
m_profilesTree(nullptr),
m_newButton(nullptr), m_newButton(nullptr),
m_editButton(nullptr), m_editButton(nullptr),
m_deleteButton(nullptr), m_deleteButton(nullptr),
m_repository(std::make_unique<ProfileRepository>()) m_repository(std::make_unique<ProfileRepository>())
{ {
setWindowTitle(QStringLiteral("OrbitHub Profiles")); setWindowTitle(QStringLiteral("OrbitHub Profiles"));
resize(640, 620); resize(860, 640);
setWindowIcon(QApplication::windowIcon());
setupUi(); setupUi();
@@ -47,10 +119,11 @@ ProfilesWindow::ProfilesWindow(QWidget* parent)
m_editButton->setEnabled(false); m_editButton->setEnabled(false);
m_deleteButton->setEnabled(false); m_deleteButton->setEnabled(false);
m_searchBox->setEnabled(false); m_searchBox->setEnabled(false);
m_profilesList->setEnabled(false); m_profilesTree->setEnabled(false);
return; return;
} }
loadUiPreferences();
loadProfiles(); loadProfiles();
} }
@@ -63,10 +136,47 @@ void ProfilesWindow::setupUi()
auto* searchLabel = new QLabel(QStringLiteral("Search"), central); auto* searchLabel = new QLabel(QStringLiteral("Search"), central);
m_searchBox = new QLineEdit(central); m_searchBox = new QLineEdit(central);
m_searchBox->setPlaceholderText(QStringLiteral("Filter by name or host...")); m_searchBox->setPlaceholderText(QStringLiteral("Filter by name, host, folder, or tags..."));
m_profilesList = new QListWidget(central); auto* viewModeLabel = new QLabel(QStringLiteral("View"), central);
m_profilesList->setSelectionMode(QAbstractItemView::SingleSelection); m_viewModeBox = new QComboBox(central);
m_viewModeBox->addItem(QStringLiteral("List"));
m_viewModeBox->addItem(QStringLiteral("Folders"));
auto* sortLabel = new QLabel(QStringLiteral("Sort"), central);
m_sortBox = new QComboBox(central);
m_sortBox->addItem(QStringLiteral("Name"), static_cast<int>(ProfileSortOrder::NameAsc));
m_sortBox->addItem(QStringLiteral("Protocol"), static_cast<int>(ProfileSortOrder::ProtocolAsc));
m_sortBox->addItem(QStringLiteral("Host"), static_cast<int>(ProfileSortOrder::HostAsc));
auto* protocolFilterLabel = new QLabel(QStringLiteral("Protocol"), central);
m_protocolFilterBox = new QComboBox(central);
m_protocolFilterBox->addItem(QStringLiteral("All"));
m_protocolFilterBox->addItem(QStringLiteral("SSH"));
m_protocolFilterBox->addItem(QStringLiteral("RDP"));
m_protocolFilterBox->addItem(QStringLiteral("VNC"));
auto* tagFilterLabel = new QLabel(QStringLiteral("Tag"), central);
m_tagFilterBox = new QComboBox(central);
m_tagFilterBox->addItem(QStringLiteral("All"));
m_profilesTree = new ProfilesTreeWidget(central);
m_profilesTree->setSelectionMode(QAbstractItemView::SingleSelection);
m_profilesTree->setColumnCount(4);
m_profilesTree->setHeaderLabels(
{QStringLiteral("Name"), QStringLiteral("Protocol"), QStringLiteral("Server"), QStringLiteral("Tags")});
m_profilesTree->setRootIsDecorated(true);
m_profilesTree->setDragEnabled(true);
m_profilesTree->setAcceptDrops(true);
m_profilesTree->viewport()->setAcceptDrops(true);
m_profilesTree->setDropIndicatorShown(true);
m_profilesTree->setDragDropMode(QAbstractItemView::InternalMove);
m_profilesTree->setDefaultDropAction(Qt::MoveAction);
m_profilesTree->setContextMenuPolicy(Qt::CustomContextMenu);
m_profilesTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
m_profilesTree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
m_profilesTree->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
m_profilesTree->header()->setSectionResizeMode(3, QHeaderView::Stretch);
auto* buttonRow = new QHBoxLayout(); auto* buttonRow = new QHBoxLayout();
m_newButton = new QPushButton(QStringLiteral("New"), central); m_newButton = new QPushButton(QStringLiteral("New"), central);
@@ -78,34 +188,117 @@ void ProfilesWindow::setupUi()
buttonRow->addWidget(m_deleteButton); buttonRow->addWidget(m_deleteButton);
buttonRow->addStretch(); buttonRow->addStretch();
rootLayout->addWidget(searchLabel); auto* filterRow = new QHBoxLayout();
rootLayout->addWidget(m_searchBox); filterRow->addWidget(searchLabel);
rootLayout->addWidget(m_profilesList, 1); filterRow->addWidget(m_searchBox, 1);
filterRow->addWidget(viewModeLabel);
filterRow->addWidget(m_viewModeBox);
filterRow->addWidget(protocolFilterLabel);
filterRow->addWidget(m_protocolFilterBox);
filterRow->addWidget(tagFilterLabel);
filterRow->addWidget(m_tagFilterBox);
filterRow->addWidget(sortLabel);
filterRow->addWidget(m_sortBox);
rootLayout->addLayout(filterRow);
rootLayout->addWidget(m_profilesTree, 1);
rootLayout->addLayout(buttonRow); rootLayout->addLayout(buttonRow);
setCentralWidget(central); setCentralWidget(central);
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("File"));
QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile"));
QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder"));
fileMenu->addSeparator();
QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit"));
connect(newProfileAction,
&QAction::triggered,
this,
[this]() {
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
createProfile(folderPath);
});
connect(newFolderAction,
&QAction::triggered,
this,
[this]() {
const QString folderPath = folderPathForItem(m_profilesTree->currentItem());
createFolderInContext(folderPath);
});
connect(quitAction, &QAction::triggered, this, [this]() { close(); });
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
connect(aboutAction,
&QAction::triggered,
this,
[this]() {
AboutDialog dialog(this);
dialog.exec();
});
connect(m_searchBox, connect(m_searchBox,
&QLineEdit::textChanged, &QLineEdit::textChanged,
this, this,
[this](const QString& text) { loadProfiles(text); }); [this](const QString&) {
saveUiPreferences();
connect(m_profilesList, loadProfiles();
&QListWidget::itemDoubleClicked, });
connect(m_viewModeBox,
&QComboBox::currentIndexChanged,
this, this,
[this](QListWidgetItem* item) { openSessionForItem(item); }); [this](int) {
saveUiPreferences();
loadProfiles();
});
connect(m_sortBox,
&QComboBox::currentIndexChanged,
this,
[this](int) {
saveUiPreferences();
loadProfiles();
});
connect(m_protocolFilterBox,
&QComboBox::currentIndexChanged,
this,
[this](int) {
saveUiPreferences();
loadProfiles();
});
connect(m_tagFilterBox,
&QComboBox::currentIndexChanged,
this,
[this](int) {
saveUiPreferences();
loadProfiles();
});
connect(m_profilesTree,
&QTreeWidget::itemDoubleClicked,
this,
[this](QTreeWidgetItem* item, int) { openSessionForItem(item); });
connect(m_profilesTree,
&QWidget::customContextMenuRequested,
this,
[this](const QPoint& pos) { showTreeContextMenu(pos); });
connect(m_profilesTree,
&ProfilesTreeWidget::itemsDropped,
this,
[this]() { persistFolderAssignmentsFromTree(); });
connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); }); connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); });
connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); }); connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); });
connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); }); connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); });
} }
void ProfilesWindow::loadProfiles(const QString& query) void ProfilesWindow::loadProfiles()
{ {
m_profilesList->clear(); m_profilesTree->clear();
m_profileCache.clear(); m_profileCache.clear();
const std::vector<Profile> profiles = m_repository->listProfiles(query); const QString query = m_searchBox == nullptr ? QString() : m_searchBox->text();
const std::vector<Profile> profiles = m_repository->listProfiles(query, selectedSortOrder());
if (!m_repository->lastError().isEmpty()) { if (!m_repository->lastError().isEmpty()) {
QMessageBox::warning(this, QMessageBox::warning(this,
QStringLiteral("Load Profiles"), QStringLiteral("Load Profiles"),
@@ -114,44 +307,522 @@ void ProfilesWindow::loadProfiles(const QString& query)
return; return;
} }
updateTagFilterOptions(profiles);
const QString protocolFilter = selectedProtocolFilter();
const QString tagFilter = selectedTagFilter();
const bool folderView = isFolderViewEnabled();
m_profilesTree->setDragEnabled(folderView);
m_profilesTree->setAcceptDrops(folderView);
m_profilesTree->viewport()->setAcceptDrops(folderView);
m_profilesTree->setDropIndicatorShown(folderView);
m_profilesTree->setDragDropMode(folderView ? QAbstractItemView::InternalMove
: QAbstractItemView::NoDragDrop);
std::vector<Profile> filteredProfiles;
filteredProfiles.reserve(profiles.size());
for (const Profile& profile : profiles) { for (const Profile& profile : profiles) {
auto* item = new QListWidgetItem(formatProfileListItem(profile), m_profilesList); if (!protocolFilter.isEmpty()
item->setData(Qt::UserRole, QVariant::fromValue(profile.id)); && profile.protocol.compare(protocolFilter, Qt::CaseInsensitive) != 0) {
const QString identity = [&profile]() { continue;
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
&& !profile.domain.trimmed().isEmpty()) {
return QStringLiteral("%1\\%2").arg(profile.domain.trimmed(),
profile.username.trimmed().isEmpty()
? QStringLiteral("<none>")
: profile.username.trimmed());
}
return profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username;
}();
QString tooltip = QStringLiteral("%1://%2@%3:%4\nAuth: %5")
.arg(profile.protocol,
identity,
profile.host,
QString::number(profile.port),
profile.authMode);
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
tooltip += QStringLiteral("\nKnown Hosts: %1").arg(profile.knownHostsPolicy);
} else if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
tooltip += QStringLiteral("\nRDP Security: %1\nRDP Performance: %2")
.arg(profile.rdpSecurityMode, profile.rdpPerformanceProfile);
} }
item->setToolTip(tooltip); if (!tagFilter.isEmpty() && !profileHasTag(profile, tagFilter)) {
continue;
}
filteredProfiles.push_back(profile);
}
if (folderView) {
std::map<QString, QTreeWidgetItem*> folderNodes;
const std::vector<QString> explicitFolders = m_repository->listFolders();
for (const QString& folderPath : explicitFolders) {
if (query.trimmed().isEmpty()
|| folderPath.contains(query.trimmed(), Qt::CaseInsensitive)) {
upsertFolderNode(splitFolderPath(folderPath), folderNodes);
}
}
for (const Profile& profile : filteredProfiles) {
QTreeWidgetItem* parent = nullptr;
const QStringList folderParts = splitFolderPath(profile.folderPath);
if (!folderParts.isEmpty()) {
parent = upsertFolderNode(folderParts, folderNodes);
}
addProfileNode(parent, profile);
}
m_profilesTree->expandAll();
} else {
for (const Profile& profile : filteredProfiles) {
addProfileNode(nullptr, profile);
}
}
}
ProfileSortOrder ProfilesWindow::selectedSortOrder() const
{
if (m_sortBox == nullptr) {
return ProfileSortOrder::NameAsc;
}
const QVariant value = m_sortBox->currentData();
if (!value.isValid()) {
return ProfileSortOrder::NameAsc;
}
const int orderValue = value.toInt();
switch (static_cast<ProfileSortOrder>(orderValue)) {
case ProfileSortOrder::ProtocolAsc:
return ProfileSortOrder::ProtocolAsc;
case ProfileSortOrder::HostAsc:
return ProfileSortOrder::HostAsc;
case ProfileSortOrder::NameAsc:
default:
return ProfileSortOrder::NameAsc;
}
}
bool ProfilesWindow::isFolderViewEnabled() const
{
if (m_viewModeBox == nullptr) {
return false;
}
return m_viewModeBox->currentText().compare(QStringLiteral("Folders"), Qt::CaseInsensitive)
== 0;
}
QString ProfilesWindow::selectedProtocolFilter() const
{
if (m_protocolFilterBox == nullptr) {
return QString();
}
const QString selected = m_protocolFilterBox->currentText().trimmed();
if (selected.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) {
return QString();
}
return selected;
}
QString ProfilesWindow::selectedTagFilter() const
{
if (m_tagFilterBox == nullptr) {
return QString();
}
const QString selected = m_tagFilterBox->currentText().trimmed();
if (selected.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) {
return QString();
}
return selected;
}
void ProfilesWindow::updateTagFilterOptions(const std::vector<Profile>& profiles)
{
if (m_tagFilterBox == nullptr) {
return;
}
const QString previousSelection = m_tagFilterBox->currentText().trimmed();
QString desiredSelection = previousSelection;
if (!m_pendingTagFilterPreference.trimmed().isEmpty()) {
desiredSelection = m_pendingTagFilterPreference.trimmed();
}
QSet<QString> seen;
QStringList tags;
for (const Profile& profile : profiles) {
const QStringList splitTags =
profile.tags.split(QChar::fromLatin1(','), Qt::SkipEmptyParts);
for (const QString& rawTag : splitTags) {
const QString tag = rawTag.trimmed();
if (tag.isEmpty()) {
continue;
}
const QString dedupeKey = tag.toLower();
if (seen.contains(dedupeKey)) {
continue;
}
seen.insert(dedupeKey);
tags.push_back(tag);
}
}
tags.sort(Qt::CaseInsensitive);
const QSignalBlocker blocker(m_tagFilterBox);
m_tagFilterBox->clear();
m_tagFilterBox->addItem(QStringLiteral("All"));
for (const QString& tag : tags) {
m_tagFilterBox->addItem(tag);
}
int restoredIndex = m_tagFilterBox->findText(desiredSelection, Qt::MatchFixedString);
if (restoredIndex < 0) {
restoredIndex = 0;
}
m_tagFilterBox->setCurrentIndex(restoredIndex);
m_pendingTagFilterPreference.clear();
}
QTreeWidgetItem* ProfilesWindow::upsertFolderNode(const QStringList& folderParts,
std::map<QString, QTreeWidgetItem*>& folderNodes)
{
QString currentPath;
QTreeWidgetItem* parent = nullptr;
for (const QString& rawPart : folderParts) {
const QString part = rawPart.trimmed();
if (part.isEmpty()) {
continue;
}
currentPath = currentPath.isEmpty() ? part
: QStringLiteral("%1/%2").arg(currentPath, part);
const auto existing = folderNodes.find(currentPath);
if (existing != folderNodes.end()) {
parent = existing->second;
continue;
}
auto* folderItem = parent == nullptr ? new QTreeWidgetItem(m_profilesTree)
: new QTreeWidgetItem(parent);
folderItem->setText(0, part);
folderItem->setIcon(0, style()->standardIcon(QStyle::SP_DirIcon));
folderItem->setData(0, kFolderPathRole, currentPath);
folderItem->setFlags((folderItem->flags() | Qt::ItemIsDropEnabled) & ~Qt::ItemIsDragEnabled);
folderNodes.insert_or_assign(currentPath, folderItem);
parent = folderItem;
}
return parent;
}
void ProfilesWindow::addProfileNode(QTreeWidgetItem* parent, const Profile& profile)
{
auto* item = parent == nullptr ? new QTreeWidgetItem(m_profilesTree) : new QTreeWidgetItem(parent);
item->setText(0, profile.name);
item->setText(1, profile.protocol);
item->setText(2, QStringLiteral("%1:%2").arg(profile.host, QString::number(profile.port)));
item->setText(3, profile.tags);
item->setIcon(0, style()->standardIcon(QStyle::SP_ComputerIcon));
item->setData(0, kProfileIdRole, QVariant::fromValue(profile.id));
item->setData(0, kFolderPathRole, normalizeFolderPathForView(profile.folderPath));
item->setFlags((item->flags() | Qt::ItemIsDragEnabled | Qt::ItemIsSelectable) & ~Qt::ItemIsDropEnabled);
const QString identity = [&profile]() {
if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0
&& !profile.domain.trimmed().isEmpty()) {
return QStringLiteral("%1\\%2").arg(profile.domain.trimmed(),
profile.username.trimmed().isEmpty()
? QStringLiteral("<none>")
: profile.username.trimmed());
}
return profile.username.trimmed().isEmpty() ? QStringLiteral("<none>")
: profile.username.trimmed();
}();
QString tooltip = QStringLiteral("%1://%2@%3:%4\nAuth: %5")
.arg(profile.protocol,
identity,
profile.host,
QString::number(profile.port),
profile.authMode);
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
tooltip += QStringLiteral("\nKnown Hosts: %1").arg(profile.knownHostsPolicy);
} else if (profile.protocol.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) {
tooltip += QStringLiteral("\nRDP Security: %1\nRDP Performance: %2")
.arg(profile.rdpSecurityMode, profile.rdpPerformanceProfile);
}
if (!profile.folderPath.trimmed().isEmpty()) {
tooltip += QStringLiteral("\nFolder: %1").arg(profile.folderPath.trimmed());
}
if (!profile.tags.trimmed().isEmpty()) {
tooltip += QStringLiteral("\nTags: %1").arg(profile.tags);
}
item->setToolTip(0, tooltip);
item->setToolTip(1, tooltip);
item->setToolTip(2, tooltip);
item->setToolTip(3, tooltip);
m_profileCache.insert_or_assign(profile.id, profile);
}
QString ProfilesWindow::folderPathForItem(const QTreeWidgetItem* item) const
{
if (item == nullptr) {
return QString();
}
const QVariant idValue = item->data(0, kProfileIdRole);
if (idValue.isValid()) {
const qint64 id = idValue.toLongLong();
const auto cacheIt = m_profileCache.find(id);
if (cacheIt != m_profileCache.end()) {
return normalizeFolderPathForView(cacheIt->second.folderPath);
}
}
return normalizeFolderPathForView(item->data(0, kFolderPathRole).toString());
}
void ProfilesWindow::showTreeContextMenu(const QPoint& pos)
{
if (m_profilesTree == nullptr) {
return;
}
QTreeWidgetItem* item = m_profilesTree->itemAt(pos);
if (item != nullptr) {
m_profilesTree->setCurrentItem(item);
}
const QString contextFolder = folderPathForItem(item);
const bool isProfileItem = item != nullptr && item->data(0, kProfileIdRole).isValid();
QMenu menu(this);
QAction* newConnectionAction = menu.addAction(QStringLiteral("New Connection"));
QAction* newFolderAction = menu.addAction(QStringLiteral("New Folder"));
QAction* connectAction = nullptr;
QAction* editAction = nullptr;
QAction* deleteAction = nullptr;
if (isProfileItem) {
menu.addSeparator();
connectAction = menu.addAction(QStringLiteral("Connect"));
editAction = menu.addAction(QStringLiteral("Edit"));
deleteAction = menu.addAction(QStringLiteral("Delete"));
}
QAction* chosen = menu.exec(m_profilesTree->viewport()->mapToGlobal(pos));
if (chosen == nullptr) {
return;
}
if (chosen == newConnectionAction) {
createProfile(contextFolder);
return;
}
if (chosen == newFolderAction) {
createFolderInContext(contextFolder);
return;
}
if (isProfileItem && chosen == connectAction) {
openSessionForItem(item);
return;
}
if (isProfileItem && chosen == editAction) {
editSelectedProfile();
return;
}
if (isProfileItem && chosen == deleteAction) {
deleteSelectedProfile();
return;
}
}
void ProfilesWindow::createFolderInContext(const QString& baseFolderPath)
{
bool accepted = false;
const QString folderName = QInputDialog::getText(
this,
QStringLiteral("New Folder"),
QStringLiteral("Folder name"),
QLineEdit::Normal,
QString(),
&accepted);
if (!accepted) {
return;
}
const QString normalized = joinFolderPath(baseFolderPath, folderName);
if (normalized.isEmpty()) {
QMessageBox::information(this,
QStringLiteral("New Folder"),
QStringLiteral("Folder name is required."));
return;
}
if (!m_repository->createFolder(normalized)) {
QMessageBox::warning(this,
QStringLiteral("New Folder"),
QStringLiteral("Failed to create folder: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
loadProfiles();
}
void ProfilesWindow::persistFolderAssignmentsFromTree()
{
if (!isFolderViewEnabled() || m_profilesTree == nullptr) {
return;
}
std::unordered_map<qint64, QString> assignments;
const int topLevelCount = m_profilesTree->topLevelItemCount();
for (int index = 0; index < topLevelCount; ++index) {
collectProfileAssignments(m_profilesTree->topLevelItem(index), QString(), assignments);
}
bool hadErrors = false;
int updatedCount = 0;
for (const auto& [id, newFolderPath] : assignments) {
auto cacheIt = m_profileCache.find(id);
Profile profile;
if (cacheIt != m_profileCache.end()) {
profile = cacheIt->second;
} else {
const std::optional<Profile> fetched = m_repository->getProfile(id);
if (!fetched.has_value()) {
continue;
}
profile = fetched.value();
}
const QString existing = normalizeFolderPathForView(profile.folderPath);
const QString target = normalizeFolderPathForView(newFolderPath);
if (existing == target) {
continue;
}
profile.folderPath = target;
if (!target.isEmpty()) {
m_repository->createFolder(target);
}
if (!m_repository->updateProfile(profile)) {
hadErrors = true;
continue;
}
m_profileCache.insert_or_assign(profile.id, profile); m_profileCache.insert_or_assign(profile.id, profile);
++updatedCount;
}
if (hadErrors) {
QMessageBox::warning(this,
QStringLiteral("Move Profile"),
QStringLiteral("One or more profile moves could not be saved."));
}
if (updatedCount > 0 || hadErrors) {
loadProfiles();
}
}
void ProfilesWindow::collectProfileAssignments(
const QTreeWidgetItem* item,
const QString& parentFolderPath,
std::unordered_map<qint64, QString>& assignments) const
{
if (item == nullptr) {
return;
}
const QVariant idValue = item->data(0, kProfileIdRole);
if (idValue.isValid()) {
assignments.insert_or_assign(idValue.toLongLong(), normalizeFolderPathForView(parentFolderPath));
return;
}
const QString folderPath = joinFolderPath(parentFolderPath, item->text(0));
const int childCount = item->childCount();
for (int index = 0; index < childCount; ++index) {
collectProfileAssignments(item->child(index), folderPath, assignments);
}
}
void ProfilesWindow::loadUiPreferences()
{
QSettings settings;
const QString search = settings.value(QStringLiteral("profiles/searchText")).toString();
const QString viewMode =
settings.value(QStringLiteral("profiles/viewMode"), QStringLiteral("List")).toString();
const int sortValue = settings
.value(QStringLiteral("profiles/sortOrder"),
static_cast<int>(ProfileSortOrder::NameAsc))
.toInt();
const QString protocolFilter =
settings.value(QStringLiteral("profiles/protocolFilter"), QStringLiteral("All")).toString();
const QString tagFilter =
settings.value(QStringLiteral("profiles/tagFilter"), QStringLiteral("All")).toString();
m_pendingTagFilterPreference = tagFilter.trimmed();
if (m_searchBox != nullptr) {
const QSignalBlocker blocker(m_searchBox);
m_searchBox->setText(search);
}
if (m_viewModeBox != nullptr) {
int found = m_viewModeBox->findText(viewMode, Qt::MatchFixedString);
if (found < 0) {
found = 0;
}
const QSignalBlocker blocker(m_viewModeBox);
m_viewModeBox->setCurrentIndex(found);
}
if (m_sortBox != nullptr) {
int found = m_sortBox->findData(sortValue);
if (found < 0) {
found = 0;
}
const QSignalBlocker blocker(m_sortBox);
m_sortBox->setCurrentIndex(found);
}
if (m_protocolFilterBox != nullptr) {
int found = m_protocolFilterBox->findText(protocolFilter, Qt::MatchFixedString);
if (found < 0) {
found = 0;
}
const QSignalBlocker blocker(m_protocolFilterBox);
m_protocolFilterBox->setCurrentIndex(found);
}
if (m_tagFilterBox != nullptr) {
int found = m_tagFilterBox->findText(tagFilter, Qt::MatchFixedString);
if (found < 0) {
found = 0;
}
const QSignalBlocker blocker(m_tagFilterBox);
m_tagFilterBox->setCurrentIndex(found);
}
}
void ProfilesWindow::saveUiPreferences() const
{
QSettings settings;
if (m_searchBox != nullptr) {
settings.setValue(QStringLiteral("profiles/searchText"), m_searchBox->text());
}
if (m_viewModeBox != nullptr) {
settings.setValue(QStringLiteral("profiles/viewMode"), m_viewModeBox->currentText());
}
if (m_sortBox != nullptr) {
settings.setValue(QStringLiteral("profiles/sortOrder"), m_sortBox->currentData());
}
if (m_protocolFilterBox != nullptr) {
settings.setValue(QStringLiteral("profiles/protocolFilter"), m_protocolFilterBox->currentText());
}
if (m_tagFilterBox != nullptr) {
settings.setValue(QStringLiteral("profiles/tagFilter"), m_tagFilterBox->currentText());
} }
} }
std::optional<Profile> ProfilesWindow::selectedProfile() const std::optional<Profile> ProfilesWindow::selectedProfile() const
{ {
QListWidgetItem* item = m_profilesList->currentItem(); QTreeWidgetItem* item = m_profilesTree->currentItem();
if (item == nullptr) { if (item == nullptr) {
return std::nullopt; return std::nullopt;
} }
const QVariant value = item->data(Qt::UserRole); const QVariant value = item->data(0, kProfileIdRole);
if (!value.isValid()) { if (!value.isValid()) {
return std::nullopt; return std::nullopt;
} }
@@ -165,16 +836,22 @@ std::optional<Profile> ProfilesWindow::selectedProfile() const
return m_repository->getProfile(id); return m_repository->getProfile(id);
} }
void ProfilesWindow::createProfile() void ProfilesWindow::createProfile(const QString& defaultFolderPath)
{ {
ProfileDialog dialog(this); ProfileDialog dialog(this);
dialog.setDialogTitle(QStringLiteral("New Profile")); dialog.setDialogTitle(QStringLiteral("New Profile"));
dialog.setDefaultFolderPath(defaultFolderPath);
if (dialog.exec() != QDialog::Accepted) { if (dialog.exec() != QDialog::Accepted) {
return; return;
} }
if (!m_repository->createProfile(dialog.profile()).has_value()) { const Profile newProfile = dialog.profile();
if (!newProfile.folderPath.trimmed().isEmpty()) {
m_repository->createFolder(newProfile.folderPath);
}
if (!m_repository->createProfile(newProfile).has_value()) {
QMessageBox::warning(this, QMessageBox::warning(this,
QStringLiteral("Create Profile"), QStringLiteral("Create Profile"),
QStringLiteral("Failed to create profile: %1") QStringLiteral("Failed to create profile: %1")
@@ -184,7 +861,7 @@ void ProfilesWindow::createProfile()
return; return;
} }
loadProfiles(m_searchBox->text()); loadProfiles();
} }
void ProfilesWindow::editSelectedProfile() void ProfilesWindow::editSelectedProfile()
@@ -207,6 +884,9 @@ void ProfilesWindow::editSelectedProfile()
Profile updated = dialog.profile(); Profile updated = dialog.profile();
updated.id = selected->id; updated.id = selected->id;
if (!updated.folderPath.trimmed().isEmpty()) {
m_repository->createFolder(updated.folderPath);
}
if (!m_repository->updateProfile(updated)) { if (!m_repository->updateProfile(updated)) {
QMessageBox::warning(this, QMessageBox::warning(this,
@@ -218,7 +898,7 @@ void ProfilesWindow::editSelectedProfile()
return; return;
} }
loadProfiles(m_searchBox->text()); loadProfiles();
} }
void ProfilesWindow::deleteSelectedProfile() void ProfilesWindow::deleteSelectedProfile()
@@ -252,17 +932,18 @@ void ProfilesWindow::deleteSelectedProfile()
return; return;
} }
loadProfiles(m_searchBox->text()); loadProfiles();
} }
void ProfilesWindow::openSessionForItem(QListWidgetItem* item) void ProfilesWindow::openSessionForItem(QTreeWidgetItem* item)
{ {
if (item == nullptr) { if (item == nullptr) {
return; return;
} }
const QVariant value = item->data(Qt::UserRole); const QVariant value = item->data(0, kProfileIdRole);
if (!value.isValid()) { if (!value.isValid()) {
item->setExpanded(!item->isExpanded());
return; return;
} }

View File

@@ -5,18 +5,24 @@
#include <QMainWindow> #include <QMainWindow>
#include <QString> #include <QString>
#include <QStringList>
#include <QtGlobal> #include <QtGlobal>
#include <memory> #include <memory>
#include <map>
#include <optional> #include <optional>
#include <QPointer> #include <QPointer>
#include <vector>
#include <unordered_map> #include <unordered_map>
class QListWidget; class QTreeWidget;
class QListWidgetItem; class QTreeWidgetItem;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class QComboBox;
class QPoint;
class SessionWindow; class SessionWindow;
class ProfilesTreeWidget;
class ProfilesWindow : public QMainWindow class ProfilesWindow : public QMainWindow
{ {
@@ -28,21 +34,43 @@ public:
private: private:
QLineEdit* m_searchBox; QLineEdit* m_searchBox;
QListWidget* m_profilesList; QComboBox* m_viewModeBox;
QComboBox* m_sortBox;
QComboBox* m_protocolFilterBox;
QComboBox* m_tagFilterBox;
ProfilesTreeWidget* m_profilesTree;
QPushButton* m_newButton; QPushButton* m_newButton;
QPushButton* m_editButton; QPushButton* m_editButton;
QPushButton* m_deleteButton; QPushButton* m_deleteButton;
QPointer<SessionWindow> m_sessionWindow; QPointer<SessionWindow> m_sessionWindow;
std::unique_ptr<ProfileRepository> m_repository; std::unique_ptr<ProfileRepository> m_repository;
std::unordered_map<qint64, Profile> m_profileCache; std::unordered_map<qint64, Profile> m_profileCache;
QString m_pendingTagFilterPreference;
void setupUi(); void setupUi();
void loadProfiles(const QString& query = QString()); void loadProfiles();
ProfileSortOrder selectedSortOrder() const;
bool isFolderViewEnabled() const;
QString selectedProtocolFilter() const;
QString selectedTagFilter() const;
void updateTagFilterOptions(const std::vector<Profile>& profiles);
QTreeWidgetItem* upsertFolderNode(const QStringList& folderParts,
std::map<QString, QTreeWidgetItem*>& folderNodes);
void addProfileNode(QTreeWidgetItem* parent, const Profile& profile);
QString folderPathForItem(const QTreeWidgetItem* item) const;
void showTreeContextMenu(const QPoint& pos);
void createFolderInContext(const QString& baseFolderPath);
void persistFolderAssignmentsFromTree();
void collectProfileAssignments(const QTreeWidgetItem* item,
const QString& parentFolderPath,
std::unordered_map<qint64, QString>& assignments) const;
void loadUiPreferences();
void saveUiPreferences() const;
std::optional<Profile> selectedProfile() const; std::optional<Profile> selectedProfile() const;
void createProfile(); void createProfile(const QString& defaultFolderPath = QString());
void editSelectedProfile(); void editSelectedProfile();
void deleteSelectedProfile(); void deleteSelectedProfile();
void openSessionForItem(QListWidgetItem* item); void openSessionForItem(QTreeWidgetItem* item);
}; };
#endif #endif

View File

@@ -7,6 +7,7 @@
#include <KodoTerm/KodoTerm.hpp> #include <KodoTerm/KodoTerm.hpp>
#include <QDateTime> #include <QDateTime>
#include <QFile>
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
#include <QFont> #include <QFont>
@@ -17,10 +18,14 @@
#include <QLineEdit> #include <QLineEdit>
#include <QMessageBox> #include <QMessageBox>
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QApplication>
#include <QClipboard>
#include <QComboBox>
#include <QProcessEnvironment> #include <QProcessEnvironment>
#include <QThread> #include <QThread>
#include <QTimer> #include <QTimer>
#include <QToolButton> #include <QToolButton>
#include <QTextStream>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <memory> #include <memory>
@@ -53,7 +58,9 @@ TerminalTheme themeForName(const QString& themeName)
} }
} }
SessionTab::SessionTab(const Profile& profile, QWidget* parent) SessionTab::SessionTab(const Profile& profile,
const SessionUiPreferences& preferences,
QWidget* parent)
: QWidget(parent), : QWidget(parent),
m_profile(profile), m_profile(profile),
m_backendThread(nullptr), m_backendThread(nullptr),
@@ -61,13 +68,21 @@ 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_terminalThemeName(QStringLiteral("Dark")), m_terminalThemeName(preferences.terminalThemeName.trimmed().isEmpty()
? QStringLiteral("Dark")
: preferences.terminalThemeName.trimmed()),
m_sshTerminal(nullptr), m_sshTerminal(nullptr),
m_rdpDisplay(nullptr), m_rdpDisplay(nullptr),
m_terminalOutput(nullptr), m_terminalOutput(nullptr),
m_eventLog(nullptr), m_eventLog(nullptr),
m_toggleEventsButton(nullptr), m_toggleEventsButton(nullptr),
m_eventsPanel(nullptr) m_eventFilterInput(nullptr),
m_eventSeverityFilterInput(nullptr),
m_clearEventsButton(nullptr),
m_exportEventsButton(nullptr),
m_eventsPanel(nullptr),
m_eventSeverityFilter(EventSeverity::Info),
m_eventsPanelExpanded(preferences.eventsPanelExpanded)
{ {
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions"); qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
qRegisterMetaType<SessionState>("SessionState"); qRegisterMetaType<SessionState>("SessionState");
@@ -347,6 +362,7 @@ void SessionTab::setTerminalThemeName(const QString& themeName)
m_terminalThemeName = normalized; m_terminalThemeName = normalized;
applyTerminalTheme(m_terminalThemeName); applyTerminalTheme(m_terminalThemeName);
appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName)); appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName));
emit terminalThemeChanged(m_terminalThemeName);
} }
QString SessionTab::terminalThemeName() const QString SessionTab::terminalThemeName() const
@@ -364,6 +380,111 @@ bool SessionTab::supportsClearAction() const
return m_useKodoTermForSsh || m_terminalOutput != nullptr; return m_useKodoTermForSsh || m_terminalOutput != nullptr;
} }
bool SessionTab::isEventsPanelExpanded() const
{
return m_eventsPanelExpanded;
}
void SessionTab::setEventsPanelExpanded(bool expanded)
{
if (m_eventsPanel == nullptr || m_toggleEventsButton == nullptr) {
return;
}
if (m_eventsPanelExpanded == expanded && m_eventsPanel->isVisible() == expanded) {
return;
}
m_eventsPanelExpanded = expanded;
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
emit eventsPanelVisibilityChanged(m_eventsPanelExpanded);
}
void SessionTab::clearEvents()
{
m_eventEntries.clear();
if (m_eventLog != nullptr) {
m_eventLog->clear();
}
}
void SessionTab::copyEvents() const
{
QStringList visibleLines;
for (const EventEntry& entry : m_eventEntries) {
const bool matchesText = m_eventFilter.isEmpty()
|| entry.line.contains(m_eventFilter, Qt::CaseInsensitive);
bool matchesSeverity = true;
if (m_eventSeverityFilter == EventSeverity::Warning) {
matchesSeverity = entry.severity == EventSeverity::Warning;
} else if (m_eventSeverityFilter == EventSeverity::Error) {
matchesSeverity = entry.severity == EventSeverity::Error;
} else if (m_eventSeverityFilter == EventSeverity::Info) {
matchesSeverity = true;
}
if (matchesText && matchesSeverity) {
visibleLines.push_back(entry.line);
}
}
if (!visibleLines.isEmpty()) {
QApplication::clipboard()->setText(visibleLines.join(QChar::fromLatin1('\n')));
}
}
void SessionTab::exportEventsToFile()
{
QStringList visibleLines;
for (const EventEntry& entry : m_eventEntries) {
const bool matchesText = m_eventFilter.isEmpty()
|| entry.line.contains(m_eventFilter, Qt::CaseInsensitive);
bool matchesSeverity = true;
if (m_eventSeverityFilter == EventSeverity::Warning) {
matchesSeverity = entry.severity == EventSeverity::Warning;
} else if (m_eventSeverityFilter == EventSeverity::Error) {
matchesSeverity = entry.severity == EventSeverity::Error;
} else if (m_eventSeverityFilter == EventSeverity::Info) {
matchesSeverity = true;
}
if (matchesText && matchesSeverity) {
visibleLines.push_back(entry.line);
}
}
if (visibleLines.isEmpty()) {
QMessageBox::information(this,
QStringLiteral("Export Events"),
QStringLiteral("No events match the current filters."));
return;
}
const QString defaultName =
QStringLiteral("orbithub-events-%1.log")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")));
const QString targetPath = QFileDialog::getSaveFileName(this,
QStringLiteral("Export Session Events"),
defaultName,
QStringLiteral("Log Files (*.log);;Text Files (*.txt);;All Files (*)"));
if (targetPath.isEmpty()) {
return;
}
QFile file(targetPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this,
QStringLiteral("Export Events"),
QStringLiteral("Failed to write file: %1").arg(targetPath));
return;
}
QTextStream stream(&file);
for (const QString& line : visibleLines) {
stream << line << '\n';
}
file.close();
}
void SessionTab::onBackendStateChanged(SessionState state, const QString& message) void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
{ {
setState(state, message); setState(state, message);
@@ -445,7 +566,21 @@ void SessionTab::setupUi()
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);
m_eventFilterInput = new QLineEdit(this);
m_eventFilterInput->setPlaceholderText(QStringLiteral("Filter events..."));
m_eventSeverityFilterInput = new QComboBox(this);
m_eventSeverityFilterInput->addItem(QStringLiteral("All"));
m_eventSeverityFilterInput->addItem(QStringLiteral("Warnings"));
m_eventSeverityFilterInput->addItem(QStringLiteral("Errors"));
m_clearEventsButton = new QToolButton(this);
m_clearEventsButton->setText(QStringLiteral("Clear Events"));
m_exportEventsButton = new QToolButton(this);
m_exportEventsButton->setText(QStringLiteral("Export Events"));
eventsHeader->addWidget(m_toggleEventsButton); eventsHeader->addWidget(m_toggleEventsButton);
eventsHeader->addWidget(m_eventFilterInput, 1);
eventsHeader->addWidget(m_eventSeverityFilterInput);
eventsHeader->addWidget(m_exportEventsButton);
eventsHeader->addWidget(m_clearEventsButton);
eventsHeader->addStretch(); eventsHeader->addStretch();
m_eventsPanel = new QWidget(this); m_eventsPanel = new QWidget(this);
@@ -464,15 +599,43 @@ void SessionTab::setupUi()
rootLayout->addLayout(eventsHeader); rootLayout->addLayout(eventsHeader);
rootLayout->addWidget(m_eventsPanel); rootLayout->addWidget(m_eventsPanel);
setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), false); setPanelExpanded(
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), m_eventsPanelExpanded);
connect(m_toggleEventsButton, connect(m_toggleEventsButton,
&QToolButton::toggled, &QToolButton::toggled,
this, this,
[this](bool expanded) { [this](bool expanded) {
setPanelExpanded( setEventsPanelExpanded(expanded);
m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded);
}); });
connect(m_eventFilterInput,
&QLineEdit::textChanged,
this,
[this](const QString& text) {
m_eventFilter = text.trimmed();
refreshEventLogView();
});
connect(m_eventSeverityFilterInput,
&QComboBox::currentTextChanged,
this,
[this](const QString& selected) {
if (selected.compare(QStringLiteral("Errors"), Qt::CaseInsensitive) == 0) {
m_eventSeverityFilter = EventSeverity::Error;
} else if (selected.compare(QStringLiteral("Warnings"), Qt::CaseInsensitive) == 0) {
m_eventSeverityFilter = EventSeverity::Warning;
} else {
m_eventSeverityFilter = EventSeverity::Info;
}
refreshEventLogView();
});
connect(m_exportEventsButton,
&QToolButton::clicked,
this,
[this]() { exportEventsToFile(); });
connect(m_clearEventsButton,
&QToolButton::clicked,
this,
[this]() { clearEvents(); });
if (m_terminalOutput != nullptr) { if (m_terminalOutput != nullptr) {
connect(m_terminalOutput, connect(m_terminalOutput,
@@ -639,7 +802,14 @@ bool SessionTab::validateProfileForConnect()
void SessionTab::appendEvent(const QString& message) void SessionTab::appendEvent(const QString& message)
{ {
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
m_eventLog->appendPlainText(QStringLiteral("[%1] %2").arg(timestamp, message)); m_eventEntries.push_back(
EventEntry{QStringLiteral("[%1] %2").arg(timestamp, message),
classifyEventSeverity(message)});
constexpr int kMaxEventLines = 5000;
while (m_eventEntries.size() > static_cast<size_t>(kMaxEventLines)) {
m_eventEntries.erase(m_eventEntries.begin());
}
refreshEventLogView();
} }
void SessionTab::setState(SessionState state, const QString& message) void SessionTab::setState(SessionState state, const QString& message)
@@ -803,3 +973,51 @@ void SessionTab::applyTerminalTheme(const QString& themeName)
m_terminalOutput->setThemeName(themeName); m_terminalOutput->setThemeName(themeName);
} }
} }
void SessionTab::refreshEventLogView()
{
if (m_eventLog == nullptr) {
return;
}
QStringList visibleLines;
visibleLines.reserve(static_cast<int>(m_eventEntries.size()));
for (const EventEntry& entry : m_eventEntries) {
if (!m_eventFilter.isEmpty() && !entry.line.contains(m_eventFilter, Qt::CaseInsensitive)) {
continue;
}
if (m_eventSeverityFilter == EventSeverity::Warning
&& entry.severity != EventSeverity::Warning) {
continue;
}
if (m_eventSeverityFilter == EventSeverity::Error
&& entry.severity != EventSeverity::Error) {
continue;
}
visibleLines.push_back(entry.line);
}
m_eventLog->setPlainText(visibleLines.join(QChar::fromLatin1('\n')));
m_eventLog->moveCursor(QTextCursor::End);
}
SessionTab::EventSeverity SessionTab::classifyEventSeverity(const QString& message)
{
const QString normalized = message.trimmed().toLower();
if (normalized.startsWith(QStringLiteral("error:"))
|| normalized.contains(QStringLiteral("failed"))
|| normalized.contains(QStringLiteral("permission denied"))) {
return EventSeverity::Error;
}
if (normalized.startsWith(QStringLiteral("warning:"))
|| normalized.contains(QStringLiteral("warning"))) {
return EventSeverity::Warning;
}
return EventSeverity::Info;
}

View File

@@ -5,9 +5,11 @@
#include "session_backend.h" #include "session_backend.h"
#include <QWidget> #include <QWidget>
#include <QStringList>
#include <QtGlobal> #include <QtGlobal>
#include <optional> #include <optional>
#include <vector>
class QPlainTextEdit; class QPlainTextEdit;
class QThread; class QThread;
@@ -15,14 +17,24 @@ class SessionBackend;
class TerminalView; class TerminalView;
class RdpDisplayWidget; class RdpDisplayWidget;
class QToolButton; class QToolButton;
class QLineEdit;
class QComboBox;
class KodoTerm; class KodoTerm;
struct SessionUiPreferences
{
QString terminalThemeName = QStringLiteral("Dark");
bool eventsPanelExpanded = false;
};
class SessionTab : public QWidget class SessionTab : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit SessionTab(const Profile& profile, QWidget* parent = nullptr); explicit SessionTab(const Profile& profile,
const SessionUiPreferences& preferences,
QWidget* parent = nullptr);
~SessionTab() override; ~SessionTab() override;
QString tabTitle() const; QString tabTitle() const;
@@ -34,10 +46,17 @@ public:
QString terminalThemeName() const; QString terminalThemeName() const;
bool supportsThemeSelection() const; bool supportsThemeSelection() const;
bool supportsClearAction() const; bool supportsClearAction() const;
bool isEventsPanelExpanded() const;
void setEventsPanelExpanded(bool expanded);
void clearEvents();
void copyEvents() const;
void exportEventsToFile();
signals: signals:
void tabTitleChanged(const QString& title); void tabTitleChanged(const QString& title);
void tabStateChanged(SessionState state); void tabStateChanged(SessionState state);
void terminalThemeChanged(const QString& themeName);
void eventsPanelVisibilityChanged(bool expanded);
void requestConnect(const SessionConnectOptions& options); void requestConnect(const SessionConnectOptions& options);
void requestDisconnect(); void requestDisconnect();
void requestReconnect(const SessionConnectOptions& options); void requestReconnect(const SessionConnectOptions& options);
@@ -75,7 +94,24 @@ private:
TerminalView* m_terminalOutput; TerminalView* m_terminalOutput;
QPlainTextEdit* m_eventLog; QPlainTextEdit* m_eventLog;
QToolButton* m_toggleEventsButton; QToolButton* m_toggleEventsButton;
QLineEdit* m_eventFilterInput;
QComboBox* m_eventSeverityFilterInput;
QToolButton* m_clearEventsButton;
QToolButton* m_exportEventsButton;
QWidget* m_eventsPanel; QWidget* m_eventsPanel;
enum class EventSeverity {
Info,
Warning,
Error,
};
struct EventEntry {
QString line;
EventSeverity severity;
};
std::vector<EventEntry> m_eventEntries;
QString m_eventFilter;
EventSeverity m_eventSeverityFilter;
bool m_eventsPanelExpanded;
void setupUi(); void setupUi();
std::optional<SessionConnectOptions> buildConnectOptions(); std::optional<SessionConnectOptions> buildConnectOptions();
@@ -87,6 +123,8 @@ private:
void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded); void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded);
bool startSshTerminal(const SessionConnectOptions& options); bool startSshTerminal(const SessionConnectOptions& options);
void applyTerminalTheme(const QString& themeName); void applyTerminalTheme(const QString& themeName);
void refreshEventLogView();
static EventSeverity classifyEventSeverity(const QString& message);
}; };
#endif #endif

View File

@@ -1,11 +1,15 @@
#include "session_window.h" #include "session_window.h"
#include "about_dialog.h"
#include <QApplication>
#include "session_tab.h" #include "session_tab.h"
#include <QAction> #include <QAction>
#include <QColor> #include <QColor>
#include <QMenu> #include <QMenu>
#include <QMenuBar>
#include <QPalette> #include <QPalette>
#include <QSettings>
#include <QStringList> #include <QStringList>
#include <QTabBar> #include <QTabBar>
#include <QTabWidget> #include <QTabWidget>
@@ -36,8 +40,11 @@ QStringList terminalThemeNames()
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))
{ {
loadUiPreferences();
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name)); setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
resize(1080, 760); resize(1080, 760);
setWindowIcon(QApplication::windowIcon());
m_tabs->setTabsClosable(true); m_tabs->setTabsClosable(true);
connect(m_tabs, connect(m_tabs,
@@ -72,6 +79,12 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
QMenu menu(this); QMenu menu(this);
QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect")); QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect"));
QAction* reconnectAction = menu.addAction(QStringLiteral("Reconnect")); QAction* reconnectAction = menu.addAction(QStringLiteral("Reconnect"));
QAction* toggleEventsAction = menu.addAction(
tab->isEventsPanelExpanded() ? QStringLiteral("Hide Events")
: QStringLiteral("Show Events"));
QAction* copyEventsAction = menu.addAction(QStringLiteral("Copy Events"));
QAction* exportEventsAction = menu.addAction(QStringLiteral("Export Events"));
QAction* clearEventsAction = menu.addAction(QStringLiteral("Clear Events"));
QList<QAction*> themeActions; QList<QAction*> themeActions;
if (tab->supportsThemeSelection()) { if (tab->supportsThemeSelection()) {
@@ -97,6 +110,14 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
tab->disconnectSession(); tab->disconnectSession();
} else if (chosen == reconnectAction) { } else if (chosen == reconnectAction) {
tab->reconnectSession(); tab->reconnectSession();
} else if (chosen == toggleEventsAction) {
tab->setEventsPanelExpanded(!tab->isEventsPanelExpanded());
} else if (chosen == copyEventsAction) {
tab->copyEvents();
} else if (chosen == exportEventsAction) {
tab->exportEventsToFile();
} else if (chosen == clearEventsAction) {
tab->clearEvents();
} else if (clearAction != nullptr && chosen == clearAction) { } else if (clearAction != nullptr && chosen == clearAction) {
tab->clearTerminal(); tab->clearTerminal();
} else { } else {
@@ -109,6 +130,16 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
} }
}); });
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help"));
QAction* aboutAction = helpMenu->addAction(QStringLiteral("About OrbitHub"));
connect(aboutAction,
&QAction::triggered,
this,
[this]() {
AboutDialog dialog(this);
dialog.exec();
});
setCentralWidget(m_tabs); setCentralWidget(m_tabs);
addSessionTab(profile); addSessionTab(profile);
} }
@@ -120,7 +151,7 @@ void SessionWindow::openProfile(const Profile& profile)
void SessionWindow::addSessionTab(const Profile& profile) void SessionWindow::addSessionTab(const Profile& profile)
{ {
auto* tab = new SessionTab(profile, this); auto* tab = new SessionTab(profile, m_preferences, 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);
if (m_tabs->count() > 1) { if (m_tabs->count() > 1) {
@@ -145,6 +176,22 @@ void SessionWindow::addSessionTab(const Profile& profile)
} }
} }
}); });
connect(tab,
&SessionTab::terminalThemeChanged,
this,
[this](const QString& themeName) {
m_preferences.terminalThemeName = themeName.trimmed().isEmpty()
? QStringLiteral("Dark")
: themeName.trimmed();
saveUiPreferences();
});
connect(tab,
&SessionTab::eventsPanelVisibilityChanged,
this,
[this](bool expanded) {
m_preferences.eventsPanelExpanded = expanded;
saveUiPreferences();
});
} }
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title) void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
@@ -156,3 +203,26 @@ void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
} }
} }
} }
void SessionWindow::loadUiPreferences()
{
QSettings settings;
m_preferences.terminalThemeName =
settings.value(QStringLiteral("session/defaultTerminalTheme"), QStringLiteral("Dark"))
.toString()
.trimmed();
if (m_preferences.terminalThemeName.isEmpty()) {
m_preferences.terminalThemeName = QStringLiteral("Dark");
}
m_preferences.eventsPanelExpanded =
settings.value(QStringLiteral("session/eventsPanelExpanded"), false).toBool();
}
void SessionWindow::saveUiPreferences() const
{
QSettings settings;
settings.setValue(QStringLiteral("session/defaultTerminalTheme"),
m_preferences.terminalThemeName);
settings.setValue(QStringLiteral("session/eventsPanelExpanded"),
m_preferences.eventsPanelExpanded);
}

View File

@@ -2,11 +2,11 @@
#define ORBITHUB_SESSION_WINDOW_H #define ORBITHUB_SESSION_WINDOW_H
#include "profile_repository.h" #include "profile_repository.h"
#include "session_tab.h"
#include <QMainWindow> #include <QMainWindow>
class QTabWidget; class QTabWidget;
class SessionTab;
class SessionWindow : public QMainWindow class SessionWindow : public QMainWindow
{ {
@@ -18,9 +18,12 @@ public:
private: private:
QTabWidget* m_tabs; QTabWidget* m_tabs;
SessionUiPreferences m_preferences;
void addSessionTab(const Profile& profile); void addSessionTab(const Profile& profile);
void updateTabTitle(SessionTab* tab, const QString& title); void updateTabTitle(SessionTab* tab, const QString& title);
void loadUiPreferences();
void saveUiPreferences() const;
}; };
#endif #endif