9 Commits

Author SHA1 Message Date
Keith Smith
582c57bc5f Auto-fill standard port when protocol changes 2026-03-01 09:24:31 -07:00
Keith Smith
01762422e9 Document Milestone 2 scope and completion 2026-03-01 09:21:57 -07:00
Keith Smith
f8a81ebe36 Implement Milestone 2 profile schema, dialog, and connect lifecycle 2026-03-01 09:21:53 -07:00
Keith Smith
87b0f60569 Update progress for Milestone 1 completion 2026-03-01 09:06:36 -07:00
Keith Smith
09c54313af Implement SQLite-backed profile CRUD in Profiles window 2026-03-01 09:06:34 -07:00
Keith Smith
fba7336f69 Add SQLite profile repository and Qt SQL dependency 2026-03-01 09:06:31 -07:00
Keith Smith
6d147894c5 Add cross-platform development environment setup guide 2026-03-01 09:02:53 -07:00
Keith Smith
b43fb6fb1a Record Milestone 0 completion progress 2026-03-01 08:56:13 -07:00
Keith Smith
285a81d9b5 Scaffold Qt6 Widgets app for Milestone 0 2026-03-01 08:56:10 -07:00
14 changed files with 1122 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build/

31
CMakeLists.txt Normal file
View File

@@ -0,0 +1,31 @@
cmake_minimum_required(VERSION 3.21)
project(OrbitHub VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
qt_standard_project_setup()
add_executable(orbithub
src/main.cpp
src/profile_dialog.cpp
src/profile_dialog.h
src/profile_repository.cpp
src/profile_repository.h
src/profiles_window.cpp
src/profiles_window.h
src/session_window.cpp
src/session_window.h
)
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)
install(TARGETS orbithub RUNTIME DESTINATION bin)

56
docs/BUILDING.md Normal file
View File

@@ -0,0 +1,56 @@
# Building OrbitHub (C++ / Qt6 Widgets)
Run all commands from the repository root unless noted.
## Linux (Ubuntu / Mint)
```bash
sudo apt update
sudo apt install -y \
build-essential cmake ninja-build git pkg-config \
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools
cmake -S . -B build -G Ninja
cmake --build build
./build/orbithub
```
## macOS (Homebrew)
```bash
xcode-select --install
brew update
brew install cmake ninja pkg-config qt@6
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)"
cmake --build build
./build/orbithub
```
## Windows 11 (PowerShell + MSVC + vcpkg)
```powershell
winget install -e --id Git.Git
winget install -e --id Kitware.CMake
winget install -e --id Ninja-build.Ninja
winget install -e --id Microsoft.VisualStudio.2022.BuildTools `
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools"
```
Open a new terminal after installs, then:
```powershell
git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg
C:\dev\vcpkg\bootstrap-vcpkg.bat
C:\dev\vcpkg\vcpkg.exe install qtbase:x64-windows
cmake -S . -B build -G Ninja `
-DCMAKE_TOOLCHAIN_FILE=C:/dev/vcpkg/scripts/buildsystems/vcpkg.cmake
cmake --build build
.\build\orbithub.exe
```
## Notes
- OrbitHub currently requires Qt6 Widgets and CMake 3.21+.
- If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake.

View File

@@ -54,3 +54,13 @@ OrbitHub uses a two-window model:
- Profiles CRUD - Profiles CRUD
- Connect loads profile name into tab - Connect loads profile name into tab
- Tag: v0-m1-done - Tag: v0-m1-done
---
## Milestone 2
- Extend profiles schema (`host`, `port`, `username`, `protocol`, `auth_mode`)
- Replace prompt-based create/edit with a structured profile dialog form
- Connect loads full profile details into session tab
- Session lifecycle states in UI (`Connecting`, `Connected`, `Failed`) with non-blocking updates
- Tag: v0-m2-done

45
docs/PROGRESS.md Normal file
View File

