diff --git a/CMakeLists.txt b/CMakeLists.txt index be17258..ff8f2d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,11 +76,17 @@ set(WITH_WINPR_TOOLS OFF CACHE BOOL "" FORCE) add_subdirectory(third_party/FreeRDP EXCLUDE_FROM_ALL) add_executable(orbithub + src/about_dialog.cpp + src/about_dialog.h + src/app_icon.cpp + src/app_icon.h src/main.cpp src/profile_dialog.cpp src/profile_dialog.h src/profile_repository.cpp src/profile_repository.h + src/profiles_tree_widget.cpp + src/profiles_tree_widget.h src/profiles_window.cpp src/profiles_window.h src/session_backend.h diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index f2b87bb..85ddf31 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -100,11 +100,11 @@ Delivered: - Pulled FreeRDP source for integration planning and API review Git: -- Tag: pending (awaiting explicit approval before tagging/pushing) +- Tag: `v0-m5-done` ## Milestone 6 - VNC Fully Working -Status: Planned +Status: Deferred (temporarily postponed) Planned Scope: - Replace current unsupported VNC path with complete VNC implementation @@ -124,7 +124,21 @@ Planned Scope: ## Milestone 8 - Profile and Session UX Completion -Status: Planned +Status: In Progress + +Started: +- Added profile `tags` field to storage + schema migration and profile editor UX +- Added profile `folder_path` field + nested folder/subfolder profile view mode +- 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 Planned Scope: - Complete protocol-aware profile validation and UX polish diff --git a/src/about_dialog.cpp b/src/about_dialog.cpp new file mode 100644 index 0000000..fac03f4 --- /dev/null +++ b/src/about_dialog.cpp @@ -0,0 +1,92 @@ +#include "about_dialog.h" + +#include +#include +#include +#include +#include +#include +#include + +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("

OrbitHub

"), 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"( +

Third-Party Libraries

+

OrbitHub uses the following external libraries:

+ + + + + + +
LibraryLicenseUpstream
Qt 6 (Widgets / SQL)LGPLv3 / GPLv3 / Commercialqt.io/licensing
KodoTermMITgithub.com/diegoiast/KodoTerm
libvtermMITgithub.com/neovim/libvterm
FreeRDP / WinPRApache License 2.0github.com/FreeRDP/FreeRDP
+ +

License Links

+ + +

License Files In This Repository

+
    +
  • third_party/KodoTerm/LICENSE
  • +
  • third_party/FreeRDP/LICENSE
  • +
  • LICENSE (project license)
  • +
+)")); + + 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); +} diff --git a/src/about_dialog.h b/src/about_dialog.h new file mode 100644 index 0000000..f118b08 --- /dev/null +++ b/src/about_dialog.h @@ -0,0 +1,14 @@ +#ifndef ORBITHUB_ABOUT_DIALOG_H +#define ORBITHUB_ABOUT_DIALOG_H + +#include + +class AboutDialog : public QDialog +{ + Q_OBJECT + +public: + explicit AboutDialog(QWidget* parent = nullptr); +}; + +#endif diff --git a/src/app_icon.cpp b/src/app_icon.cpp new file mode 100644 index 0000000..908a095 --- /dev/null +++ b/src/app_icon.cpp @@ -0,0 +1,101 @@ +#include "app_icon.h" + +#include +#include +#include +#include +#include +#include +#include + +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(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; +} diff --git a/src/app_icon.h b/src/app_icon.h new file mode 100644 index 0000000..7d69c98 --- /dev/null +++ b/src/app_icon.h @@ -0,0 +1,8 @@ +#ifndef ORBITHUB_APP_ICON_H +#define ORBITHUB_APP_ICON_H + +#include + +QIcon createOrbitHubAppIcon(); + +#endif diff --git a/src/main.cpp b/src/main.cpp index 54f57af..b9e2de3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,3 +1,4 @@ +#include "app_icon.h" #include "profiles_window.h" #include @@ -7,6 +8,9 @@ int main(int argc, char* argv[]) Q_INIT_RESOURCE(KodoTermThemes); QApplication app(argc, argv); + app.setOrganizationName(QStringLiteral("FireBugIT")); + app.setApplicationName(QStringLiteral("OrbitHub")); + app.setWindowIcon(createOrbitHubAppIcon()); ProfilesWindow window; window.show(); diff --git a/src/profile_dialog.cpp b/src/profile_dialog.cpp index 5704f51..2ddb557 100644 --- a/src/profile_dialog.cpp +++ b/src/profile_dialog.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,28 @@ int standardPortForProtocol(const QString& protocol) } 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) @@ -33,15 +56,18 @@ ProfileDialog::ProfileDialog(QWidget* parent) m_portInput(new QSpinBox(this)), m_usernameInput(new QLineEdit(this)), m_domainInput(new QLineEdit(this)), + m_tagsInput(new QLineEdit(this)), m_protocolInput(new QComboBox(this)), m_authModeInput(new QComboBox(this)), m_privateKeyPathInput(new QLineEdit(this)), m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)), m_knownHostsPolicyInput(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* form = new QFormLayout(); @@ -52,6 +78,7 @@ ProfileDialog::ProfileDialog(QWidget* parent) m_portInput->setValue(22); m_usernameInput->setPlaceholderText(QStringLiteral("deploy")); m_domainInput->setPlaceholderText(QStringLiteral("CONTOSO")); + m_tagsInput->setPlaceholderText(QStringLiteral("prod, linux, db")); m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")}); 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("Username"), m_usernameInput); form->addRow(QStringLiteral("Domain"), m_domainInput); + form->addRow(QStringLiteral("Tags"), m_tagsInput); form->addRow(QStringLiteral("Protocol"), m_protocolInput); form->addRow(QStringLiteral("Auth Mode"), m_authModeInput); 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."), this); note->setWordWrap(true); + m_protocolHint->setWordWrap(true); + m_folderHint->setWordWrap(true); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); layout->addLayout(form); + layout->addWidget(m_protocolHint); + layout->addWidget(m_folderHint); layout->addWidget(note); layout->addWidget(buttons); @@ -135,6 +167,12 @@ void ProfileDialog::setDialogTitle(const QString& title) setWindowTitle(title); } +void ProfileDialog::setDefaultFolderPath(const QString& folderPath) +{ + m_defaultFolderPath = folderPath.trimmed(); + refreshAuthFields(); +} + void ProfileDialog::setProfile(const Profile& profile) { 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_usernameInput->setText(profile.username); m_domainInput->setText(profile.domain); + m_defaultFolderPath = profile.folderPath.trimmed(); + m_tagsInput->setText(profile.tags); m_privateKeyPathInput->setText(profile.privateKeyPath); const int protocolIndex = m_protocolInput->findText(profile.protocol); @@ -169,18 +209,31 @@ void ProfileDialog::setProfile(const Profile& profile) Profile ProfileDialog::profile() const { Profile profile; + const QString protocol = normalizedProtocol(m_protocolInput->currentText()); + const QString authMode = normalizedAuthMode(protocol, m_authModeInput->currentText()); + profile.id = -1; profile.name = m_nameInput->text().trimmed(); profile.host = m_hostInput->text().trimmed(); profile.port = m_portInput->value(); profile.username = m_usernameInput->text().trimmed(); - profile.domain = m_domainInput->text().trimmed(); - profile.protocol = m_protocolInput->currentText(); - profile.authMode = m_authModeInput->currentText(); - profile.privateKeyPath = m_privateKeyPathInput->text().trimmed(); - profile.knownHostsPolicy = m_knownHostsPolicyInput->currentText(); - profile.rdpSecurityMode = m_rdpSecurityModeInput->currentText(); - profile.rdpPerformanceProfile = m_rdpPerformanceProfileInput->currentText(); + profile.domain = protocol == QStringLiteral("RDP") ? m_domainInput->text().trimmed() : QString(); + profile.folderPath = m_defaultFolderPath.trimmed(); + profile.tags = m_tagsInput->text().trimmed(); + profile.protocol = protocol; + profile.authMode = authMode; + profile.privateKeyPath = (protocol == QStringLiteral("SSH") + && 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; } @@ -209,14 +262,40 @@ void ProfileDialog::accept() 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(); } void ProfileDialog::refreshAuthFields() { - const bool isSsh = m_protocolInput->currentText() == QStringLiteral("SSH"); - const bool isRdp = m_protocolInput->currentText() == QStringLiteral("RDP"); - const bool isPrivateKey = m_authModeInput->currentText() == QStringLiteral("Private Key"); + const QString protocol = normalizedProtocol(m_protocolInput->currentText()); + const bool isSsh = protocol == QStringLiteral("SSH"); + 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_privateKeyPathInput->setEnabled(isSsh && isPrivateKey); @@ -225,4 +304,22 @@ void ProfileDialog::refreshAuthFields() m_domainInput->setEnabled(isRdp); m_rdpSecurityModeInput->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)); } diff --git a/src/profile_dialog.h b/src/profile_dialog.h index dfcd669..b146920 100644 --- a/src/profile_dialog.h +++ b/src/profile_dialog.h @@ -6,6 +6,7 @@ #include class QComboBox; +class QLabel; class QLineEdit; class QPushButton; class QSpinBox; @@ -18,6 +19,7 @@ public: explicit ProfileDialog(QWidget* parent = nullptr); void setDialogTitle(const QString& title); + void setDefaultFolderPath(const QString& folderPath); void setProfile(const Profile& profile); Profile profile() const; @@ -30,6 +32,7 @@ private: QSpinBox* m_portInput; QLineEdit* m_usernameInput; QLineEdit* m_domainInput; + QLineEdit* m_tagsInput; QComboBox* m_protocolInput; QComboBox* m_authModeInput; QLineEdit* m_privateKeyPathInput; @@ -37,6 +40,9 @@ private: QComboBox* m_knownHostsPolicyInput; QComboBox* m_rdpSecurityModeInput; QComboBox* m_rdpPerformanceProfileInput; + QLabel* m_protocolHint; + QLabel* m_folderHint; + QString m_defaultFolderPath; void refreshAuthFields(); }; diff --git a/src/profile_repository.cpp b/src/profile_repository.cpp index c936a9b..71a2b07 100644 --- a/src/profile_repository.cpp +++ b/src/profile_repository.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace { QString buildDatabasePath() @@ -52,21 +53,130 @@ QString normalizedRdpPerformanceProfile(const QString& value) 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 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) { - query.addBindValue(profile.name.trimmed()); - query.addBindValue(profile.host.trimmed()); + const QString protocol = normalizedProtocol(profile.protocol); + 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.username.trimmed()); - query.addBindValue(profile.domain.trimmed()); - query.addBindValue(profile.protocol.trimmed()); - query.addBindValue(profile.authMode.trimmed()); - query.addBindValue(profile.privateKeyPath.trimmed()); - query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty() - ? QStringLiteral("Ask") - : profile.knownHostsPolicy.trimmed()); - query.addBindValue(normalizedRdpSecurityMode(profile.rdpSecurityMode)); - query.addBindValue(normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)); + query.addBindValue(nonNullTrimmed(profile.username)); + query.addBindValue(isRdp ? nonNullTrimmed(profile.domain) : QStringLiteral("")); + query.addBindValue(nonNullTrimmed(normalizedFolderPath(profile.folderPath))); + query.addBindValue(protocol); + query.addBindValue(authMode); + query.addBindValue((isSsh && authMode == QStringLiteral("Private Key")) + ? nonNullTrimmed(profile.privateKeyPath) + : QStringLiteral("")); + query.addBindValue(isSsh ? normalizedKnownHostsPolicy(profile.knownHostsPolicy) + : 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) @@ -78,22 +188,67 @@ Profile profileFromQuery(const QSqlQuery& query) profile.port = query.value(3).toInt(); profile.username = query.value(4).toString(); profile.domain = query.value(5).toString(); - profile.protocol = query.value(6).toString(); - profile.authMode = query.value(7).toString(); - profile.privateKeyPath = query.value(8).toString(); - profile.knownHostsPolicy = query.value(9).toString(); - if (profile.knownHostsPolicy.isEmpty()) { - profile.knownHostsPolicy = QStringLiteral("Ask"); - } - profile.rdpSecurityMode = normalizedRdpSecurityMode(query.value(10).toString()); - profile.rdpPerformanceProfile = normalizedRdpPerformanceProfile(query.value(11).toString()); + profile.folderPath = normalizedFolderPath(query.value(6).toString()); + profile.protocol = normalizedProtocol(query.value(7).toString()); + profile.authMode = normalizedAuthMode(profile.protocol, query.value(8).toString()); + profile.privateKeyPath = profile.authMode == QStringLiteral("Private Key") + ? query.value(9).toString().trimmed() + : QString(); + profile.knownHostsPolicy = profile.protocol == QStringLiteral("SSH") + ? normalizedKnownHostsPolicy(query.value(10).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; } -bool isProfileValid(const Profile& profile) +bool isProfileValid(const Profile& profile, QString* error) { - return !profile.name.trimmed().isEmpty() && !profile.host.trimmed().isEmpty() - && profile.port >= 1 && profile.port <= 65535; + if (profile.name.trimmed().isEmpty()) { + 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; } -std::vector ProfileRepository::listProfiles(const QString& searchQuery) const +std::vector ProfileRepository::listFolders() const +{ + std::vector 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 ProfileRepository::listProfiles(const QString& searchQuery, + ProfileSortOrder sortOrder) const { std::vector result; @@ -136,20 +344,23 @@ std::vector ProfileRepository::listProfiles(const QString& searchQuery) setLastError(QString()); QSqlQuery query(QSqlDatabase::database(m_connectionName)); + const QString orderBy = orderByClause(sortOrder); if (searchQuery.trimmed().isEmpty()) { 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 " - "FROM profiles " - "ORDER BY lower(name) ASC, id ASC")); + "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 ") + + orderBy); } else { 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 " - "FROM profiles " - "WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) " - "ORDER BY lower(name) ASC, id ASC")); + "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 lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) OR lower(tags) LIKE lower(?) OR lower(folder_path) LIKE lower(?) ") + + orderBy); const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%"); query.addBindValue(search); query.addBindValue(search); + query.addBindValue(search); + query.addBindValue(search); } if (!query.exec()) { @@ -174,7 +385,7 @@ std::optional ProfileRepository::getProfile(qint64 id) const QSqlQuery query(QSqlDatabase::database(m_connectionName)); 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 = ?")); query.addBindValue(id); @@ -198,15 +409,16 @@ std::optional ProfileRepository::createProfile(const Profile& profile) setLastError(QString()); - if (!isProfileValid(profile)) { - setLastError(QStringLiteral("Name, host, and a valid port are required.")); + QString validationError; + if (!isProfileValid(profile, &validationError)) { + setLastError(validationError); return std::nullopt; } QSqlQuery query(QSqlDatabase::database(m_connectionName)); 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) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")); + "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")); bindProfileFields(query, profile); if (!query.exec()) { @@ -227,15 +439,17 @@ bool ProfileRepository::updateProfile(const Profile& profile) const setLastError(QString()); - if (profile.id < 0 || !isProfileValid(profile)) { - setLastError(QStringLiteral("Invalid profile data.")); + QString validationError; + if (profile.id < 0 || !isProfileValid(profile, &validationError)) { + setLastError(validationError.isEmpty() ? QStringLiteral("Invalid profile data.") + : validationError); return false; } QSqlQuery query(QSqlDatabase::database(m_connectionName)); query.prepare(QStringLiteral( "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 = ?")); bindProfileFields(query, profile); query.addBindValue(profile.id); @@ -287,12 +501,14 @@ bool ProfileRepository::initializeDatabase() "port INTEGER NOT NULL DEFAULT 22," "username TEXT NOT NULL DEFAULT ''," "domain TEXT NOT NULL DEFAULT ''," + "folder_path TEXT NOT NULL DEFAULT ''," "protocol TEXT NOT NULL DEFAULT 'SSH'," "auth_mode TEXT NOT NULL DEFAULT 'Password'," "private_key_path TEXT NOT NULL DEFAULT ''," "known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'," "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) { @@ -300,6 +516,15 @@ bool ProfileRepository::initializeDatabase() 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()) { m_initError = m_lastError; 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("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("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("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("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_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) { 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()); return true; } diff --git a/src/profile_repository.h b/src/profile_repository.h index 403f5e7..41cde3e 100644 --- a/src/profile_repository.h +++ b/src/profile_repository.h @@ -15,12 +15,20 @@ struct Profile int port = 22; QString username; QString domain; + QString folderPath; QString protocol = QStringLiteral("SSH"); QString authMode = QStringLiteral("Password"); QString privateKeyPath; QString knownHostsPolicy = QStringLiteral("Ask"); QString rdpSecurityMode = QStringLiteral("Negotiate"); QString rdpPerformanceProfile = QStringLiteral("Balanced"); + QString tags; +}; + +enum class ProfileSortOrder { + NameAsc, + ProtocolAsc, + HostAsc, }; class ProfileRepository @@ -32,7 +40,10 @@ public: QString initError() const; QString lastError() const; - std::vector listProfiles(const QString& searchQuery = QString()) const; + std::vector listProfiles(const QString& searchQuery = QString(), + ProfileSortOrder sortOrder = ProfileSortOrder::NameAsc) const; + std::vector listFolders() const; + bool createFolder(const QString& folderPath) const; std::optional getProfile(qint64 id) const; std::optional createProfile(const Profile& profile) const; bool updateProfile(const Profile& profile) const; diff --git a/src/profiles_tree_widget.cpp b/src/profiles_tree_widget.cpp new file mode 100644 index 0000000..5bc2035 --- /dev/null +++ b/src/profiles_tree_widget.cpp @@ -0,0 +1,11 @@ +#include "profiles_tree_widget.h" + +#include + +ProfilesTreeWidget::ProfilesTreeWidget(QWidget* parent) : QTreeWidget(parent) {} + +void ProfilesTreeWidget::dropEvent(QDropEvent* event) +{ + QTreeWidget::dropEvent(event); + emit itemsDropped(); +} diff --git a/src/profiles_tree_widget.h b/src/profiles_tree_widget.h new file mode 100644 index 0000000..ec1aef1 --- /dev/null +++ b/src/profiles_tree_widget.h @@ -0,0 +1,22 @@ +#ifndef ORBITHUB_PROFILES_TREE_WIDGET_H +#define ORBITHUB_PROFILES_TREE_WIDGET_H + +#include + +class QDropEvent; + +class ProfilesTreeWidget : public QTreeWidget +{ + Q_OBJECT + +public: + explicit ProfilesTreeWidget(QWidget* parent = nullptr); + +signals: + void itemsDropped(); + +protected: + void dropEvent(QDropEvent* event) override; +}; + +#endif diff --git a/src/profiles_window.cpp b/src/profiles_window.cpp index 85a4903..86b76a0 100644 --- a/src/profiles_window.cpp +++ b/src/profiles_window.cpp @@ -1,40 +1,112 @@ #include "profiles_window.h" +#include "about_dialog.h" #include "profile_dialog.h" #include "profile_repository.h" +#include "profiles_tree_widget.h" #include "session_window.h" +#include #include +#include +#include #include #include #include -#include -#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include 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]") - .arg(profile.name, profile.protocol, profile.host, QString::number(profile.port)); + 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 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) : QMainWindow(parent), 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_editButton(nullptr), m_deleteButton(nullptr), m_repository(std::make_unique()) { setWindowTitle(QStringLiteral("OrbitHub Profiles")); - resize(640, 620); + resize(860, 640); + setWindowIcon(QApplication::windowIcon()); setupUi(); @@ -47,10 +119,11 @@ ProfilesWindow::ProfilesWindow(QWidget* parent) m_editButton->setEnabled(false); m_deleteButton->setEnabled(false); m_searchBox->setEnabled(false); - m_profilesList->setEnabled(false); + m_profilesTree->setEnabled(false); return; } + loadUiPreferences(); loadProfiles(); } @@ -63,10 +136,47 @@ void ProfilesWindow::setupUi() auto* searchLabel = new QLabel(QStringLiteral("Search"), 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); - m_profilesList->setSelectionMode(QAbstractItemView::SingleSelection); + auto* viewModeLabel = new QLabel(QStringLiteral("View"), central); + 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(ProfileSortOrder::NameAsc)); + m_sortBox->addItem(QStringLiteral("Protocol"), static_cast(ProfileSortOrder::ProtocolAsc)); + m_sortBox->addItem(QStringLiteral("Host"), static_cast(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(); m_newButton = new QPushButton(QStringLiteral("New"), central); @@ -78,34 +188,117 @@ void ProfilesWindow::setupUi() buttonRow->addWidget(m_deleteButton); buttonRow->addStretch(); - rootLayout->addWidget(searchLabel); - rootLayout->addWidget(m_searchBox); - rootLayout->addWidget(m_profilesList, 1); + auto* filterRow = new QHBoxLayout(); + filterRow->addWidget(searchLabel); + 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); 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, &QLineEdit::textChanged, this, - [this](const QString& text) { loadProfiles(text); }); - - connect(m_profilesList, - &QListWidget::itemDoubleClicked, + [this](const QString&) { + saveUiPreferences(); + loadProfiles(); + }); + connect(m_viewModeBox, + &QComboBox::currentIndexChanged, 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_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); }); 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(); - const std::vector profiles = m_repository->listProfiles(query); + const QString query = m_searchBox == nullptr ? QString() : m_searchBox->text(); + const std::vector profiles = m_repository->listProfiles(query, selectedSortOrder()); if (!m_repository->lastError().isEmpty()) { QMessageBox::warning(this, QStringLiteral("Load Profiles"), @@ -114,44 +307,522 @@ void ProfilesWindow::loadProfiles(const QString& query) 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 filteredProfiles; + filteredProfiles.reserve(profiles.size()); for (const Profile& profile : profiles) { - auto* item = new QListWidgetItem(formatProfileListItem(profile), m_profilesList); - item->setData(Qt::UserRole, QVariant::fromValue(profile.id)); - 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("") - : profile.username.trimmed()); - } - return profile.username.isEmpty() ? QStringLiteral("") : 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); + if (!protocolFilter.isEmpty() + && profile.protocol.compare(protocolFilter, Qt::CaseInsensitive) != 0) { + continue; } - item->setToolTip(tooltip); + if (!tagFilter.isEmpty() && !profileHasTag(profile, tagFilter)) { + continue; + } + filteredProfiles.push_back(profile); + } + + if (folderView) { + std::map folderNodes; + const std::vector 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(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& 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 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& 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("") + : profile.username.trimmed()); + } + return profile.username.trimmed().isEmpty() ? QStringLiteral("") + : 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 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 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); + ++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& 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(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 ProfilesWindow::selectedProfile() const { - QListWidgetItem* item = m_profilesList->currentItem(); + QTreeWidgetItem* item = m_profilesTree->currentItem(); if (item == nullptr) { return std::nullopt; } - const QVariant value = item->data(Qt::UserRole); + const QVariant value = item->data(0, kProfileIdRole); if (!value.isValid()) { return std::nullopt; } @@ -165,16 +836,22 @@ std::optional ProfilesWindow::selectedProfile() const return m_repository->getProfile(id); } -void ProfilesWindow::createProfile() +void ProfilesWindow::createProfile(const QString& defaultFolderPath) { ProfileDialog dialog(this); dialog.setDialogTitle(QStringLiteral("New Profile")); + dialog.setDefaultFolderPath(defaultFolderPath); if (dialog.exec() != QDialog::Accepted) { 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, QStringLiteral("Create Profile"), QStringLiteral("Failed to create profile: %1") @@ -184,7 +861,7 @@ void ProfilesWindow::createProfile() return; } - loadProfiles(m_searchBox->text()); + loadProfiles(); } void ProfilesWindow::editSelectedProfile() @@ -207,6 +884,9 @@ void ProfilesWindow::editSelectedProfile() Profile updated = dialog.profile(); updated.id = selected->id; + if (!updated.folderPath.trimmed().isEmpty()) { + m_repository->createFolder(updated.folderPath); + } if (!m_repository->updateProfile(updated)) { QMessageBox::warning(this, @@ -218,7 +898,7 @@ void ProfilesWindow::editSelectedProfile() return; } - loadProfiles(m_searchBox->text()); + loadProfiles(); } void ProfilesWindow::deleteSelectedProfile() @@ -252,17 +932,18 @@ void ProfilesWindow::deleteSelectedProfile() return; } - loadProfiles(m_searchBox->text()); + loadProfiles(); } -void ProfilesWindow::openSessionForItem(QListWidgetItem* item) +void ProfilesWindow::openSessionForItem(QTreeWidgetItem* item) { if (item == nullptr) { return; } - const QVariant value = item->data(Qt::UserRole); + const QVariant value = item->data(0, kProfileIdRole); if (!value.isValid()) { + item->setExpanded(!item->isExpanded()); return; } diff --git a/src/profiles_window.h b/src/profiles_window.h index 33c548b..63a7198 100644 --- a/src/profiles_window.h +++ b/src/profiles_window.h @@ -5,18 +5,24 @@ #include #include +#include #include #include +#include #include #include +#include #include -class QListWidget; -class QListWidgetItem; +class QTreeWidget; +class QTreeWidgetItem; class QLineEdit; class QPushButton; +class QComboBox; +class QPoint; class SessionWindow; +class ProfilesTreeWidget; class ProfilesWindow : public QMainWindow { @@ -28,21 +34,43 @@ public: private: 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_editButton; QPushButton* m_deleteButton; QPointer m_sessionWindow; std::unique_ptr m_repository; std::unordered_map m_profileCache; + QString m_pendingTagFilterPreference; 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& profiles); + QTreeWidgetItem* upsertFolderNode(const QStringList& folderParts, + std::map& 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& assignments) const; + void loadUiPreferences(); + void saveUiPreferences() const; std::optional selectedProfile() const; - void createProfile(); + void createProfile(const QString& defaultFolderPath = QString()); void editSelectedProfile(); void deleteSelectedProfile(); - void openSessionForItem(QListWidgetItem* item); + void openSessionForItem(QTreeWidgetItem* item); }; #endif diff --git a/src/session_tab.cpp b/src/session_tab.cpp index fa2088e..47de9f3 100644 --- a/src/session_tab.cpp +++ b/src/session_tab.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -17,10 +18,14 @@ #include #include #include +#include +#include +#include #include #include #include #include +#include #include #include @@ -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), m_profile(profile), m_backendThread(nullptr), @@ -61,13 +68,21 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent) m_useKodoTermForSsh(profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0), m_state(SessionState::Disconnected), - m_terminalThemeName(QStringLiteral("Dark")), + m_terminalThemeName(preferences.terminalThemeName.trimmed().isEmpty() + ? QStringLiteral("Dark") + : preferences.terminalThemeName.trimmed()), m_sshTerminal(nullptr), m_rdpDisplay(nullptr), m_terminalOutput(nullptr), m_eventLog(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"); qRegisterMetaType("SessionState"); @@ -347,6 +362,7 @@ void SessionTab::setTerminalThemeName(const QString& themeName) m_terminalThemeName = normalized; applyTerminalTheme(m_terminalThemeName); appendEvent(QStringLiteral("Terminal theme set to %1.").arg(m_terminalThemeName)); + emit terminalThemeChanged(m_terminalThemeName); } QString SessionTab::terminalThemeName() const @@ -364,6 +380,111 @@ bool SessionTab::supportsClearAction() const 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) { setState(state, message); @@ -445,7 +566,21 @@ void SessionTab::setupUi() auto* eventsHeader = new QHBoxLayout(); m_toggleEventsButton = new QToolButton(this); 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_eventFilterInput, 1); + eventsHeader->addWidget(m_eventSeverityFilterInput); + eventsHeader->addWidget(m_exportEventsButton); + eventsHeader->addWidget(m_clearEventsButton); eventsHeader->addStretch(); m_eventsPanel = new QWidget(this); @@ -464,15 +599,43 @@ void SessionTab::setupUi() rootLayout->addLayout(eventsHeader); rootLayout->addWidget(m_eventsPanel); - setPanelExpanded(m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), false); + setPanelExpanded( + m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), m_eventsPanelExpanded); connect(m_toggleEventsButton, &QToolButton::toggled, this, [this](bool expanded) { - setPanelExpanded( - m_toggleEventsButton, m_eventsPanel, QStringLiteral("Events"), expanded); + setEventsPanelExpanded(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) { connect(m_terminalOutput, @@ -639,7 +802,14 @@ bool SessionTab::validateProfileForConnect() void SessionTab::appendEvent(const QString& message) { 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(kMaxEventLines)) { + m_eventEntries.erase(m_eventEntries.begin()); + } + refreshEventLogView(); } void SessionTab::setState(SessionState state, const QString& message) @@ -803,3 +973,51 @@ void SessionTab::applyTerminalTheme(const QString& themeName) m_terminalOutput->setThemeName(themeName); } } + +void SessionTab::refreshEventLogView() +{ + if (m_eventLog == nullptr) { + return; + } + + QStringList visibleLines; + visibleLines.reserve(static_cast(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; +} diff --git a/src/session_tab.h b/src/session_tab.h index 4fe899f..6e70a8d 100644 --- a/src/session_tab.h +++ b/src/session_tab.h @@ -5,9 +5,11 @@ #include "session_backend.h" #include +#include #include #include +#include class QPlainTextEdit; class QThread; @@ -15,14 +17,24 @@ class SessionBackend; class TerminalView; class RdpDisplayWidget; class QToolButton; +class QLineEdit; +class QComboBox; class KodoTerm; +struct SessionUiPreferences +{ + QString terminalThemeName = QStringLiteral("Dark"); + bool eventsPanelExpanded = false; +}; + class SessionTab : public QWidget { Q_OBJECT public: - explicit SessionTab(const Profile& profile, QWidget* parent = nullptr); + explicit SessionTab(const Profile& profile, + const SessionUiPreferences& preferences, + QWidget* parent = nullptr); ~SessionTab() override; QString tabTitle() const; @@ -34,10 +46,17 @@ public: QString terminalThemeName() const; bool supportsThemeSelection() const; bool supportsClearAction() const; + bool isEventsPanelExpanded() const; + void setEventsPanelExpanded(bool expanded); + void clearEvents(); + void copyEvents() const; + void exportEventsToFile(); signals: void tabTitleChanged(const QString& title); void tabStateChanged(SessionState state); + void terminalThemeChanged(const QString& themeName); + void eventsPanelVisibilityChanged(bool expanded); void requestConnect(const SessionConnectOptions& options); void requestDisconnect(); void requestReconnect(const SessionConnectOptions& options); @@ -75,7 +94,24 @@ private: TerminalView* m_terminalOutput; QPlainTextEdit* m_eventLog; QToolButton* m_toggleEventsButton; + QLineEdit* m_eventFilterInput; + QComboBox* m_eventSeverityFilterInput; + QToolButton* m_clearEventsButton; + QToolButton* m_exportEventsButton; QWidget* m_eventsPanel; + enum class EventSeverity { + Info, + Warning, + Error, + }; + struct EventEntry { + QString line; + EventSeverity severity; + }; + std::vector m_eventEntries; + QString m_eventFilter; + EventSeverity m_eventSeverityFilter; + bool m_eventsPanelExpanded; void setupUi(); std::optional buildConnectOptions(); @@ -87,6 +123,8 @@ private: void setPanelExpanded(QToolButton* button, QWidget* panel, const QString& name, bool expanded); bool startSshTerminal(const SessionConnectOptions& options); void applyTerminalTheme(const QString& themeName); + void refreshEventLogView(); + static EventSeverity classifyEventSeverity(const QString& message); }; #endif diff --git a/src/session_window.cpp b/src/session_window.cpp index 0db761f..fccd5d1 100644 --- a/src/session_window.cpp +++ b/src/session_window.cpp @@ -1,11 +1,15 @@ #include "session_window.h" +#include "about_dialog.h" +#include #include "session_tab.h" #include #include #include +#include #include +#include #include #include #include @@ -36,8 +40,11 @@ QStringList terminalThemeNames() SessionWindow::SessionWindow(const Profile& profile, QWidget* parent) : QMainWindow(parent), m_tabs(new QTabWidget(this)) { + loadUiPreferences(); + setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name)); resize(1080, 760); + setWindowIcon(QApplication::windowIcon()); m_tabs->setTabsClosable(true); connect(m_tabs, @@ -72,6 +79,12 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent) QMenu menu(this); QAction* disconnectAction = menu.addAction(QStringLiteral("Disconnect")); 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 themeActions; if (tab->supportsThemeSelection()) { @@ -97,6 +110,14 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent) tab->disconnectSession(); } else if (chosen == reconnectAction) { 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) { tab->clearTerminal(); } 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); addSessionTab(profile); } @@ -120,7 +151,7 @@ void SessionWindow::openProfile(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()); m_tabs->setCurrentIndex(index); 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) @@ -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); +} diff --git a/src/session_window.h b/src/session_window.h index 408449a..9afa305 100644 --- a/src/session_window.h +++ b/src/session_window.h @@ -2,11 +2,11 @@ #define ORBITHUB_SESSION_WINDOW_H #include "profile_repository.h" +#include "session_tab.h" #include class QTabWidget; -class SessionTab; class SessionWindow : public QMainWindow { @@ -18,9 +18,12 @@ public: private: QTabWidget* m_tabs; + SessionUiPreferences m_preferences; void addSessionTab(const Profile& profile); void updateTabTitle(SessionTab* tab, const QString& title); + void loadUiPreferences(); + void saveUiPreferences() const; }; #endif