diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f9a7ae..a7513f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.21) -project(OrbitHub VERSION 2026.9.26.2 LANGUAGES CXX) +project(OrbitHub VERSION 2026.9.28 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 20804a4..9e54ef6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -424,6 +424,23 @@ Git: - Release: [v2026.9.14.2](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.14.2) — same-day patch fixing RDP display corruption (missing/misplaced taskbar) after resizing the session window (the client never resized its own display buffer for channel-driven RDP resizes) - Release: [v2026.9.15](https://git.darksingularity.org/DarkSingularity/orbithub/releases/tag/v2026.9.15) — adds Import/Export for profile lists (File menu) +- Quick Connect (issue #23): a `File > Quick Connect...` action and a + matching button on the Profiles tab open a connection without saving a + profile first. Reuses `ProfileDialog` via a new `setQuickConnectMode()` + toggle (Name/Tags/Folder hidden, Name validation skipped) rather than a + second dialog class, so all protocol-specific field logic + (`refreshAuthFields()`) stays in one place. The tab title is derived + from host/username/protocol (e.g. `user@host`) purely for display -- + never written to the database. A Quick Connect tab's right-click menu + gets a "Save as Profile..." action (absent on normal profile-backed + tabs) that opens the same dialog pre-filled to persist it afterward. + Detecting a Quick Connect tab needed no new state: `ProfileDialog:: + profile()` already always returns `id = -1` for an unsaved profile, + and Quick Connect is the only path today that hands such a profile to + `SessionWindow::addSessionTab()`. GUI-only change with no automated + test coverage (consistent with `ProfileDialog` never having had any); + verified manually by running the built binary + ## Milestone 10 - v1.0 Stabilization Status: Planned diff --git a/src/profile_dialog.cpp b/src/profile_dialog.cpp index 3de71ed..4cfb3ee 100644 --- a/src/profile_dialog.cpp +++ b/src/profile_dialog.cpp @@ -65,7 +65,10 @@ ProfileDialog::ProfileDialog(QWidget* parent) m_rdpSecurityModeInput(new QComboBox(this)), m_rdpPerformanceProfileInput(new QComboBox(this)), m_protocolHint(new QLabel(this)), - m_folderHint(new QLabel(this)) + m_folderHint(new QLabel(this)), + m_nameLabel(nullptr), + m_tagsLabel(nullptr), + m_quickConnectMode(false) { resize(560, 360); @@ -145,6 +148,9 @@ ProfileDialog::ProfileDialog(QWidget* parent) form->addRow(QStringLiteral("RDP Security"), m_rdpSecurityModeInput); form->addRow(QStringLiteral("RDP Performance"), m_rdpPerformanceProfileInput); + m_nameLabel = form->labelForField(m_nameInput); + m_tagsLabel = form->labelForField(m_tagsInput); + auto* note = new QLabel( QStringLiteral("Passwords are requested at connect time and are not stored."), this); @@ -176,6 +182,24 @@ void ProfileDialog::setDefaultFolderPath(const QString& folderPath) refreshAuthFields(); } +void ProfileDialog::setQuickConnectMode(bool enabled) +{ + m_quickConnectMode = enabled; + + const bool showOrganizationalFields = !enabled; + if (m_nameLabel != nullptr) { + m_nameLabel->setVisible(showOrganizationalFields); + } + m_nameInput->setVisible(showOrganizationalFields); + if (m_tagsLabel != nullptr) { + m_tagsLabel->setVisible(showOrganizationalFields); + } + m_tagsInput->setVisible(showOrganizationalFields); + m_folderHint->setVisible(showOrganizationalFields); + + refreshAuthFields(); +} + void ProfileDialog::setProfile(const Profile& profile) { m_nameInput->setText(profile.name); @@ -237,12 +261,26 @@ Profile ProfileDialog::profile() const profile.rdpPerformanceProfile = protocol == QStringLiteral("RDP") ? m_rdpPerformanceProfileInput->currentText() : QStringLiteral("Balanced"); + + if (m_quickConnectMode) { + // Name is never entered in Quick Connect mode -- derive something + // usable for the session tab's title. Only ever a starting point: + // never written to the database directly, and fully editable if + // the user later saves this connection as a real profile. + profile.name = profile.username.isEmpty() + ? profile.host + : QStringLiteral("%1@%2").arg(profile.username, profile.host); + if (protocol != QStringLiteral("SSH")) { + profile.name = QStringLiteral("%1 (%2)").arg(profile.name, protocol); + } + } + return profile; } void ProfileDialog::accept() { - if (m_nameInput->text().trimmed().isEmpty()) { + if (!m_quickConnectMode && m_nameInput->text().trimmed().isEmpty()) { QMessageBox::warning(this, QStringLiteral("Validation Error"), QStringLiteral("Profile name is required.")); diff --git a/src/profile_dialog.h b/src/profile_dialog.h index b146920..2cd1373 100644 --- a/src/profile_dialog.h +++ b/src/profile_dialog.h @@ -21,6 +21,12 @@ public: void setDialogTitle(const QString& title); void setDefaultFolderPath(const QString& folderPath); void setProfile(const Profile& profile); + // Hides Name/Tags/Folder and skips the Name-required validation, for + // Quick Connect (issue #23) -- a connection opened without ever being + // saved as a profile. profile() derives a usable name from + // host/username/protocol instead. Everything else (protocol-specific + // fields, defaults) behaves identically to a normal New Profile. + void setQuickConnectMode(bool enabled); Profile profile() const; protected: @@ -42,7 +48,10 @@ private: QComboBox* m_rdpPerformanceProfileInput; QLabel* m_protocolHint; QLabel* m_folderHint; + QWidget* m_nameLabel; + QWidget* m_tagsLabel; QString m_defaultFolderPath; + bool m_quickConnectMode; void refreshAuthFields(); }; diff --git a/src/profiles_window.cpp b/src/profiles_window.cpp index 0f51ba8..2c6d5f9 100644 --- a/src/profiles_window.cpp +++ b/src/profiles_window.cpp @@ -151,6 +151,7 @@ ProfilesWindow::ProfilesWindow(QWidget* parent) m_newButton(nullptr), m_editButton(nullptr), m_deleteButton(nullptr), + m_quickConnectButton(nullptr), m_repository(std::make_unique()) { setupUi(); @@ -226,10 +227,12 @@ void ProfilesWindow::setupUi() m_newButton = new QPushButton(QStringLiteral("New"), this); m_editButton = new QPushButton(QStringLiteral("Edit"), this); m_deleteButton = new QPushButton(QStringLiteral("Delete"), this); + m_quickConnectButton = new QPushButton(QStringLiteral("Quick Connect"), this); buttonRow->addWidget(m_newButton); buttonRow->addWidget(m_editButton); buttonRow->addWidget(m_deleteButton); + buttonRow->addWidget(m_quickConnectButton); buttonRow->addStretch(); auto* filterRow = new QHBoxLayout(); @@ -300,6 +303,7 @@ void ProfilesWindow::setupUi() connect(m_newButton, &QPushButton::clicked, this, [this]() { createProfile(); }); connect(m_editButton, &QPushButton::clicked, this, [this]() { editSelectedProfile(); }); connect(m_deleteButton, &QPushButton::clicked, this, [this]() { deleteSelectedProfile(); }); + connect(m_quickConnectButton, &QPushButton::clicked, this, [this]() { quickConnect(); }); } void ProfilesWindow::loadProfiles() @@ -932,7 +936,11 @@ void ProfilesWindow::createProfile(const QString& defaultFolderPath) return; } - const Profile newProfile = dialog.profile(); + createProfileFromExisting(dialog.profile()); +} + +bool ProfilesWindow::createProfileFromExisting(const Profile& newProfile) +{ if (!newProfile.folderPath.trimmed().isEmpty()) { m_repository->createFolder(newProfile.folderPath); } @@ -944,10 +952,24 @@ void ProfilesWindow::createProfile(const QString& defaultFolderPath) .arg(m_repository->lastError().isEmpty() ? QStringLiteral("unknown error") : m_repository->lastError())); - return; + return false; } loadProfiles(); + return true; +} + +void ProfilesWindow::quickConnect() +{ + ProfileDialog dialog(this); + dialog.setDialogTitle(QStringLiteral("Quick Connect")); + dialog.setQuickConnectMode(true); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + emit connectRequested(dialog.profile()); } void ProfilesWindow::editSelectedProfile() diff --git a/src/profiles_window.h b/src/profiles_window.h index 4591a92..a00bac9 100644 --- a/src/profiles_window.h +++ b/src/profiles_window.h @@ -35,6 +35,18 @@ public: void exportProfiles(); void importProfiles(); void importFromMRemoteNG(); + // Opens the New/Edit Profile dialog with Name/Tags/Folder hidden (see + // ProfileDialog::setQuickConnectMode()) and, on accept, emits + // connectRequested() directly -- never writes to the repository + // (issue #23). + void quickConnect(); + // Persists an already-built Profile (e.g. from ProfileDialog::profile()) + // the same way createProfile() does after its own dialog accepts: + // optional folder creation, repository insert with a failure warning, + // then refresh. Used by SessionWindow's "Save as Profile..." action so + // repository access stays inside ProfilesWindow. Returns true on + // success. + bool createProfileFromExisting(const Profile& newProfile); signals: void connectRequested(const Profile& profile); @@ -49,6 +61,7 @@ private: QPushButton* m_newButton; QPushButton* m_editButton; QPushButton* m_deleteButton; + QPushButton* m_quickConnectButton; std::unique_ptr m_repository; std::unordered_map m_profileCache; QString m_pendingTagFilterPreference; diff --git a/src/session_tab.cpp b/src/session_tab.cpp index 2f0237f..c8ed0ff 100644 --- a/src/session_tab.cpp +++ b/src/session_tab.cpp @@ -387,6 +387,16 @@ bool SessionTab::awaitingUserInput() const return m_awaitingUserInput; } +bool SessionTab::isQuickConnect() const +{ + return m_profile.id <= 0; +} + +Profile SessionTab::profileForSaving() const +{ + return m_profile; +} + void SessionTab::connectSession() { if (m_state == SessionState::Connecting || m_state == SessionState::Connected) { diff --git a/src/session_tab.h b/src/session_tab.h index e96c4c0..c2102da 100644 --- a/src/session_tab.h +++ b/src/session_tab.h @@ -69,6 +69,14 @@ public: void setVncScaleToFit(bool scaleToFit); bool vncScaleToFit() const; bool awaitingUserInput() const; + // True if this tab's profile was never saved to the repository (i.e. + // it came from Quick Connect, issue #23, rather than a saved profile). + // ProfileDialog::profile() always returns id <= 0 for an unsaved + // profile, and Quick Connect is the only path that hands such a + // profile to SessionWindow::addSessionTab() -- every other path looks + // the profile up from the repository first, which returns id > 0. + bool isQuickConnect() const; + Profile profileForSaving() const; signals: void tabTitleChanged(const QString& title); diff --git a/src/session_window.cpp b/src/session_window.cpp index f4fb26f..8300d5c 100644 --- a/src/session_window.cpp +++ b/src/session_window.cpp @@ -1,6 +1,7 @@ #include "session_window.h" #include "about_dialog.h" +#include "profile_dialog.h" #include "profiles_window.h" #include "user_guide_dialog.h" #include @@ -148,6 +149,12 @@ SessionWindow::SessionWindow(QWidget* parent) actualSizeAction->setChecked(!tab->vncScaleToFit()); } + QAction* saveAsProfileAction = nullptr; + if (tab->isQuickConnect()) { + menu.addSeparator(); + saveAsProfileAction = menu.addAction(QStringLiteral("Save as Profile...")); + } + QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos)); if (chosen == disconnectAction) { tab->disconnectSession(); @@ -188,6 +195,8 @@ SessionWindow::SessionWindow(QWidget* parent) tab->setVncScaleToFit(true); } else if (actualSizeAction != nullptr && chosen == actualSizeAction) { tab->setVncScaleToFit(false); + } else if (saveAsProfileAction != nullptr && chosen == saveAsProfileAction) { + saveTabAsProfile(tab); } else { for (QAction* themeAction : themeActions) { if (chosen == themeAction) { @@ -201,6 +210,7 @@ SessionWindow::SessionWindow(QWidget* parent) QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("File")); QAction* newProfileAction = fileMenu->addAction(QStringLiteral("New Profile")); QAction* newFolderAction = fileMenu->addAction(QStringLiteral("New Folder")); + QAction* quickConnectAction = fileMenu->addAction(QStringLiteral("Quick Connect...")); fileMenu->addSeparator(); QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles...")); QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles...")); @@ -216,6 +226,10 @@ SessionWindow::SessionWindow(QWidget* parent) &QAction::triggered, this, [this]() { m_profilesWidget->createFolderInCurrentContext(); }); + connect(quickConnectAction, + &QAction::triggered, + this, + [this]() { m_profilesWidget->quickConnect(); }); connect(importProfilesAction, &QAction::triggered, this, @@ -368,6 +382,19 @@ int SessionWindow::sessionTabCount() const return m_tabs->count() - 1; } +void SessionWindow::saveTabAsProfile(SessionTab* tab) +{ + ProfileDialog dialog(this); + dialog.setDialogTitle(QStringLiteral("Save as Profile")); + dialog.setProfile(tab->profileForSaving()); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + m_profilesWidget->createProfileFromExisting(dialog.profile()); +} + void SessionWindow::loadUiPreferences() { QSettings settings; diff --git a/src/session_window.h b/src/session_window.h index 63e88df..58e5863 100644 --- a/src/session_window.h +++ b/src/session_window.h @@ -32,6 +32,7 @@ private: void loadUiPreferences(); void saveUiPreferences() const; int sessionTabCount() const; + void saveTabAsProfile(SessionTab* tab); }; #endif