@@ -0,0 +1,45 @@
# OrbitHub Progress
## Milestone 0 - Restart in C++/Qt Widgets
Status: Completed
Delivered:
- Fresh C++17/Qt6 Widgets scaffold with CMake
- `ProfilesWindow` (`QMainWindow`) with search, profile list, and New/Edit/Delete controls
- Double-click in Profiles opens a `SessionWindow`
- `SessionWindow` (`QMainWindow`) with `QTabWidget`
- Placeholder tab content showing `OrbitHub Native Surface`
- `main.cpp` wiring for application startup
- Cross-platform build command guide in `docs/BUILDING.md`
Git:
- Tag: `v0-m0-done`
## Milestone 1 - Storage and CRUD
Status: Completed
Delivered:
- SQLite integration via Qt SQL (`QSQLITE`)
- Persistent profile database bootstrap (`profiles` table)
- Profiles CRUD (New / Edit / Delete) in `ProfilesWindow`
- Search-backed profile listing from storage
- Double-click connect opens `SessionWindow` tab with selected profile name
Git:
- Tag: `v0-m1-done`
## Milestone 2 - Profile Details and Connect Lifecycle
Status: Completed
Delivered:
- SQLite schema migration for profile details (`host`, `port`, `username`, `protocol`, `auth_mode`)
- New `ProfileDialog` form for New/Edit profile workflows
- Profiles list now shows endpoint metadata and supports search by name or host
- Connect now loads complete profile details into `SessionWindow`
- Session tab lifecycle status updates (`Connecting`, `Connected`, `Failed`) via non-blocking timer flow
Git:
- Tag: `v0-m2-done`

13
src/main.cpp Normal file
View File

@@ -0,0 +1,13 @@
#include "profiles_window.h"
#include <QApplication>
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
ProfilesWindow window;
window.show();
return app.exec();
}

124
src/profile_dialog.cpp Normal file
View File

@@ -0,0 +1,124 @@
#include "profile_dialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSpinBox>
#include <QVBoxLayout>
namespace {
int standardPortForProtocol(const QString& protocol)
{
if (protocol == QStringLiteral("RDP")) {
return 3389;
}
if (protocol == QStringLiteral("VNC")) {
return 5900;
}
return 22; // SSH default
}
}
ProfileDialog::ProfileDialog(QWidget* parent)
: QDialog(parent),
m_nameInput(new QLineEdit(this)),
m_hostInput(new QLineEdit(this)),
m_portInput(new QSpinBox(this)),
m_usernameInput(new QLineEdit(this)),
m_protocolInput(new QComboBox(this)),
m_authModeInput(new QComboBox(this))
{
resize(420, 260);
auto* layout = new QVBoxLayout(this);
auto* form = new QFormLayout();
m_nameInput->setPlaceholderText(QStringLiteral("Production Bastion"));
m_hostInput->setPlaceholderText(QStringLiteral("example.internal"));
m_portInput->setRange(1, 65535);
m_portInput->setValue(22);
m_usernameInput->setPlaceholderText(QStringLiteral("deploy"));
m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")});
m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")});
connect(m_protocolInput,
&QComboBox::currentTextChanged,
this,
[this](const QString& protocol) {
m_portInput->setValue(standardPortForProtocol(protocol));
});
form->addRow(QStringLiteral("Name"), m_nameInput);
form->addRow(QStringLiteral("Host"), m_hostInput);
form->addRow(QStringLiteral("Port"), m_portInput);
form->addRow(QStringLiteral("Username"), m_usernameInput);
form->addRow(QStringLiteral("Protocol"), m_protocolInput);
form->addRow(QStringLiteral("Auth Mode"), m_authModeInput);
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(buttons);
}
void ProfileDialog::setDialogTitle(const QString& title)
{
setWindowTitle(title);
}
void ProfileDialog::setProfile(const Profile& profile)
{
m_nameInput->setText(profile.name);
m_hostInput->setText(profile.host);
m_portInput->setValue(profile.port > 0 ? profile.port : 22);
m_usernameInput->setText(profile.username);
const int protocolIndex = m_protocolInput->findText(profile.protocol);
{
// Keep stored custom port when loading an existing profile.
const QSignalBlocker blocker(m_protocolInput);
m_protocolInput->setCurrentIndex(protocolIndex >= 0 ? protocolIndex : 0);
}
const int authModeIndex = m_authModeInput->findText(profile.authMode);
m_authModeInput->setCurrentIndex(authModeIndex >= 0 ? authModeIndex : 0);
}
Profile ProfileDialog::profile() const
{
Profile profile;
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.protocol = m_protocolInput->currentText();
profile.authMode = m_authModeInput->currentText();
return profile;
}
void ProfileDialog::accept()
{
if (m_nameInput->text().trimmed().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Profile name is required."));
return;
}
if (m_hostInput->text().trimmed().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Host is required."));
return;
}
QDialog::accept();
}

