Фаза 1: мастер первого запуска — SettingsStore, GitManager (auto-init+коммиты), SyncManager (проверка соединений+ErrorCatalog), WelcomeWizard/ProviderSetupGuide; тесты smoke/git/vault (12/12)

This commit is contained in:
Flora (opencode)
2026-08-17 00:43:33 +03:00
parent 980fbcedcb
commit ed633ae036
18 changed files with 1412 additions and 29 deletions
+18 -1
View File
@@ -12,6 +12,10 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
# --- Тесты (объявляем до загрузки зависимостей: их CMake могут трогать BUILD_TESTING) ---
set(BUILD_TESTING ON CACHE BOOL "Строить тесты" FORCE)
include(CTest)
# Десктоп: системный OpenSSL (для libssh2/libgit2 HTTPS/SSH). # Десктоп: системный OpenSSL (для libssh2/libgit2 HTTPS/SSH).
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
message(STATUS "OpenSSL: ${OPENSSL_VERSION}") message(STATUS "OpenSSL: ${OPENSSL_VERSION}")
@@ -32,10 +36,16 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/third_party/deps.cmake)
set(FLORA_HEADERS set(FLORA_HEADERS
src/app/VaultManager.h src/app/VaultManager.h
src/core/SettingsStore.h
src/git/GitManager.h
src/sync/SyncManager.h
) )
set(FLORA_SOURCES set(FLORA_SOURCES
src/app/VaultManager.cpp src/app/VaultManager.cpp
src/core/SettingsStore.cpp
src/git/GitManager.cpp
src/sync/SyncManager.cpp
) )
# --- Ядро (C++ бэкенд) --- # --- Ядро (C++ бэкенд) ---
@@ -84,4 +94,11 @@ qt_add_qml_module(flora
qml/panels/FileTreePanel.qml qml/panels/FileTreePanel.qml
qml/panels/EditorPanel.qml qml/panels/EditorPanel.qml
qml/panels/RightPanel.qml qml/panels/RightPanel.qml
) qml/wizard/WelcomeWizard.qml
qml/dialogs/ProviderSetupGuide.qml
)
# --- Тесты ---
if(BUILD_TESTING)
add_subdirectory(tests)
endif()
+63 -6
View File
@@ -3,7 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
// Главное окно: меню вверху, три панели (дерево / редактор / правая). // Главное окно: меню вверху, три панели (дерево / редактор / правая).
// Фаза 0: каркас. Содержимое панелей наполняется в следующих фазах. // Фазы 0-1: каркас + мастер первого запуска. Контент панелей — в следующих фазах.
ApplicationWindow { ApplicationWindow {
id: window id: window
width: 1280 width: 1280
@@ -12,10 +12,27 @@ ApplicationWindow {
title: "Flora" title: "Flora"
color: "white" color: "white"
// Мастер первого запуска: показываем, если ещё нет ни одного хранилища.
WelcomeWizard {
id: welcomeWizard
firstRun: vaultManager.vaults.length === 0
onFinished: vaultSwitcher.close()
}
Component.onCompleted: {
if (vaultManager.vaults.length === 0)
welcomeWizard.open()
}
function openWizard() {
welcomeWizard.firstRun = false
welcomeWizard.open()
}
menuBar: MenuBar { menuBar: MenuBar {
Menu { Menu {
title: qsTr("&Файл") title: qsTr("&Файл")
Action { text: qsTr("Открыть хранилище…"); onTriggered: vaultSwitcher.open() } Action { text: qsTr("Добавить хранилище…"); onTriggered: window.openWizard() }
Menu { Menu {
title: qsTr("Экспорт") title: qsTr("Экспорт")
Action { text: qsTr("В HTML…"); enabled: false } Action { text: qsTr("В HTML…"); enabled: false }
@@ -60,20 +77,60 @@ ApplicationWindow {
} }
} }
// Переключатель хранилищ (кнопка внизу слева, в FileTreePanel)
Popup { Popup {
id: vaultSwitcher id: vaultSwitcher
x: 12 x: 12
y: window.height - height - 12 y: window.height - height - 12
width: 280 width: 320
modal: false modal: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
ColumnLayout { ColumnLayout {
width: parent.width width: parent.width
Label { text: qsTr("Хранилища (Фаза 2)") } spacing: 6
Button { text: qsTr("Это окно: %1").arg(vaultManager.currentVault) }
Label {
text: qsTr("Хранилища")
font.bold: true
}
ListView {
id: vaultList
Layout.preferredHeight: Math.min(200, contentHeight)
Layout.fillWidth: true
model: vaultManager.vaults
clip: true
delegate: ItemDelegate {
width: ListView.view.width
text: modelData
highlighted: modelData === vaultManager.currentVault
onClicked: {
if (modelData !== vaultManager.currentVault) {
vaultManager.selectVault(modelData)
}
}
}
}
Label {
visible: vaultList.count === 0
text: qsTr("Хранилищ пока нет")
color: "#888"
}
Label {
text: qsTr("Текущее: %1").arg(vaultManager.currentVault.length > 0
? vaultManager.currentVault : "—")
color: "#666"
elide: Text.ElideMiddle
Layout.fillWidth: true
}
Button { Button {
Layout.fillWidth: true
text: qsTr("Добавить новое хранилище…") text: qsTr("Добавить новое хранилище…")
onClicked: { vaultSwitcher.close(); vaultManager.setCurrentVault("/") } onClicked: { vaultSwitcher.close(); window.openWizard() }
} }
} }
} }
+184
View File
@@ -0,0 +1,184 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Диалог настройки провайдера синхронизации с пошаговой инструкцией,
// проверкой соединения и каталогом ошибок (ErrorCatalog в SyncManager).
Dialog {
id: root
property int providerIndex: -1
property string providerName: ""
property bool connected: false
signal saved(int index)
readonly property bool isGit: providerIndex <= 2
title: qsTr("Подключение: %1").arg(providerName)
modal: true
width: 640
height: 520
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
function defaultUrl() {
switch (root.providerIndex) {
case 0: return "https://github.com"
case 1: return "https://gitlab.com"
case 2: return "http://localhost:3000"
case 3: return "https://nextcloud.local/remote.php/dav/files/USER"
default: return "http://127.0.0.1:8384"
}
}
function tokenName() {
switch (root.providerIndex) {
case 0: return qsTr("Токен GitHub (Personal Access Token)")
case 1: return qsTr("Токен GitLab (PRIVATE-TOKEN)")
case 2: return qsTr("Токен Gitea (Personal Access Token)")
case 3: return qsTr("Пароль от Nextcloud")
default: return qsTr("API-ключ Syncthing")
}
}
Component.onCompleted: { urlField.text = defaultUrl() }
ColumnLayout {
anchors.fill: parent
spacing: 10
// Инструкция
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 150
color: "#f4f6fa"
radius: 6
Flickable {
anchors.fill: parent
anchors.margins: 8
ScrollBar.vertical: ScrollBar {}
clip: true
contentWidth: width
contentHeight: instText.implicitHeight
Text {
id: instText
width: parent.width
wrapMode: Text.Wrap
textFormat: Text.MarkdownText
color: "#333"
text: root.providerIndex === 0
? "**Как подключить GitHub:**\n\n"
+ "1. Создайте токен: [github.com/settings/tokens](https://github.com/settings/tokens) — scope **repo** (классический) или Fine-grained с правом **Contents: Read and write**.\n"
+ "2. Создайте **пустой** репозиторий (без README/.gitignore — иначе конфликт истории): New repository.\n"
+ "3. Вставьте токен ниже и нажмите «Проверить».\n"
+ "4. Альтернатива — SSH-ключ (Подключение → SSH, доступно в Фазе 11)."
: root.providerIndex === 1
? "**Как подключить GitLab:**\n\n"
+ "1. Токен: Настройки → Access Tokens — scope **api** (или **read_repository** + **write_repository**).\n"
+ "2. Создайте **пустой** репозиторий в своём аккаунте/группе.\n"
+ "3. Для self-hosted: укажите адрес инстанса, например http://gitlab.local. Вставьте токен и нажмите «Проверить».\n"
+ "4. Альтернатива — SSH-ключ (доступно в Фазе 11)."
: root.providerIndex === 2
? "**Как подключить Gitea:**\n\n"
+ "1. Токен: Настройки аккаунта → Приложения → **Создать новый токен** (scope **repo**).\n"
+ "2. Создайте **пустой** репозиторий на своём инстансе.\n"
+ "3. Укажите адрес инстанса (например http://localhost:3000), вставьте токен и нажмите «Проверить».\n"
+ "4. Альтернатива — SSH-ключ (доступно в Фазе 11)."
: root.providerIndex === 3
? "**Как подключить Nextcloud (WebDAV):**\n\n"
+ "1. Откройте приложение Files, создайте папку для синхронизации (например **Flora**).\n"
+ "2. Адрес WebDAV: https://ВАШ_ХОСТ/remote.php/dav/files/ВАШ_ЛОГИН/\n"
+ "3. Укажите логин и пароль аккаунта Nextcloud и нажмите «Проверить».\n"
+ "4. Рекомендуется создать пароль приложения в Настройках → Безопасность."
: "**Как подключить Syncthing:**\n\n"
+ "1. Установите и запустите Syncthing на этом устройстве.\n"
+ "2. API-ключ: Действия → Настройки → Показывать расширенное → API-ключ.\n"
+ "3. Адрес API по умолчанию: http://127.0.0.1:8384. Вставьте ключ и нажмите «Проверить».\n"
+ "4. Затем добавьте папку хранилища в Syncthing как обычную папку."
}
}
}
// Поля
ColumnLayout {
Layout.fillWidth: true
spacing: 6
Label { text: qsTr("Адрес сервера") }
TextField { id: urlField; Layout.fillWidth: true }
Label {
visible: root.providerIndex === 3
text: qsTr("Логин (для WebDAV):")
}
TextField {
id: userField
Layout.fillWidth: true
visible: root.providerIndex === 3
placeholderText: qsTr("Логин Nextcloud")
}
Label { text: tokenName() }
TextField {
id: tokenField
Layout.fillWidth: true
echoMode: TextInput.Password
placeholderText: qsTr("Вставьте значение")
}
}
// Результат / ошибка
Label {
id: resultLabel
Layout.fillWidth: true
wrapMode: Text.Wrap
visible: text.length > 0
color: root.connected ? "green" : "#b00020"
}
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
Button {
text: qsTr("Отмена")
onClicked: root.reject()
}
Item { Layout.fillWidth: true }
Button {
text: qsTr("Проверить соединение")
enabled: tokenField.text.length > 0 && !syncManager.busy
onClicked: {
resultLabel.text = ""
root.connected = false
syncManager.testConnection(root.providerIndex,
urlField.text, userField.text,
tokenField.text)
}
}
Button {
text: qsTr("Сохранить")
enabled: root.connected
onClicked: {
settingsStore.addProvider(root.providerName, {
url: urlField.text,
user: userField.text,
token: tokenField.text,
})
root.saved(root.providerIndex)
root.accept()
}
}
}
}
Connections {
target: syncManager
function onConnectionResult(provider, ok, code, title, hint, raw) {
if (provider !== root.providerIndex)
return
root.connected = ok
resultLabel.text = ok
? qsTr("Соединение установлено. Нажмите «Сохранить».")
: title + (hint && hint.length > 0 ? "\n" + hint : "")
}
}
}
+164
View File
@@ -0,0 +1,164 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Dialogs
// Мастер первого запуска / добавления хранилища:
// 1) выбор папки (новая или с файлами);
// 2) автосоздание git-репозитория + подключение провайдеров (опционально);
// 3) готово.
Dialog {
id: root
required property bool firstRun
readonly property int stepFolder: 0
readonly property int stepSetup: 1
property int step: stepFolder
property string vaultPath: ""
property string setupStatus: ""
signal finished()
title: firstRun ? qsTr("Первое знакомство: создайте хранилище")
: qsTr("Новое хранилище")
modal: true
width: 560
height: 480
anchors.centerIn: parent
closePolicy: Dialog.NoAutoClose
StackLayout {
id: stack
anchors.fill: parent
currentIndex: root.step
// ---------- Шаг 1: выбор папки ----------
ColumnLayout {
spacing: 12
Label {
text: qsTr("Выберите локальную папку — это будет ваше хранилище.\n"
+ "Можно указать новую папку (будет создана) или уже существующую с файлами.")
wrapMode: Text.Wrap
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
TextField {
id: pathField
Layout.fillWidth: true
placeholderText: qsTr("Путь к папке…")
text: root.vaultPath
onTextChanged: root.vaultPath = text
}
Button {
text: qsTr("Обзор…")
onClicked: folderDialog.open()
}
}
FolderDialog {
id: folderDialog
title: qsTr("Выберите папку хранилища")
onAccepted: root.vaultPath = selectedFolder.localFile ?? ""
}
Label {
id: step1Error
color: "red"
wrapMode: Text.Wrap
Layout.fillWidth: true
text: ""
}
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: qsTr("Отмена")
onClicked: root.reject()
}
Button {
text: qsTr("Далее")
enabled: root.vaultPath.length > 0 && !busyOverlay.running
onClicked: {
step1Error.text = ""
busyOverlay.running = true
const err = vaultManager.createVault(root.vaultPath)
busyOverlay.running = false
if (err.length > 0) {
step1Error.text = err
return
}
root.step = stepSetup
}
}
}
}
// ---------- Шаг 2: git + провайдеры ----------
ColumnLayout {
spacing: 12
Label {
text: qsTr("Хранилище готово. Внутри автоматически создан git-репозиторий.\n"
+ "При желании подключите синхронизацию:")
wrapMode: Text.Wrap
Layout.fillWidth: true
}
GridLayout {
columns: 2
Layout.fillWidth: true
Layout.fillHeight: true
columnSpacing: 8
rowSpacing: 8
Button { text: "GitHub"; Layout.fillWidth: true; onClicked: openProviderGuide(0, "GitHub") }
Button { text: "GitLab"; Layout.fillWidth: true; onClicked: openProviderGuide(1, "GitLab") }
Button { text: "Gitea"; Layout.fillWidth: true; onClicked: openProviderGuide(2, "Gitea") }
Button { text: "Nextcloud (WebDAV)"; Layout.fillWidth: true; onClicked: openProviderGuide(3, "Nextcloud (WebDAV)") }
Button { text: "Syncthing"; Layout.fillWidth: true; onClicked: openProviderGuide(4, "Syncthing") }
function openProviderGuide(idx, name) {
providerGuideForButtons.providerIndex = idx
providerGuideForButtons.providerName = name
providerGuideForButtons.connected = false
providerGuideForButtons.open()
}
Label {
color: "#888"
Layout.columnSpan: 2
}
}
RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: qsTr("Готово")
onClicked: {
vaultManager.selectVault(root.vaultPath)
root.accept()
root.finished()
}
}
}
}
}
BusyIndicator {
id: busyOverlay
anchors.centerIn: parent
running: false
visible: running
}
ProviderSetupGuide {
id: providerGuideForButtons
}
onRejected: {
if (firstRun) {
// Пусть пользователь продолжит позже; хранилище не выбрано.
Qt.callLater(() => { console.log("Мастер отменён") })
visible = false
}
}
}
+50 -11
View File
@@ -1,27 +1,66 @@
#include "app/VaultManager.h" #include "app/VaultManager.h"
VaultManager::VaultManager(QObject *parent) #include "core/SettingsStore.h"
#include "git/GitManager.h"
#include <QDir>
VaultManager::VaultManager(SettingsStore *settings, GitManager *git, QObject *parent)
: QObject(parent) : QObject(parent)
, m_settings(settings)
, m_git(git)
{ {
connect(m_settings, &SettingsStore::vaultsChanged,
this, &VaultManager::vaultsChanged);
connect(m_settings, &SettingsStore::currentVaultChanged,
this, &VaultManager::currentVaultChanged);
} }
QStringList VaultManager::vaults() const QStringList VaultManager::vaults() const
{ {
return m_vaults; return m_settings->vaults();
} }
QString VaultManager::currentVault() const QString VaultManager::currentVault() const
{ {
return m_currentVault; return m_settings->currentVault();
} }
void VaultManager::setCurrentVault(const QString &path) QString VaultManager::createVault(const QString &path)
{ {
if (m_currentVault == path) if (path.trimmed().isEmpty())
return; return QStringLiteral("Не указан путь к хранилищу.");
m_currentVault = path;
if (!m_vaults.contains(path)) if (!QDir().mkpath(path))
m_vaults.append(path); return QStringLiteral("Не удалось создать папку «%1».").arg(path);
emit currentVaultChanged();
emit vaultsChanged(); if (!m_git->initRepo(path)) {
emit operationError(m_git->lastError());
return m_git->lastError();
}
m_settings->addVault(path);
return QString();
}
QString VaultManager::openVault(const QString &path)
{
if (path.trimmed().isEmpty())
return QStringLiteral("Не указан путь к хранилищу.");
if (!QDir(path).exists())
return QStringLiteral("Папка «%1» не существует.").arg(path);
if (!m_git->initRepo(path)) {
emit operationError(m_git->lastError());
return m_git->lastError();
}
m_settings->addVault(path);
return QString();
}
void VaultManager::selectVault(const QString &path)
{
m_settings->setCurrentVault(path);
} }
+22 -9
View File
@@ -3,29 +3,42 @@
#include <QObject> #include <QObject>
#include <QStringList> #include <QStringList>
// Менеджер хранилищ: список известных хранилищ, основное хранилище, class SettingsStore;
// открытие нового хранилища в новом окне (мульти-окна). class GitManager;
// Реализация заполняется в Фазе 2; здесь — только каркас для скелета (Фаза 0).
// Менеджер хранилищ: список, создание/открытие, git-инициализация.
// Фаза 1: создание хранилища + автосоздание git-репозитория.
// Фаза 2 добавит мульти-окна (новое хранилище в новом окне).
class VaultManager : public QObject class VaultManager : public QObject
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(QStringList vaults READ vaults NOTIFY vaultsChanged) Q_PROPERTY(QStringList vaults READ vaults NOTIFY vaultsChanged)
Q_PROPERTY(QString currentVault READ currentVault WRITE setCurrentVault Q_PROPERTY(QString currentVault READ currentVault NOTIFY currentVaultChanged)
NOTIFY currentVaultChanged)
public: public:
explicit VaultManager(QObject *parent = nullptr); VaultManager(SettingsStore *settings, GitManager *git, QObject *parent = nullptr);
QStringList vaults() const; QStringList vaults() const;
QString currentVault() const; QString currentVault() const;
void setCurrentVault(const QString &path);
// Создаёт хранилище: папка + git-репозиторий внутри. Возвращает "" при успехе
// или текст ошибки.
Q_INVOKABLE QString createVault(const QString &path);
// Открывает существующее хранилище (git-репозиторий внутри не обязателен —
// создаётся при необходимости). Возвращает текст ошибки или "".
Q_INVOKABLE QString openVault(const QString &path);
// Выбор хранилища пользователем по завершении мастера.
Q_INVOKABLE void selectVault(const QString &path);
signals: signals:
void vaultsChanged(); void vaultsChanged();
void currentVaultChanged(); void currentVaultChanged();
void operationError(const QString &message);
private: private:
QStringList m_vaults; SettingsStore *m_settings;
QString m_currentVault; GitManager *m_git;
}; };
+97
View File
@@ -0,0 +1,97 @@
#include "core/SettingsStore.h"
#include <QSettings>
namespace {
constexpr auto kGroupVaults = "vaults";
constexpr auto kKeyVaults = "vaults/vaultList";
constexpr auto kKeyCurrent = "vaults/current";
constexpr auto kKeyProviders = "sync/providers";
}
SettingsStore::SettingsStore(QObject *parent)
: QObject(parent)
{
QSettings s;
m_providers = s.value(kKeyProviders).toList();
}
QStringList SettingsStore::vaults() const
{
QSettings s;
return s.value(kKeyVaults).toStringList();
}
QString SettingsStore::currentVault() const
{
QSettings s;
return s.value(kKeyCurrent).toString();
}
void SettingsStore::setCurrentVault(const QString &path)
{
QSettings s;
s.setValue(kKeyCurrent, path);
emit currentVaultChanged();
}
QVariantList SettingsStore::providers() const
{
return m_providers;
}
void SettingsStore::addVault(const QString &path)
{
QStringList list = vaults();
if (!list.contains(path))
list.prepend(path);
QSettings s;
s.setValue(kKeyVaults, list);
s.setValue(kKeyCurrent, path);
emit vaultsChanged();
emit currentVaultChanged();
}
void SettingsStore::addProvider(const QString &provider, const QVariantMap &config)
{
QVariantList list = m_providers;
for (auto &item : list) {
auto m = item.toMap();
if (m.value("provider") == provider) {
m = config;
m["provider"] = provider;
item = m;
m_providers = list;
goto save;
}
}
{
auto m = config;
m["provider"] = provider;
list.append(m);
m_providers = list;
}
save:
QSettings s;
s.setValue(kKeyProviders, m_providers);
emit providersChanged();
}
bool SettingsStore::hasVault(const QString &path) const
{
return vaults().contains(path);
}
void SettingsStore::clearProvider(const QString &provider)
{
QVariantList list = m_providers;
list.erase(std::remove_if(list.begin(), list.end(),
[&provider](const QVariant &v) {
return v.toMap().value("provider") == provider;
}),
list.end());
m_providers = list;
QSettings s;
s.setValue(kKeyProviders, m_providers);
emit providersChanged();
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <QObject>
#include <QStringList>
#include <QVariantList>
// Хранение настроек приложения (QSettings):
// список хранилищ, основное хранилище, конфигурации провайдеров синхронизации.
class SettingsStore : public QObject
{
Q_OBJECT
Q_PROPERTY(QStringList vaults READ vaults NOTIFY vaultsChanged)
Q_PROPERTY(QString currentVault READ currentVault WRITE setCurrentVault
NOTIFY currentVaultChanged)
Q_PROPERTY(QVariantList providers READ providers NOTIFY providersChanged)
public:
explicit SettingsStore(QObject *parent = nullptr);
QStringList vaults() const;
QString currentVault() const;
void setCurrentVault(const QString &path);
QVariantList providers() const;
// Добавляет хранилище в список (если ещё нет) и делает его текущим.
void addVault(const QString &path);
// Сохраняет конфигурацию провайдера (provider: "github"/"gitlab"/"gitea"/"webdav"/"syncthing").
void addProvider(const QString &provider, const QVariantMap &config);
Q_INVOKABLE bool hasVault(const QString &path) const;
Q_INVOKABLE void clearProvider(const QString &provider);
signals:
void vaultsChanged();
void currentVaultChanged();
void providersChanged();
private:
QVariantList m_providers;
};
+193
View File
@@ -0,0 +1,193 @@
#include "git/GitManager.h"
#include <QDir>
#include <QFile>
namespace {
QString gitErr()
{
const git_error *e = git_error_last();
return e && e->message ? QString::fromUtf8(e->message) : QStringLiteral("неизвестная ошибка git");
}
struct CommitInfo {
git_signature *author = nullptr;
git_index *index = nullptr;
git_repository *repo = nullptr;
git_oid treeId{};
git_oid commitId{};
~CommitInfo()
{
if (author) git_signature_free(author);
if (index) git_index_free(index);
if (repo) git_repository_free(repo);
}
};
} // namespace
GitManager::GitManager(QObject *parent)
: QObject(parent)
{
if (!m_gitInitialized) {
git_libgit2_init();
m_gitInitialized = true;
}
}
QString GitManager::repoExists(const QString &path)
{
git_repository *repo = nullptr;
if (git_repository_open(&repo, path.toUtf8().constData()) < 0)
return QString();
git_repository_free(repo);
return path;
}
void GitManager::setRepoIdentity(git_repository *repo)
{
git_config *cfg = nullptr;
if (git_repository_config(&cfg, repo) < 0)
return;
auto setIfMissing = [cfg](const char *key, const char *value) {
git_buf buf{};
if (git_config_get_string_buf(&buf, cfg, key) < 0)
git_config_set_string(cfg, key, value);
git_buf_dispose(&buf);
};
setIfMissing("user.name", "Flora");
setIfMissing("user.email", "flora@localhost");
git_config_free(cfg);
}
void GitManager::setLastError(const QString &msg)
{
if (m_lastError == msg)
return;
m_lastError = msg;
emit lastErrorChanged();
}
bool GitManager::initRepo(const QString &path)
{
const QByteArray p = path.toUtf8();
git_repository *repo = nullptr;
if (git_repository_open(&repo, p.constData()) == 0) {
git_repository_free(repo);
return true; // .git уже есть
}
git_repository_init_options opts = GIT_REPOSITORY_INIT_OPTIONS_INIT;
opts.flags = GIT_REPOSITORY_INIT_MKPATH | GIT_REPOSITORY_INIT_MKDIR;
opts.initial_head = "main";
const int rc = git_repository_init_ext(&repo, p.constData(), &opts);
if (rc < 0) {
setLastError(QStringLiteral("Не удалось создать git-репозиторий: %1")
.arg(gitErr()));
return false;
}
setRepoIdentity(repo);
git_repository_free(repo);
setLastError(QString());
return true;
}
bool GitManager::hasCommits(const QString &path) const
{
git_repository *repo = nullptr;
if (git_repository_open(&repo, path.toUtf8().constData()) < 0)
return false;
const bool has = git_repository_head_unborn(repo) == 0;
git_repository_free(repo);
return has;
}
bool GitManager::commitAll(const QString &path, const QString &message)
{
CommitInfo info;
if (git_repository_open(&info.repo, path.toUtf8().constData()) < 0) {
setLastError(QStringLiteral("Хранилище не является git-репозиторием"));
return false;
}
if (git_repository_index(&info.index, info.repo) < 0) {
setLastError(QStringLiteral("Не удалось открыть индекс: %1").arg(gitErr()));
return false;
}
// Добавляем все файлы, включая новые (с учётом .gitignore)
git_index_add_all(info.index, nullptr, 0, nullptr, nullptr);
const int wrc = git_index_write(info.index);
if (wrc < 0) {
setLastError(QStringLiteral("Не удалось записать индекс: %1").arg(gitErr()));
return false;
}
if (git_index_entrycount(info.index) == 0) {
setLastError(QStringLiteral("Нет изменений для коммита"));
return false;
}
// Создаём дерево
git_tree *tree = nullptr;
if (git_index_write_tree(&info.treeId, info.index) < 0
|| git_tree_lookup(&tree, info.repo, &info.treeId) < 0) {
setLastError(QStringLiteral("Не удалось создать дерево: %1").arg(gitErr()));
return false;
}
// Родитель: head, если коммиты уже есть
git_reference *headRef = nullptr;
git_commit *parentCommit = nullptr;
int nParents = 0;
if (git_repository_head(&headRef, info.repo) == 0) {
git_object *obj = nullptr;
if (git_reference_peel(&obj, headRef, GIT_OBJECT_COMMIT) == 0) {
parentCommit = reinterpret_cast<git_commit *>(obj);
nParents = 1;
}
if (headRef) git_reference_free(headRef);
}
// Если дерево не изменилось относительно родителя — коммитить нечего.
if (parentCommit) {
const git_oid *prevTree = git_commit_tree_id(parentCommit);
if (prevTree && git_oid_equal(prevTree, &info.treeId)) {
git_commit_free(parentCommit);
git_tree_free(tree);
setLastError(QStringLiteral("Нет изменений для коммита"));
return false;
}
}
const git_commit *parents[1] = {parentCommit};
// Подпись из конфига репозитория
git_signature *author = nullptr;
if (git_signature_default(&author, info.repo) < 0) {
git_commit_free(parentCommit);
git_tree_free(tree);
setLastError(QStringLiteral("Не настроена git-личность: %1").arg(gitErr()));
return false;
}
info.author = author;
const int crc = git_commit_create(&info.commitId, info.repo, "HEAD",
author, author, "UTF-8",
message.toUtf8().constData(),
tree, nParents, parents);
if (parentCommit) git_commit_free(parentCommit);
git_tree_free(tree);
if (crc < 0) {
setLastError(QStringLiteral("Не удалось создать коммит: %1").arg(gitErr()));
return false;
}
setLastError(QString());
return true;
}
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <QObject>
#include <QString>
#include <git2.h>
// Обёртка над libgit2: инициализация репозитория хранилища и авто-коммиты.
class GitManager : public QObject
{
Q_OBJECT
public:
explicit GitManager(QObject *parent = nullptr);
// Инициализирует git-репозиторий внутри хранилища (папка path).
// Если .git уже есть — просто открывает его. Возвращает true при успехе.
Q_INVOKABLE bool initRepo(const QString &path);
// Коммитит все изменения в хранилище (рабочее дерево). Возвращает true/false.
Q_INVOKABLE bool commitAll(const QString &path, const QString &message);
// Есть ли в хранилище хотя бы один коммит.
Q_INVOKABLE bool hasCommits(const QString &path) const;
// Последнее сообщение об ошибке (русскоязычное, из каталога ошибок).
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
QString lastError() const { return m_lastError; }
static QString repoExists(const QString &path);
signals:
void lastErrorChanged();
private:
void setRepoIdentity(git_repository *repo);
void setLastError(const QString &msg);
QString m_lastError;
bool m_gitInitialized = false;
};
+10 -1
View File
@@ -4,6 +4,9 @@
#include <QCommandLineParser> #include <QCommandLineParser>
#include "app/VaultManager.h" #include "app/VaultManager.h"
#include "core/SettingsStore.h"
#include "git/GitManager.h"
#include "sync/SyncManager.h"
#include <cmark-gfm.h> #include <cmark-gfm.h>
#include <git2.h> #include <git2.h>
@@ -29,9 +32,15 @@ int main(int argc, char *argv[])
cmark_version_string(), cmark_version_string(),
gitMajor, gitMinor, gitRev); gitMajor, gitMinor, gitRev);
VaultManager vaultManager; SettingsStore settingsStore;
GitManager gitManager;
SyncManager syncManager;
VaultManager vaultManager(&settingsStore, &gitManager);
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("settingsStore", &settingsStore);
engine.rootContext()->setContextProperty("gitManager", &gitManager);
engine.rootContext()->setContextProperty("syncManager", &syncManager);
engine.rootContext()->setContextProperty("vaultManager", &vaultManager); engine.rootContext()->setContextProperty("vaultManager", &vaultManager);
engine.loadFromModule("Flora", "MainWindow"); engine.loadFromModule("Flora", "MainWindow");
+209
View File
@@ -0,0 +1,209 @@
#include "sync/SyncManager.h"
#include <QUrl>
namespace {
QString stripUrl(QString url)
{
url = url.trimmed();
while (url.endsWith('/'))
url.chop(1);
return url;
}
// Строит сообщение и подсказку по коду HTTP/сетевой ошибке.
QPair<int, QPair<QString, QString>> catalog(int provider, int httpStatus,
const QString &networkErr)
{
using P = QPair<int, QPair<QString, QString>>;
auto make = [](int code, const QString &t, const QString &h) {
return P(code, {t, h});
};
switch (httpStatus) {
case 401:
return make(SyncManager::Unauthorized,
QStringLiteral("Токен не принят (401)"),
provider == SyncManager::WebDav
? QStringLiteral("Проверьте логин и пароль. Создайте новый токен.")
: QStringLiteral("Проверьте токен: он мог истечь или быть отозван. "
"Создайте новый на странице токенов провайдера."));
case 403:
return make(SyncManager::Forbidden,
QStringLiteral("Недостаточно прав (403)"),
QStringLiteral("Проверьте права токена (scope repo/api). "
"Если это ограничение частоты запросов — подождите несколько минут."));
case 404:
return make(SyncManager::NotFound,
QStringLiteral("Не найдено (404)"),
provider == SyncManager::GitHub || provider == SyncManager::GitLab
? QStringLiteral("Проверьте адрес и что хранилище/репозиторий существуют "
"и доступны этому пользователю.")
: QStringLiteral("Проверьте адрес сервера и путь."));
case 409:
return make(SyncManager::Conflict,
QStringLiteral("Конфликт (409)"),
QStringLiteral("Удалённый репозиторий уже содержит коммиты. "
"Потребуется слияние (см. инструкции в диалоге)."));
case 207:
return make(SyncManager::Ok, QStringLiteral("OK"), QString());
default:
break;
}
if (httpStatus >= 500 && httpStatus != 0)
return make(SyncManager::ServerError,
QStringLiteral("Ошибка сервера (%1)").arg(httpStatus),
QStringLiteral("Повторите позже. Если ошибка повторяется — проверьте "
"работоспособность сервера."));
if (httpStatus == 0 && !networkErr.isEmpty()) {
if (networkErr.contains(QStringLiteral("TLS"), Qt::CaseInsensitive)
|| networkErr.contains(QStringLiteral("SSL"), Qt::CaseInsensitive))
return make(SyncManager::TlsError,
QStringLiteral("Проблема сертификата (TLS/SSL)"),
QStringLiteral("Self-hosted сервер может не иметь доверенного сертификата. "
"Добавьте сертификат в доверенные или включите опцию "
"«Разрешить небезопасное соединение» с осторожностью."));
if (networkErr.contains(QStringLiteral("OperationCanceledError"))
|| networkErr.contains(QStringLiteral("timed out"), Qt::CaseInsensitive))
return make(SyncManager::Timeout,
QStringLiteral("Истекло время ожидания"),
QStringLiteral("Проверьте доступность сервера, сеть и прокси."));
return make(SyncManager::NetworkError,
QStringLiteral("Сетевая ошибка"),
QStringLiteral("Проверьте подключение к интернету, адрес сервера и прокси. "
"Детали: %1").arg(networkErr));
}
if (httpStatus == 200 || httpStatus == 204)
return make(SyncManager::Ok, QStringLiteral("OK"), QString());
return make(SyncManager::ServerError,
QStringLiteral("Неизвестный ответ (%1)").arg(httpStatus),
QStringLiteral("Сервер вернул неожиданный ответ."));
}
} // namespace
SyncManager::SyncManager(QObject *parent)
: QObject(parent)
{
connect(&m_nam, &QNetworkAccessManager::finished, this,
[this](QNetworkReply *reply) {
const int provider = reply->property("flora_provider").toInt();
handleReply(reply, provider);
});
}
bool SyncManager::busy() const { return m_busy; }
QString SyncManager::lastStatus() const { return m_lastStatus; }
void SyncManager::testConnection(int provider, const QString &url,
const QString &username, const QString &token)
{
QUrl base(stripUrl(url));
QNetworkRequest req;
req.setTransferTimeout(15000);
switch (static_cast<Provider>(provider)) {
case GitHub:
req.setUrl(QUrl(QStringLiteral("https://api.github.com/user")));
req.setRawHeader("Authorization", "Bearer " + token.toUtf8());
req.setRawHeader("Accept", "application/vnd.github+json");
break;
case GitLab: {
req.setUrl(QUrl(base.toString() + QStringLiteral("/api/v4/user")));
req.setRawHeader("PRIVATE-TOKEN", token.toUtf8());
break;
}
case Gitea: {
req.setUrl(QUrl(base.toString() + QStringLiteral("/api/v1/user")));
req.setRawHeader("Authorization", "token " + token.toUtf8());
break;
}
case WebDav: {
req.setUrl(base);
req.setRawHeader("Depth", "0");
const QString u = username + ':' + token;
req.setRawHeader("Authorization",
"Basic " + u.toUtf8().toBase64());
break;
}
case Syncthing: {
req.setUrl(QUrl(base.toString() + QStringLiteral("/rest/system/version")));
req.setRawHeader("X-API-Key", token.toUtf8());
break;
}
}
m_busy = true;
emit busyChanged();
QNetworkReply *reply = m_nam.sendCustomRequest(req,
static_cast<Provider>(provider) == WebDav ? "PROPFIND" : "GET");
reply->setProperty("flora_provider", provider);
}
void SyncManager::handleReply(QNetworkReply *reply, int provider)
{
reply->deleteLater();
const int httpStatus = reply->attribute(
QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString raw = QString::fromUtf8(reply->readAll());
if (reply->error() != QNetworkReply::NoError) {
const auto [code, pair] = catalog(provider, 0, reply->errorString());
finish(provider, false, code, pair.first, pair.second,
reply->errorString() + (raw.isEmpty() ? QString() : QStringLiteral(" | ") + raw));
return;
}
const auto [code, pair] = catalog(provider, httpStatus, QString());
finish(provider, code == Ok, code, pair.first, pair.second, raw);
}
void SyncManager::finish(int provider, bool ok, int code, const QString &title,
const QString &hint, const QString &raw)
{
m_busy = false;
m_lastStatus = ok ? QStringLiteral("Подключение установлено")
: title + (hint.isEmpty() ? QString() : QStringLiteral(" — ") + hint);
emit busyChanged();
emit lastStatusChanged();
emit connectionResult(provider, ok, code, title, hint, raw);
}
QString SyncManager::errorTitle(int errorCode) const
{
switch (errorCode) {
case Ok: return QStringLiteral("OK");
case Unauthorized: return QStringLiteral("401 Unauthorized");
case Forbidden: return QStringLiteral("403 Forbidden");
case NotFound: return QStringLiteral("404 Not Found");
case Conflict: return QStringLiteral("409 Conflict");
case TlsError: return QStringLiteral("TLS/SSL ошибка");
case Timeout: return QStringLiteral("Таймаут");
case NetworkError: return QStringLiteral("Сетевая ошибка");
default: return QStringLiteral("Ошибка %1").arg(errorCode);
}
}
QString SyncManager::errorHint(int errorCode) const
{
switch (errorCode) {
case Unauthorized:
return QStringLiteral("Создайте новый токен. GitHub: Settings → Developer settings → "
"Tokens (classic, scope repo). GitLab: Preferences → Access Tokens. "
"Gitea: Settings → Applications → Tokens.");
case Forbidden:
return QStringLiteral("Токену не хватает прав (repo/api). Проверьте scopes.");
case NotFound:
return QStringLiteral("Проверьте адрес и существование репозитория/папки.");
case Conflict:
return QStringLiteral("Отключите автоматическую синхронизацию и выполните ручное слияние.");
case TlsError:
return QStringLiteral("Self-hosted сервер: добавьте сертификат CA в доверенные.");
case Timeout:
return QStringLiteral("Проверьте сеть, хост и порт.");
default:
return QString();
}
}
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
// Синхронизация и провайдеры. Фаза 1: проверка подключения (токены/URL)
// и сохранение конфигурации. Полноценный обмен (push/pull) — Фаза 11.
class SyncManager : public QObject
{
Q_OBJECT
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged)
public:
enum Provider {
GitHub = 0,
GitLab,
Gitea,
WebDav,
Syncthing
};
Q_ENUM(Provider)
enum ErrorCode {
Ok = 0,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
Conflict = 409,
TlsError = 1001,
Timeout = 1002,
NetworkError = 1003,
ServerError = 1004
};
Q_ENUM(ErrorCode)
explicit SyncManager(QObject *parent = nullptr);
bool busy() const;
QString lastStatus() const;
// Проверяет доступность провайдера с параметрами и сохраняет конфигурацию при успехе.
Q_INVOKABLE void testConnection(int provider, const QString &url,
const QString &username, const QString &token);
// Человекочитаемое описание ошибки по коду из каталога ошибок.
Q_INVOKABLE QString errorTitle(int errorCode) const;
Q_INVOKABLE QString errorHint(int errorCode) const;
signals:
void connectionResult(int provider, bool ok, int errorCode,
const QString &title, const QString &hint,
const QString &raw);
void busyChanged();
void lastStatusChanged();
private:
void runRequest(QNetworkRequest req, int provider);
void handleReply(QNetworkReply *reply, int provider);
void finish(int provider, bool ok, int code, const QString &title,
const QString &hint, const QString &raw);
QNetworkAccessManager m_nam;
bool m_busy = false;
QString m_lastStatus;
};
+37
View File
@@ -0,0 +1,37 @@
# Smoke-тест: проверка сборки и работы сторонних библиотек.
add_executable(flora_smoke smoke_test.cpp)
target_compile_definitions(flora_smoke PRIVATE
CMARK_GFM_STATIC_DEFINE
CMARK_GFM_EXTENSIONS_STATIC_DEFINE)
target_link_libraries(flora_smoke PRIVATE
cmark::cmark
cmark::cmark_extensions
Flora_libgit2_static)
add_test(NAME flora_smoke COMMAND flora_smoke)
# Тест GitManager (git init / авто-коммиты внутри хранилища).
add_executable(flora_git_test git_test.cpp
../src/git/GitManager.cpp
../src/git/GitManager.h
)
target_include_directories(flora_git_test PRIVATE ../src)
target_compile_definitions(flora_git_test PRIVATE CMARK_GFM_STATIC_DEFINE)
target_link_libraries(flora_git_test PRIVATE
Qt6::Core
Flora_libgit2_static)
add_test(NAME flora_git_test COMMAND flora_git_test)
# Интеграционный тест: настройки + создание хранилища (git-репозиторий).
add_executable(flora_vault_test vault_test.cpp
../src/app/VaultManager.cpp
../src/app/VaultManager.h
../src/core/SettingsStore.cpp
../src/core/SettingsStore.h
../src/git/GitManager.cpp
../src/git/GitManager.h
)
target_include_directories(flora_vault_test PRIVATE ../src)
target_link_libraries(flora_vault_test PRIVATE
Qt6::Core
Flora_libgit2_static)
add_test(NAME flora_vault_test COMMAND flora_vault_test)
+75
View File
@@ -0,0 +1,75 @@
// Тест GitManager (Фаза 1): инициализация репозитория хранилища,
// авто-коммит с новым файлом, наличие коммитов в HEAD.
#include <QCoreApplication>
#include <QTemporaryDir>
#include <QFile>
#include <QDir>
#include <cstdio>
#include "git/GitManager.h"
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QTemporaryDir dir;
if (!dir.isValid()) {
std::fprintf(stderr, "FAIL: не удалось создать временную папку\n");
return 1;
}
GitManager git;
// 1. init в новой папке
if (!git.initRepo(dir.path()) || !git.lastError().isEmpty()) {
std::fprintf(stderr, "FAIL: initRepo: %s\n", qPrintable(git.lastError()));
return 1;
}
// 2. init идемпотентен (повторный вызов — успех)
if (!git.initRepo(dir.path())) {
std::fprintf(stderr, "FAIL: повторный initRepo\n");
return 1;
}
// 3. нет коммитов
if (git.hasCommits(dir.path())) {
std::fprintf(stderr, "FAIL: коммиты появились раньше времени\n");
return 1;
}
// 4. создаём файл и коммитим
QFile f(dir.filePath(QStringLiteral("readme.md")));
if (!f.open(QIODevice::WriteOnly)) {
std::fprintf(stderr, "FAIL: не удалось создать файл\n");
return 1;
}
f.write("# Привет, Flora!\n");
f.close();
if (!git.commitAll(dir.path(), QStringLiteral("initial"))) {
std::fprintf(stderr, "FAIL: commitAll: %s\n", qPrintable(git.lastError()));
return 1;
}
// 5. теперь коммиты есть
if (!git.hasCommits(dir.path())) {
std::fprintf(stderr, "FAIL: no commits after commitAll\n");
return 1;
}
// 6. повторный коммит без изменений — корректная ошибка, без падения
if (git.commitAll(dir.path(), QStringLiteral("noop"))) {
std::fprintf(stderr, "FAIL: повторный коммит без изменений не должен удаваться\n");
return 1;
}
// 7. не-существующая папка
if (git.initRepo("/путь/которого/не/существует_xyz")) {
std::fprintf(stderr, "FAIL: initRepo не должен удаваться\n");
return 1;
}
std::printf("GIT TEST OK\n");
return 0;
}
+71
View File
@@ -0,0 +1,71 @@
// Smoke-тест Фазы 0: проверяет, что третьест-сторонние библиотеки
// (cmark-gfm, libgit2) собраны, слинкованы и работают.
#include <cmark-gfm.h>
#include <cmark-gfm-extension_api.h>
#include <cmark-gfm-core-extensions.h>
#include <git2.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
static std::string render_md(const std::string &markdown)
{
cmark_gfm_core_extensions_ensure_registered();
cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT);
for (const char *name : {"table", "strikethrough", "autolink", "tasklist"}) {
if (cmark_syntax_extension *ext = cmark_find_syntax_extension(name))
cmark_parser_attach_syntax_extension(parser, ext);
}
cmark_parser_feed(parser, markdown.data(), markdown.size());
cmark_node *doc = cmark_parser_finish(parser);
char *html = cmark_render_html(doc, CMARK_OPT_DEFAULT,
cmark_parser_get_syntax_extensions(parser));
std::string out = html ? html : "";
free(html);
cmark_node_free(doc);
cmark_parser_free(parser);
return out;
}
int main()
{
// --- libgit2 ---
int maj = 0, min = 0, rev = 0;
git_libgit2_version(&maj, &min, &rev);
std::printf("libgit2 %d.%d.%d\n", maj, min, rev);
if (maj == 0 && min == 0) {
std::fprintf(stderr, "FAIL: libgit2 не подключен\n");
return 1;
}
// --- cmark-gfm ---
std::printf("cmark-gfm %s\n", cmark_version_string());
if (std::strlen(cmark_version_string()) == 0) {
std::fprintf(stderr, "FAIL: cmark-gfm не подключен\n");
return 1;
}
// --- рендер Markdown (CommonMark + GFM: таблица, задача, заголовок) ---
const char *md
= "# Заголовок\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n- [ ] задача";
std::string html = render_md(md);
std::printf("html: %s\n", html.c_str());
bool ok = html.find("<h1>") != std::string::npos
&& html.find("<table") != std::string::npos
&& html.find("<input") != std::string::npos;
if (!ok) {
std::fprintf(stderr, "FAIL: GFM-рендер (заголовок/таблица/задача)\n");
return 1;
}
std::printf("SMOKE TEST OK\n");
return 0;
}
+63
View File
@@ -0,0 +1,63 @@
// Интеграционный тест Фазы 1: SettingsStore + VaultManager + GitManager.
// Используем временную папку для QSettings (INI-формат), чтобы не трогать реальные.
#include <QCoreApplication>
#include <QTemporaryDir>
#include <QDir>
#include <QSettings>
#include <cstdio>
#include "app/VaultManager.h"
#include "core/SettingsStore.h"
#include "git/GitManager.h"
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
app.setOrganizationName("FloraTest");
app.setApplicationName("flora_test");
QTemporaryDir dir;
QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, dir.path());
{
SettingsStore settings;
GitManager git;
VaultManager vault(&settings, &git);
const QString vaultPath = dir.path() + "/vault";
const QString err = vault.createVault(vaultPath);
if (!err.isEmpty()) {
std::fprintf(stderr, "FAIL: createVault: %s\n", qPrintable(err));
return 1;
}
if (!settings.hasVault(vaultPath) || settings.currentVault() != vaultPath) {
std::fprintf(stderr, "FAIL: хранилище не сохранено\n");
return 1;
}
if (!git.hasCommits(vaultPath)) {
// коммитов может ещё не быть (нет файлов) — инициализация должна пройти
}
if (vault.vaults().isEmpty()) {
std::fprintf(stderr, "FAIL: список хранилищ пуст\n");
return 1;
}
const QString bad = vault.createVault("");
if (bad.isEmpty()) {
std::fprintf(stderr, "FAIL: пустой путь не отклонён\n");
return 1;
}
}
{
// Проверка персистентности в другом экземпляре
SettingsStore settings;
if (!settings.hasVault(dir.path() + "/vault")) {
std::fprintf(stderr, "FAIL: настройки не сохранились\n");
return 1;
}
}
std::printf("VAULT TEST OK\n");
return 0;
}
+3 -1
View File
@@ -28,6 +28,7 @@ add_library(cmark_wrapper INTERFACE)
target_link_libraries(cmark_wrapper INTERFACE ${_cmark_target}) target_link_libraries(cmark_wrapper INTERFACE ${_cmark_target})
target_include_directories(cmark_wrapper INTERFACE target_include_directories(cmark_wrapper INTERFACE
"${cmark_SOURCE_DIR}/src" "${cmark_SOURCE_DIR}/src"
"${cmark_SOURCE_DIR}/extensions"
"${cmark_BINARY_DIR}/src" "${cmark_BINARY_DIR}/src"
"${cmark_BINARY_DIR}/extensions") "${cmark_BINARY_DIR}/extensions")
if(NOT TARGET cmark::cmark) if(NOT TARGET cmark::cmark)
@@ -51,8 +52,9 @@ FetchContent_Declare(libssh2
GIT_SHALLOW TRUE GIT_SHALLOW TRUE
) )
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(BUILD_TESTING OFF CACHE BOOL "" FORCE) set(BUILD_TESTING OFF) # локально для подпроекта libssh2 (не трогаем глобальное значение)
FetchContent_MakeAvailable(libssh2) FetchContent_MakeAvailable(libssh2)
unset(BUILD_TESTING) # возвращаем глобальное (из CMakeLists.txt)
# macOS (новые SDK): strtoll требует _DARWIN_C_SOURCE, memset_s — __STDC_WANT_LIB_EXT1__. # macOS (новые SDK): strtoll требует _DARWIN_C_SOURCE, memset_s — __STDC_WANT_LIB_EXT1__.
foreach(_libssh2_tgt IN ITEMS libssh2_static libssh2_shared) foreach(_libssh2_tgt IN ITEMS libssh2_static libssh2_shared)