Internal
Public Access
Compare commits
2
Commits
v2026.9.26
...
v2026.9.28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
988637eb39 | ||
|
|
67ea7ea6b3 |
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(OrbitHub VERSION 2026.9.26 LANGUAGES CXX)
|
||||
project(OrbitHub VERSION 2026.9.28 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
@@ -242,6 +242,11 @@ Delivered:
|
||||
and not-yet-ready-channel copies are logged to the session event log
|
||||
(with a line per copy sent/received) instead of silently dropped. Not
|
||||
reproduced locally; needs confirmation from real use
|
||||
- Follow-up to the clipboard fix above: restricting sync to the visible
|
||||
tab left a tab that had been in the background with a stale remote
|
||||
clipboard when switched to (with two RDP sessions only the one last
|
||||
copied in worked). A tab now pushes the current local clipboard to its
|
||||
remote whenever it becomes visible
|
||||
- Robustness fix: an unrecognized `FramebufferUpdate` rectangle encoding
|
||||
used to abort the connection generically; `kAnnouncedEncodings` is now
|
||||
the single source of truth for what `SetEncodings` announces and what
|
||||
@@ -419,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
|
||||
|
||||
+40
-2
@@ -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."));
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
+24
-2
@@ -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<ProfileRepository>())
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -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<ProfileRepository> m_repository;
|
||||
std::unordered_map<qint64, Profile> m_profileCache;
|
||||
QString m_pendingTagFilterPreference;
|
||||
|
||||
+37
-12
@@ -28,6 +28,7 @@
|
||||
#include <QComboBox>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QPushButton>
|
||||
#include <QShowEvent>
|
||||
#include <QScrollArea>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
@@ -386,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) {
|
||||
@@ -799,6 +810,26 @@ void SessionTab::onBackendRemoteClipboardTextChanged(const QString& text)
|
||||
}
|
||||
|
||||
void SessionTab::onSystemClipboardChanged()
|
||||
{
|
||||
const QClipboard* clipboard = QApplication::clipboard();
|
||||
const QString text = clipboard->mimeData()->hasText() ? clipboard->text() : QString();
|
||||
|
||||
// Suppress only the change we caused ourselves, and only briefly. The
|
||||
// old "same as last synced text" check had no expiry: once a send was
|
||||
// lost (channel not ready, remote clipboard changed to something we
|
||||
// never saw) copying that same text again did nothing, indefinitely.
|
||||
constexpr qint64 kEchoWindowMs = 1000;
|
||||
const bool isEcho = !m_pendingClipboardEcho.isEmpty() && text == m_pendingClipboardEcho
|
||||
&& QDateTime::currentMSecsSinceEpoch() - m_pendingClipboardEchoMs < kEchoWindowMs;
|
||||
m_pendingClipboardEcho.clear();
|
||||
if (isEcho) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushLocalClipboardToRemote();
|
||||
}
|
||||
|
||||
void SessionTab::pushLocalClipboardToRemote()
|
||||
{
|
||||
if (!m_clipboardSyncSupported || m_state != SessionState::Connected || !isVisible()) {
|
||||
return;
|
||||
@@ -814,22 +845,16 @@ void SessionTab::onSystemClipboardChanged()
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress only the change we caused ourselves, and only briefly. The
|
||||
// old "same as last synced text" check had no expiry: once a send was
|
||||
// lost (channel not ready, remote clipboard changed to something we
|
||||
// never saw) copying that same text again did nothing, indefinitely.
|
||||
constexpr qint64 kEchoWindowMs = 1000;
|
||||
const bool isEcho = !m_pendingClipboardEcho.isEmpty() && text == m_pendingClipboardEcho
|
||||
&& QDateTime::currentMSecsSinceEpoch() - m_pendingClipboardEchoMs < kEchoWindowMs;
|
||||
m_pendingClipboardEcho.clear();
|
||||
if (isEcho) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit requestSetClipboardText(text);
|
||||
appendEvent(QStringLiteral("Clipboard: sending %1 characters to remote.").arg(text.size()));
|
||||
}
|
||||
|
||||
void SessionTab::showEvent(QShowEvent* event)
|
||||
{
|
||||
QWidget::showEvent(event);
|
||||
pushLocalClipboardToRemote();
|
||||
}
|
||||
|
||||
void SessionTab::setupUi()
|
||||
{
|
||||
auto* rootLayout = new QVBoxLayout(this);
|
||||
|
||||
@@ -24,6 +24,7 @@ class QComboBox;
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QScrollArea;
|
||||
class QShowEvent;
|
||||
class KodoTerm;
|
||||
|
||||
struct SessionUiPreferences
|
||||
@@ -68,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);
|
||||
@@ -99,6 +108,11 @@ signals:
|
||||
void requestSetClipboardText(const QString& text);
|
||||
void requestProvideUsername(const QString& username);
|
||||
|
||||
protected:
|
||||
// A tab that wasn't in front while the local clipboard changed missed
|
||||
// that change (only the visible tab syncs), so catch it up on show.
|
||||
void showEvent(QShowEvent* event) override;
|
||||
|
||||
private slots:
|
||||
void onBackendStateChanged(SessionState state, const QString& message);
|
||||
void onBackendEventLogged(const QString& message);
|
||||
@@ -166,6 +180,7 @@ private:
|
||||
std::function<void(std::optional<QString>)> callback,
|
||||
bool maskInput = true);
|
||||
void hidePasswordPrompt();
|
||||
void pushLocalClipboardToRemote();
|
||||
bool validateProfileForConnect();
|
||||
void appendEvent(const QString& message);
|
||||
void setState(SessionState state, const QString& message);
|
||||
|
||||
@@ -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 <QApplication>
|
||||
@@ -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;
|
||||
|
||||
@@ -32,6 +32,7 @@ private:
|
||||
void loadUiPreferences();
|
||||
void saveUiPreferences() const;
|
||||
int sessionTabCount() const;
|
||||
void saveTabAsProfile(SessionTab* tab);
|
||||
};
|
||||
|
||||
#endif
|
||||
Reference in new issue
Block a user