35
src/profile_dialog.h Normal file
View File

@@ -0,0 +1,35 @@
#ifndef ORBITHUB_PROFILE_DIALOG_H
#define ORBITHUB_PROFILE_DIALOG_H
#include "profile_repository.h"
#include <QDialog>
class QComboBox;
class QLineEdit;
class QSpinBox;
class ProfileDialog : public QDialog
{
Q_OBJECT
public:
explicit ProfileDialog(QWidget* parent = nullptr);
void setDialogTitle(const QString& title);
void setProfile(const Profile& profile);
Profile profile() const;
protected:
void accept() override;
private:
QLineEdit* m_nameInput;
QLineEdit* m_hostInput;
QSpinBox* m_portInput;
QLineEdit* m_usernameInput;
QComboBox* m_protocolInput;
QComboBox* m_authModeInput;
};
#endif

310
src/profile_repository.cpp Normal file
View File

@@ -0,0 +1,310 @@
#include "profile_repository.h"
#include <QDir>
#include <QSet>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QStandardPaths>
#include <QVariant>
namespace {
QString buildDatabasePath()
{
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
if (appDataPath.isEmpty()) {
appDataPath = QDir::currentPath();
}
QDir dataDir(appDataPath);
dataDir.mkpath(QStringLiteral("."));
return dataDir.filePath(QStringLiteral("orbithub_profiles.sqlite"));
}
void bindProfileFields(QSqlQuery& query, const Profile& profile)
{
query.addBindValue(profile.name.trimmed());
query.addBindValue(profile.host.trimmed());
query.addBindValue(profile.port);
query.addBindValue(profile.username.trimmed());
query.addBindValue(profile.protocol.trimmed());
query.addBindValue(profile.authMode.trimmed());
}
Profile profileFromQuery(const QSqlQuery& query)
{
Profile profile;
profile.id = query.value(0).toLongLong();
profile.name = query.value(1).toString();
profile.host = query.value(2).toString();
profile.port = query.value(3).toInt();
profile.username = query.value(4).toString();
profile.protocol = query.value(5).toString();
profile.authMode = query.value(6).toString();
return profile;
}
bool isProfileValid(const Profile& profile)
{
return !profile.name.trimmed().isEmpty() && !profile.host.trimmed().isEmpty()
&& profile.port >= 1 && profile.port <= 65535;
}
}
ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbithub_main"))
{
if (!initializeDatabase()) {
QSqlDatabase::removeDatabase(m_connectionName);
}
}
ProfileRepository::~ProfileRepository()
{
if (QSqlDatabase::contains(m_connectionName)) {
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
if (db.isOpen()) {
db.close();
}
}
QSqlDatabase::removeDatabase(m_connectionName);
}
QString ProfileRepository::initError() const
{
return m_initError;
}
QString ProfileRepository::lastError() const
{
return m_lastError;
}
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const
{
std::vector<Profile> result;
if (!QSqlDatabase::contains(m_connectionName)) {
return result;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
if (searchQuery.trimmed().isEmpty()) {
query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode "
"FROM profiles "
"ORDER BY lower(name) ASC, id ASC"));
} else {
query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode "
"FROM profiles "
"WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) "
"ORDER BY lower(name) ASC, id ASC"));
const QString search = QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%");
query.addBindValue(search);
query.addBindValue(search);
}
if (!query.exec()) {
setLastError(query.lastError().text());
return result;
}
while (query.next()) {
result.push_back(profileFromQuery(query));
}
return result;
}
std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return std::nullopt;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode "
"FROM profiles WHERE id = ?"));
query.addBindValue(id);
if (!query.exec()) {
setLastError(query.lastError().text());
return std::nullopt;
}
if (!query.next()) {
return std::nullopt;
}
return profileFromQuery(query);
}
std::optional<Profile> ProfileRepository::createProfile(const Profile& profile) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return std::nullopt;
}
setLastError(QString());
if (!isProfileValid(profile)) {
setLastError(QStringLiteral("Name, host, and a valid port are required."));
return std::nullopt;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral(
"INSERT INTO profiles(name, host, port, username, protocol, auth_mode) "
"VALUES (?, ?, ?, ?, ?, ?)"));
bindProfileFields(query, profile);
if (!query.exec()) {
setLastError(query.lastError().text());
return std::nullopt;
}
Profile created = profile;
created.id = query.lastInsertId().toLongLong();
return created;
}
bool ProfileRepository::updateProfile(const Profile& profile) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
setLastError(QString());
if (profile.id < 0 || !isProfileValid(profile)) {
setLastError(QStringLiteral("Invalid profile data."));
return false;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral(
"UPDATE profiles "
"SET name = ?, host = ?, port = ?, username = ?, protocol = ?, auth_mode = ? "
"WHERE id = ?"));
bindProfileFields(query, profile);
query.addBindValue(profile.id);
if (!query.exec()) {
setLastError(query.lastError().text());
return false;
}
return query.numRowsAffected() > 0;
}
bool ProfileRepository::deleteProfile(qint64 id) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
setLastError(QString());
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("DELETE FROM profiles WHERE id = ?"));
query.addBindValue(id);
if (!query.exec()) {
setLastError(query.lastError().text());
return false;
}
return query.numRowsAffected() > 0;
}
bool ProfileRepository::initializeDatabase()
{
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
database.setDatabaseName(buildDatabasePath());
if (!database.open()) {
m_initError = database.lastError().text();
return false;
}
QSqlQuery query(database);
const bool created = query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS profiles ("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"name TEXT NOT NULL UNIQUE,"
"host TEXT NOT NULL DEFAULT '',"
"port INTEGER NOT NULL DEFAULT 22,"
"username TEXT NOT NULL DEFAULT '',"
"protocol TEXT NOT NULL DEFAULT 'SSH',"
"auth_mode TEXT NOT NULL DEFAULT 'Password'"
")"));
if (!created) {
m_initError = query.lastError().text();
return false;
}
if (!ensureProfileSchema()) {
m_initError = m_lastError;
return false;
}
return true;
}
bool ProfileRepository::ensureProfileSchema() const
{
if (!QSqlDatabase::contains(m_connectionName)) {
setLastError(QStringLiteral("Database connection missing."));
return false;
}
QSqlQuery tableInfo(QSqlDatabase::database(m_connectionName));
if (!tableInfo.exec(QStringLiteral("PRAGMA table_info(profiles)"))) {
setLastError(tableInfo.lastError().text());
return false;
}
QSet<QString> columns;
while (tableInfo.next()) {
columns.insert(tableInfo.value(1).toString());
}
struct ColumnDef {
QString name;
QString ddl;
};
const std::vector<ColumnDef> required = {
{QStringLiteral("host"), QStringLiteral("ALTER TABLE profiles ADD COLUMN host TEXT NOT NULL DEFAULT ''")},
{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("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'")}};
for (const ColumnDef& column : required) {
if (columns.contains(column.name)) {
continue;
}
QSqlQuery alter(QSqlDatabase::database(m_connectionName));
if (!alter.exec(column.ddl)) {
setLastError(alter.lastError().text());
return false;
}
}
setLastError(QString());
return true;
}
void ProfileRepository::setLastError(const QString& error) const
{
m_lastError = error;
}

