Milestone 8 UX: folder tree workflows, about dialog, and app icon polish

This commit is contained in:
2026-03-03 20:07:41 -07:00
parent 36006bd4aa
commit eadcdd7f10
19 changed files with 1789 additions and 129 deletions
+278 -42
View File
@@ -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;
}