4 Commits

Author SHA1 Message Date
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
8 changed files with 422 additions and 39 deletions

1
.gitignore vendored Normal file
View File

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

View File

@@ -10,18 +10,20 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets)
find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
qt_standard_project_setup()
add_executable(orbithub
src/main.cpp
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)
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

@@ -11,7 +11,23 @@ Delivered:
- `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:
- Branch: `milestone-0-restart-cpp`
- 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:
- Branch: `milestone-1-storage`
- Tag: `v0-m1-done`

163
src/profile_repository.cpp Normal file
View File

@@ -0,0 +1,163 @@
#include "profile_repository.h"
#include <QDir>
#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"));
}
}
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;
}
std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery) const
{
std::vector<Profile> result;
if (!QSqlDatabase::contains(m_connectionName)) {
return result;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
if (searchQuery.trimmed().isEmpty()) {
query.prepare(QStringLiteral(
"SELECT id, name FROM profiles ORDER BY lower(name) ASC, id ASC"));
} else {
query.prepare(QStringLiteral(
"SELECT id, name FROM profiles "
"WHERE lower(name) LIKE lower(?) "
"ORDER BY lower(name) ASC, id ASC"));
query.addBindValue(QStringLiteral("%") + searchQuery.trimmed() + QStringLiteral("%"));
}
if (!query.exec()) {
return result;
}
while (query.next()) {
result.push_back(Profile{query.value(0).toLongLong(), query.value(1).toString()});
}
return result;
}
std::optional<Profile> ProfileRepository::createProfile(const QString& name) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return std::nullopt;
}
const QString trimmedName = name.trimmed();
if (trimmedName.isEmpty()) {
return std::nullopt;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("INSERT INTO profiles(name) VALUES (?)"));
query.addBindValue(trimmedName);
if (!query.exec()) {
return std::nullopt;
}
return Profile{query.lastInsertId().toLongLong(), trimmedName};
}
bool ProfileRepository::updateProfile(qint64 id, const QString& name) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
const QString trimmedName = name.trimmed();
if (trimmedName.isEmpty()) {
return false;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("UPDATE profiles SET name = ? WHERE id = ?"));
query.addBindValue(trimmedName);
query.addBindValue(id);
if (!query.exec()) {
return false;
}
return query.numRowsAffected() > 0;
}
bool ProfileRepository::deleteProfile(qint64 id) const
{
if (!QSqlDatabase::contains(m_connectionName)) {
return false;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral("DELETE FROM profiles WHERE id = ?"));
query.addBindValue(id);
if (!query.exec()) {
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"
")"));
if (!created) {
m_initError = query.lastError().text();
}
return created;
}

35
src/profile_repository.h Normal file
View File

@@ -0,0 +1,35 @@
#ifndef ORBITHUB_PROFILE_REPOSITORY_H
#define ORBITHUB_PROFILE_REPOSITORY_H
#include <QString>
#include <optional>
#include <vector>
struct Profile
{
qint64 id;
QString name;
};
class ProfileRepository
{
public:
ProfileRepository();
~ProfileRepository();
QString initError() const;
std::vector<Profile> listProfiles(const QString& searchQuery = QString()) const;
std::optional<Profile> createProfile(const QString& name) const;
bool updateProfile(qint64 id, const QString& name) const;
bool deleteProfile(qint64 id) const;
private:
QString m_connectionName;
QString m_initError;
bool initializeDatabase();
};
#endif

View File

@@ -1,18 +1,20 @@
#include "profiles_window.h"
#include "profile_repository.h"
#include "session_window.h"
#include <algorithm>
#include <QAbstractItemView>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QMessageBox>
#include <QPushButton>
#include <QStringList>
#include <QVariant>
#include <QVBoxLayout>
#include <QWidget>
@@ -22,15 +24,32 @@ ProfilesWindow::ProfilesWindow(QWidget* parent)
m_profilesList(nullptr),
m_newButton(nullptr),
m_editButton(nullptr),
m_deleteButton(nullptr)
m_deleteButton(nullptr),
m_repository(std::make_unique<ProfileRepository>())
{
setWindowTitle(QStringLiteral("OrbitHub Profiles"));
resize(520, 620);
setupUi();
populateSampleProfiles();
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);
@@ -63,51 +82,133 @@ void ProfilesWindow::setupUi()
connect(m_searchBox,
&QLineEdit::textChanged,
this,
[this](const QString& text) { filterProfiles(text); });
[this](const QString& text) { loadProfiles(text); });
connect(m_profilesList,
&QListWidget::itemDoubleClicked,
this,
[this](QListWidgetItem* item) { openSessionForItem(item); });
// Milestone 0 keeps profile management as placeholders.
auto showTodo = [this](const QString& action) {
QMessageBox::information(this,
QStringLiteral("Milestone 0"),
QStringLiteral("%1 is planned for Milestone 1.").arg(action));
};
connect(m_newButton, &QPushButton::clicked, this, [showTodo]() { showTodo(QStringLiteral("New Profile")); });
connect(m_editButton, &QPushButton::clicked, this, [showTodo]() { showTodo(QStringLiteral("Edit Profile")); });
connect(m_deleteButton,
&QPushButton::clicked,
this,
[showTodo]() { showTodo(QStringLiteral("Delete Profile")); });
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::populateSampleProfiles()
void ProfilesWindow::loadProfiles(const QString& query)
{
const QStringList sampleProfiles = {
QStringLiteral("Production Bastion"),
QStringLiteral("Staging API Host"),
QStringLiteral("Local Lab VM"),
QStringLiteral("CI Build Agent")};
m_profilesList->clear();
m_profilesList->addItems(sampleProfiles);
}
void ProfilesWindow::filterProfiles(const QString& query)
{
const QString trimmed = query.trimmed();
for (int i = 0; i < m_profilesList->count(); ++i) {
QListWidgetItem* item = m_profilesList->item(i);
const bool matches = trimmed.isEmpty()
|| item->text().contains(trimmed, Qt::CaseInsensitive);
item->setHidden(!matches);
const std::vector<Profile> profiles = m_repository->listProfiles(query);
for (const Profile& profile : profiles) {
auto* item = new QListWidgetItem(profile.name, m_profilesList);
item->setData(Qt::UserRole, QVariant::fromValue(profile.id));
}
}
std::optional<qint64> ProfilesWindow::selectedProfileId() 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;
}
return value.toLongLong();
}
void ProfilesWindow::createProfile()
{
bool accepted = false;
const QString name = QInputDialog::getText(this,
QStringLiteral("New Profile"),
QStringLiteral("Profile name:"),
QLineEdit::Normal,
QString(),
&accepted);
if (!accepted || name.trimmed().isEmpty()) {
return;
}
if (!m_repository->createProfile(name).has_value()) {
QMessageBox::warning(this,
QStringLiteral("Create Profile"),
QStringLiteral("Failed to create profile. Names must be unique."));
return;
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::editSelectedProfile()
{
const std::optional<qint64> profileId = selectedProfileId();
QListWidgetItem* currentItem = m_profilesList->currentItem();
if (!profileId.has_value() || currentItem == nullptr) {
QMessageBox::information(this,
QStringLiteral("Edit Profile"),
QStringLiteral("Select a profile first."));
return;
}
bool accepted = false;
const QString name = QInputDialog::getText(this,
QStringLiteral("Edit Profile"),
QStringLiteral("Profile name:"),
QLineEdit::Normal,
currentItem->text(),
&accepted);
if (!accepted || name.trimmed().isEmpty()) {
return;
}
if (!m_repository->updateProfile(profileId.value(), name)) {
QMessageBox::warning(this,
QStringLiteral("Edit Profile"),
QStringLiteral("Failed to update profile. Names must be unique."));
return;
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::deleteSelectedProfile()
{
const std::optional<qint64> profileId = selectedProfileId();
QListWidgetItem* currentItem = m_profilesList->currentItem();
if (!profileId.has_value() || currentItem == nullptr) {
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(currentItem->text()),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No);
if (confirm != QMessageBox::Yes) {
return;
}
if (!m_repository->deleteProfile(profileId.value())) {
QMessageBox::warning(this,
QStringLiteral("Delete Profile"),
QStringLiteral("Failed to delete profile."));
return;
}
loadProfiles(m_searchBox->text());
}
void ProfilesWindow::openSessionForItem(QListWidgetItem* item)
{
if (item == nullptr) {

View File

@@ -3,7 +3,10 @@
#include <QMainWindow>
#include <QString>
#include <QtGlobal>
#include <memory>
#include <optional>
#include <QPointer>
#include <vector>
@@ -12,6 +15,7 @@ class QListWidgetItem;
class QLineEdit;
class QPushButton;
class SessionWindow;
class ProfileRepository;
class ProfilesWindow : public QMainWindow
{
@@ -19,6 +23,7 @@ class ProfilesWindow : public QMainWindow
public:
explicit ProfilesWindow(QWidget* parent = nullptr);
~ProfilesWindow() override;
private:
QLineEdit* m_searchBox;
@@ -27,10 +32,14 @@ private:
QPushButton* m_editButton;
QPushButton* m_deleteButton;
std::vector<QPointer<SessionWindow>> m_sessionWindows;
std::unique_ptr<ProfileRepository> m_repository;
void setupUi();
void populateSampleProfiles();
void filterProfiles(const QString& query);
void loadProfiles(const QString& query = QString());
std::optional<qint64> selectedProfileId() const;
void createProfile();
void editSelectedProfile();
void deleteSelectedProfile();
void openSessionForItem(QListWidgetItem* item);
};