46
src/profile_repository.h Normal file
View File

@@ -0,0 +1,46 @@
#ifndef ORBITHUB_PROFILE_REPOSITORY_H
#define ORBITHUB_PROFILE_REPOSITORY_H
#include <QString>
#include <QtGlobal>
#include <optional>
#include <vector>
struct Profile
{
qint64 id = -1;
QString name;
QString host;
int port = 22;
QString username;
QString protocol = QStringLiteral("SSH");
QString authMode = QStringLiteral("Password");
};
class ProfileRepository
{
public:
ProfileRepository();
~ProfileRepository();
QString initError() const;
QString lastError() const;
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const;
std::optional<Profile> getProfile(qint64 id) const;
std::optional<Profile> createProfile(const Profile& profile) const;
bool updateProfile(const Profile& profile) const;
bool deleteProfile(qint64 id) const;
private:
QString m_connectionName;
QString m_initError;
mutable QString m_lastError;
bool initializeDatabase();
bool ensureProfileSchema() const;
void setLastError(const QString& error) const;
};
#endif

284
src/profiles_window.cpp Normal file
View File

@@ -0,0 +1,284 @@
#include "profiles_window.h"
#include "profile_dialog.h"
#include "profile_repository.h"
#include "session_window.h"
#include <algorithm>
#include <QAbstractItemView>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QMessageBox>
#include <QPushButton>
#include <QVariant>
#include <QVBoxLayout>
#include <QWidget>
namespace {
QString formatProfileListItem(const Profile& profile)
{
return QStringLiteral("%1 [%2 %3:%4]")
.arg(profile.name, profile.protocol, profile.host, QString::number(profile.port));
}
}
ProfilesWindow::ProfilesWindow(QWidget* parent)
: QMainWindow(parent),
m_searchBox(nullptr),
m_profilesList(nullptr),
m_newButton(nullptr),
m_editButton(nullptr),
m_deleteButton(nullptr),
m_repository(std::make_unique<ProfileRepository>())
{
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
resize(640, 620);
setupUi();
if (!m_repository->initError().isEmpty()) {
QMessageBox::critical(this,
QStringLiteral("Database Error"),
QStringLiteral("Failed to initialize SQLite database: %1")
.arg(m_repository->initError()));
m_newButton->setEnabled(false);
m_editButton->setEnabled(false);
m_deleteButton->setEnabled(false);
m_searchBox->setEnabled(false);
m_profilesList->setEnabled(false);
return;
}
loadProfiles();
}
ProfilesWindow::~ProfilesWindow() = default;
void ProfilesWindow::setupUi()
{
auto* central = new QWidget(this);
auto* rootLayout = new QVBoxLayout(central);
auto* searchLabel = new QLabel(QStringLiteral("Search"), central);
m_searchBox = new QLineEdit(central);
m_searchBox->setPlaceholderText(QStringLiteral("Filter by name or host..."));
m_profilesList = new QListWidget(central);
m_profilesList->setSelectionMode(QAbstractItemView::SingleSelection);
auto* buttonRow = new QHBoxLayout();
m_newButton = new QPushButton(QStringLiteral("New"), central);
m_editButton = new QPushButton(QStringLiteral("Edit"), central);
m_deleteButton = new QPushButton(QStringLiteral("Delete"), central);
buttonRow->addWidget(m_newButton);
buttonRow->addWidget(m_editButton);
buttonRow->addWidget(m_deleteButton);
buttonRow->addStretch();
rootLayout->addWidget(searchLabel);
rootLayout->addWidget(m_searchBox);
rootLayout->addWidget(m_profilesList, 1);
rootLayout->addLayout(buttonRow);
setCentralWidget(central);
connect(m_searchBox,
&QLineEdit::textChanged,
this,
[this](const QString& text) { loadProfiles(text); });
connect(m_profilesList,
&QListWidget::itemDoubleClicked,
this,
[this](QListWidgetItem* item) { openSessionForItem(item); });
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)
{
m_profilesList->clear();
m_profileCache.clear();
const std::vector<Profile> profiles = m_repository->listProfiles(query);
if (!m_repository->lastError().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Load Profiles"),
QStringLiteral("Failed to load profiles: %1")
.arg(m_repository->lastError()));
return;
}
for (const Profile& profile : profiles) {
auto* item = new QListWidgetItem(formatProfileListItem(profile), m_profilesList);
item->setData(Qt::UserRole, QVariant::fromValue(profile.id));
item->setToolTip(QStringLiteral("%1://%2@%3:%4\nAuth: %5")
.arg(profile.protocol,
profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username,
profile.host,
QString::number(profile.port),
profile.authMode));
m_profileCache.insert_or_assign(profile.id, profile);
}
}
std::optional<Profile> ProfilesWindow::selectedProfile() const
{
QListWidgetItem* item = m_profilesList->currentItem();
if (item == nullptr) {
return std::nullopt;
}
const QVariant value = item->data(Qt::UserRole);
if (!value.isValid()) {
return std::nullopt;
}
const qint64 id = value.toLongLong();
const auto cacheIt = m_profileCache.find(id);
if (cacheIt != m_profileCache.end()) {
return cacheIt->second;
}
return m_repository->getProfile(id);
}
void ProfilesWindow::createProfile()
{
ProfileDialog dialog(this);
dialog.setDialogTitle(QStringLiteral("New Profile"));
if (dialog.exec() != QDialog::Accepted) {
return;
}
if (!m_repository->createProfile(dialog.profile()).has_value()) {
QMessageBox::warning(this,
QStringLiteral("Create Profile"),
QStringLiteral("Failed to create profile: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::editSelectedProfile()
{
const std::optional<Profile> selected = selectedProfile();
if (!selected.has_value()) {
QMessageBox::information(this,
QStringLiteral("Edit Profile"),
QStringLiteral("Select a profile first."));
return;
}
ProfileDialog dialog(this);
dialog.setDialogTitle(QStringLiteral("Edit Profile"));
dialog.setProfile(selected.value());
if (dialog.exec() != QDialog::Accepted) {
return;
}
Profile updated = dialog.profile();
updated.id = selected->id;
if (!m_repository->updateProfile(updated)) {
QMessageBox::warning(this,
QStringLiteral("Edit Profile"),
QStringLiteral("Failed to update profile: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::deleteSelectedProfile()
{
const std::optional<Profile> selected = selectedProfile();
if (!selected.has_value()) {
QMessageBox::information(this,
QStringLiteral("Delete Profile"),
QStringLiteral("Select a profile first."));
return;
}
const QMessageBox::StandardButton confirm = QMessageBox::question(
this,
QStringLiteral("Delete Profile"),
QStringLiteral("Delete profile '%1'?").arg(selected->name),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No);
if (confirm != QMessageBox::Yes) {
return;
}
if (!m_repository->deleteProfile(selected->id)) {
QMessageBox::warning(this,
QStringLiteral("Delete Profile"),
QStringLiteral("Failed to delete profile: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("unknown error")
: m_repository->lastError()));
return;
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
{
if (item == nullptr) {
return;
}
const QVariant value = item->data(Qt::UserRole);
if (!value.isValid()) {
return;
}
const qint64 id = value.toLongLong();
const std::optional<Profile> profile = m_repository->getProfile(id);
if (!profile.has_value()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Failed to load profile for session: %1")
.arg(m_repository->lastError().isEmpty()
? QStringLiteral("profile not found")
: m_repository->lastError()));
return;
}
auto* session = new SessionWindow(profile.value());
session->setAttribute(Qt::WA_DeleteOnClose);
m_sessionWindows.emplace_back(session);
connect(session,
&QObject::destroyed,
this,
[this](QObject* object) {
m_sessionWindows.erase(
std::remove_if(m_sessionWindows.begin(),
m_sessionWindows.end(),
[object](const QPointer<SessionWindow>& candidate) {
return candidate.isNull() || candidate.data() == object;
}),
m_sessionWindows.end());
});
session->show();
}

49
src/profiles_window.h Normal file
View File

@@ -0,0 +1,49 @@
#ifndef ORBITHUB_PROFILES_WINDOW_H
#define ORBITHUB_PROFILES_WINDOW_H
#include "profile_repository.h"
#include <QMainWindow>
#include <QString>
#include <QtGlobal>
#include <memory>
#include <optional>
#include <QPointer>
#include <unordered_map>
#include <vector>
class QListWidget;
class QListWidgetItem;
class QLineEdit;
class QPushButton;
class SessionWindow;
class ProfilesWindow : public QMainWindow
{
Q_OBJECT
public:
explicit ProfilesWindow(QWidget* parent = nullptr);
~ProfilesWindow() override;
private:
QLineEdit* m_searchBox;
QListWidget* m_profilesList;
QPushButton* m_newButton;
QPushButton* m_editButton;
QPushButton* m_deleteButton;
std::vector<QPointer<SessionWindow>> m_sessionWindows;
std::unique_ptr<ProfileRepository> m_repository;
std::unordered_map<qint64, Profile> m_profileCache;
void setupUi();
void loadProfiles(const QString& query = QString());
std::optional<Profile> selectedProfile() const;
void createProfile();
void editSelectedProfile();
void deleteSelectedProfile();
void openSessionForItem(QListWidgetItem* item);
};
#endif

95
src/session_window.cpp Normal file
View File

@@ -0,0 +1,95 @@
#include "session_window.h"
#include <QFont>
#include <QLabel>
#include <QTabWidget>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
: QMainWindow(parent), m_tabs(new QTabWidget(this))
{
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
resize(980, 680);
m_tabs->setTabsClosable(true);
connect(m_tabs,
&QTabWidget::tabCloseRequested,
this,
[this](int index) {
QWidget* tab = m_tabs->widget(index);
m_tabs->removeTab(index);
delete tab;
if (m_tabs->count() == 0) {
close();
}
});
setCentralWidget(m_tabs);
addSessionTab(profile);
}
void SessionWindow::addSessionTab(const Profile& profile)
{
auto* container = new QWidget(this);
auto* layout = new QVBoxLayout(container);
auto* profileLabel = new QLabel(QStringLiteral("Profile: %1").arg(profile.name), container);
auto* endpointLabel = new QLabel(
QStringLiteral("Endpoint: %1://%2@%3:%4")
.arg(profile.protocol,
profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username,
profile.host,
QString::number(profile.port)),
container);
auto* authModeLabel = new QLabel(QStringLiteral("Auth Mode: %1").arg(profile.authMode), container);
auto* statusLabel = new QLabel(QStringLiteral("Connection State: Connecting"), container);
auto* surfaceLabel = new QLabel(QStringLiteral("OrbitHub Native Surface"), container);
QFont profileFont = profileLabel->font();
profileFont.setBold(true);
profileLabel->setFont(profileFont);
QFont surfaceFont = surfaceLabel->font();
surfaceFont.setPointSize(surfaceFont.pointSize() + 6);
surfaceFont.setBold(true);
surfaceLabel->setFont(surfaceFont);
statusLabel->setStyleSheet(
QStringLiteral("border: 1px solid #a5a5a5; background-color: #fff3cd; padding: 6px;"));
surfaceLabel->setAlignment(Qt::AlignCenter);
surfaceLabel->setMinimumHeight(220);
surfaceLabel->setStyleSheet(
QStringLiteral("border: 1px solid #8a8a8a; background-color: #f5f5f5;"));
layout->addWidget(profileLabel);
layout->addWidget(endpointLabel);
layout->addWidget(authModeLabel);
layout->addWidget(statusLabel);
layout->addWidget(surfaceLabel, 1);
const int tabIndex = m_tabs->addTab(container, QStringLiteral("%1 (Connecting)").arg(profile.name));
QTimer::singleShot(900,
this,
[this, tabIndex, statusLabel, profile]() {
const bool shouldFail = profile.host.contains(QStringLiteral("fail"),
Qt::CaseInsensitive);
if (shouldFail) {
statusLabel->setText(QStringLiteral("Connection State: Failed"));
statusLabel->setStyleSheet(QStringLiteral(
"border: 1px solid #a94442; background-color: #f2dede; padding: 6px;"));
m_tabs->setTabText(tabIndex,
QStringLiteral("%1 (Failed)").arg(profile.name));
return;
}
statusLabel->setText(QStringLiteral("Connection State: Connected"));
statusLabel->setStyleSheet(QStringLiteral(
"border: 1px solid #3c763d; background-color: #dff0d8; padding: 6px;"));
m_tabs->setTabText(tabIndex,
QStringLiteral("%1 (Connected)").arg(profile.name));
});
}

23
src/session_window.h Normal file
View File

@@ -0,0 +1,23 @@
#ifndef ORBITHUB_SESSION_WINDOW_H
#define ORBITHUB_SESSION_WINDOW_H
#include "profile_repository.h"
#include <QMainWindow>
class QTabWidget;
class SessionWindow : public QMainWindow
{
Q_OBJECT
public:
explicit SessionWindow(const Profile& profile, QWidget* parent = nullptr);
private:
QTabWidget* m_tabs;
void addSessionTab(const Profile& profile);
};
#endif