Milestone 8 UX: folder tree workflows, about dialog, and app icon polish
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
92
src/about_dialog.cpp
Normal file
92
src/about_dialog.cpp
Normal 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
14
src/about_dialog.h
Normal 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
101
src/app_icon.cpp
Normal 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
8
src/app_icon.h
Normal file
@@ -0,0 +1,8 @@
|
||||
#ifndef ORBITHUB_APP_ICON_H
|
||||
#define ORBITHUB_APP_ICON_H
|
||||
|
||||
#include <QIcon>
|
||||
|
||||
QIcon createOrbitHubAppIcon();
|
||||
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "app_icon.h"
|
||||
#include "profiles_window.h"
|
||||
|
||||
#include <QApplication>
|
||||
@@ -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();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <QDialog>
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <QSqlQuery>
|
||||
#include <QStandardPaths>
|
||||
#include <QVariant>
|
||||
#include <QStringList>
|
||||
|
||||
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<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)
|
||||
{
|
||||
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<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;
|
||||
|
||||
@@ -136,20 +344,23 @@ std::vector<Profile> 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<Profile> 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<Profile> 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;
|
||||
}
|
||||
|
||||
@@ -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<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> createProfile(const Profile& profile) const;
|
||||
bool updateProfile(const Profile& profile) const;
|
||||
|
||||
11
src/profiles_tree_widget.cpp
Normal file
11
src/profiles_tree_widget.cpp
Normal 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();
|
||||
}
|
||||
22
src/profiles_tree_widget.h
Normal file
22
src/profiles_tree_widget.h
Normal 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
|
||||
@@ -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 <QAction>
|
||||
#include <QAbstractItemView>
|
||||
#include <QComboBox>
|
||||
#include <QHeaderView>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QInputDialog>
|
||||
#include <QMenu>
|
||||
#include <QMenuBar>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QSet>
|
||||
#include <QSettings>
|
||||
#include <QSignalBlocker>
|
||||
#include <QApplication>
|
||||
#include <QStringList>
|
||||
#include <QStyle>
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QVariant>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
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<ProfileRepository>())
|
||||
{
|
||||
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<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();
|
||||
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<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()) {
|
||||
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<Profile> 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("<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);
|
||||
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<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);
|
||||
++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
|
||||
{
|
||||
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<Profile> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,24 @@
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <memory>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <QPointer>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
|
||||
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<SessionWindow> m_sessionWindow;
|
||||
std::unique_ptr<ProfileRepository> m_repository;
|
||||
std::unordered_map<qint64, Profile> 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<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;
|
||||
void createProfile();
|
||||
void createProfile(const QString& defaultFolderPath = QString());
|
||||
void editSelectedProfile();
|
||||
void deleteSelectedProfile();
|
||||
void openSessionForItem(QListWidgetItem* item);
|
||||
void openSessionForItem(QTreeWidgetItem* item);
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <KodoTerm/KodoTerm.hpp>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QFont>
|
||||
@@ -17,10 +18,14 @@
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QComboBox>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QTextStream>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#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),
|
||||
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>("SessionConnectOptions");
|
||||
qRegisterMetaType<SessionState>("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<size_t>(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<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;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
#include "session_backend.h"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QStringList>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
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<EventEntry> m_eventEntries;
|
||||
QString m_eventFilter;
|
||||
EventSeverity m_eventSeverityFilter;
|
||||
bool m_eventsPanelExpanded;
|
||||
|
||||
void setupUi();
|
||||
std::optional<SessionConnectOptions> 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
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
#include "session_window.h"
|
||||
|
||||
#include "about_dialog.h"
|
||||
#include <QApplication>
|
||||
#include "session_tab.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QColor>
|
||||
#include <QMenu>
|
||||
#include <QMenuBar>
|
||||
#include <QPalette>
|
||||
#include <QSettings>
|
||||
#include <QStringList>
|
||||
#include <QTabBar>
|
||||
#include <QTabWidget>
|
||||
@@ -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<QAction*> 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);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
#define ORBITHUB_SESSION_WINDOW_H
|
||||
|
||||
#include "profile_repository.h"
|
||||
#include "session_tab.h"
|
||||
|
||||
#include <QMainWindow>
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user