diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cb2f37..ef1bd10 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,8 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/third_party/deps.cmake) set(FLORA_HEADERS src/app/VaultManager.h + src/app/WindowController.h + src/core/FileStore.h src/core/SettingsStore.h src/git/GitManager.h src/sync/SyncManager.h @@ -43,6 +45,8 @@ set(FLORA_HEADERS set(FLORA_SOURCES src/app/VaultManager.cpp + src/app/WindowController.cpp + src/core/FileStore.cpp src/core/SettingsStore.cpp src/git/GitManager.cpp src/sync/SyncManager.cpp @@ -96,6 +100,8 @@ qt_add_qml_module(flora qml/panels/RightPanel.qml qml/wizard/WelcomeWizard.qml qml/dialogs/ProviderSetupGuide.qml + qml/dialogs/NameDialog.qml + qml/dialogs/ConfirmDialog.qml ) # --- Тесты --- diff --git a/qml/MainWindow.qml b/qml/MainWindow.qml index d03aead..b71f3cf 100644 --- a/qml/MainWindow.qml +++ b/qml/MainWindow.qml @@ -3,7 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts // Главное окно: меню вверху, три панели (дерево / редактор / правая). -// Фазы 0-1: каркас + мастер первого запуска. Контент панелей — в следующих фазах. +// Мульти-хранилища: каждое окно привязано к своему хранилищу (windowVault). ApplicationWindow { id: window width: 1280 @@ -12,16 +12,29 @@ ApplicationWindow { title: "Flora" color: "white" - // Мастер первого запуска: показываем, если ещё нет ни одного хранилища. + // Хранилище этого окна (задаётся WindowController); у основного окна + // может быть пустым — тогда берём основное хранилище из настроек. + property string windowVault: "" + + readonly property string activeVault: { + if (fileStore.ready) return fileStore.vault + if (windowVault.length > 0) return windowVault + return vaultManager.currentVault + } + WelcomeWizard { id: welcomeWizard firstRun: vaultManager.vaults.length === 0 - onFinished: vaultSwitcher.close() - } - - Component.onCompleted: { - if (vaultManager.vaults.length === 0) - welcomeWizard.open() + onFinished: { + if (fileStore.ready) { + // Добавлено новое хранилище при уже открытом — новое окно. + windowController.openWindow(welcomeWizard.vaultPath) + } else { + // Первый запуск: применяем хранилище к этому окну. + fileStore.openVault(welcomeWizard.vaultPath) + root_status("Хранилище открыто") + } + } } function openWizard() { @@ -29,6 +42,21 @@ ApplicationWindow { welcomeWizard.open() } + function root_status(msg, ok) { + console.log(msg) + } + + Component.onCompleted: { + if (windowVault.length > 0) { + fileStore.openVault(windowVault) + } else if (vaultManager.currentVault.length > 0) { + fileStore.openVault(vaultManager.currentVault) + } + // Если хранилищ нет — мастер откроется автоматически. + if (vaultManager.vaults.length === 0) + welcomeWizard.open() + } + menuBar: MenuBar { Menu { title: qsTr("&Файл") @@ -77,7 +105,7 @@ ApplicationWindow { } } - // Переключатель хранилищ (кнопка внизу слева, в FileTreePanel) + // Переключатель хранилищ (кнопка внизу слева) Popup { id: vaultSwitcher x: 12 @@ -104,10 +132,13 @@ ApplicationWindow { delegate: ItemDelegate { width: ListView.view.width text: modelData - highlighted: modelData === vaultManager.currentVault + highlighted: modelData === window.activeVault onClicked: { - if (modelData !== vaultManager.currentVault) { - vaultManager.selectVault(modelData) + if (modelData === window.activeVault) { + vaultSwitcher.close() + } else { + windowController.openWindow(modelData) + vaultSwitcher.close() } } } @@ -120,8 +151,8 @@ ApplicationWindow { } Label { - text: qsTr("Текущее: %1").arg(vaultManager.currentVault.length > 0 - ? vaultManager.currentVault : "—") + text: qsTr("Текущее: %1").arg(window.activeVault.length > 0 + ? window.activeVault : "—") color: "#666" elide: Text.ElideMiddle Layout.fillWidth: true @@ -144,9 +175,12 @@ ApplicationWindow { SplitView.preferredWidth: window.width * 0.15 SplitView.minimumWidth: 120 SplitView.maximumWidth: 500 + onOpenVaultRequested: vaultSwitcher.open() - onNewFileRequested: console.log("Новый файл (Фаза 3)") - onNewFolderRequested: console.log("Новая папка (Фаза 3)") + onOpenFileRequested: (path) => { + // Фаза 4: открытие вкладки в редакторе. Пока — лог. + console.log("Открыть файл: " + path) + } } EditorPanel { diff --git a/qml/dialogs/ConfirmDialog.qml b/qml/dialogs/ConfirmDialog.qml new file mode 100644 index 0000000..944208f --- /dev/null +++ b/qml/dialogs/ConfirmDialog.qml @@ -0,0 +1,76 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Подтверждение удаления. Для непустой папки требуется двойное подтверждение: +// первое нажатие показывает предупреждение, второе — удаляет. +Dialog { + id: root + + property string message: "" + property bool doubleConfirm: false + + signal confirmed() + + title: qsTr("Подтверждение") + modal: true + width: 420 + anchors.centerIn: parent + closePolicy: Dialog.NoAutoClose + standardButtons: Dialog.NoButton + + property bool armed: false + + function resetDialog() { + root.armed = false + deleteButton.text = qsTr("Удалить") + warnLabel.visible = false + } + + onOpened: root.resetDialog() + + ColumnLayout { + anchors.fill: parent + spacing: 12 + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: root.message + } + + Label { + id: warnLabel + Layout.fillWidth: true + wrapMode: Text.Wrap + visible: false + color: "#b00020" + text: qsTr("Внимание: папка содержит файлы. Они будут удалены безвозвратно.\n" + + "Если вы уверены — нажмите «Точно удалить».") + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + Button { + text: qsTr("Отмена") + onClicked: root.reject() + } + Button { + id: deleteButton + text: qsTr("Удалить") + highlighted: root.armed + onClicked: { + if (root.doubleConfirm && !root.armed) { + root.armed = true + deleteButton.text = qsTr("Точно удалить") + warnLabel.visible = true + return + } + root.confirmed() + root.accept() + } + } + } + } +} \ No newline at end of file diff --git a/qml/dialogs/NameDialog.qml b/qml/dialogs/NameDialog.qml new file mode 100644 index 0000000..b8b2018 --- /dev/null +++ b/qml/dialogs/NameDialog.qml @@ -0,0 +1,76 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Диалог ввода имени (новый файл/папка, переименование). +Dialog { + id: root + + property string dialogTitle: qsTr("Имя") + property string actionLabel: qsTr("OK") + property string initialText: "" + property string errorText: "" + + signal confirmed(string text) + + title: dialogTitle + modal: true + width: 380 + anchors.centerIn: parent + closePolicy: Dialog.NoAutoClose + standardButtons: Dialog.NoButton + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + TextField { + id: nameField + Layout.fillWidth: true + placeholderText: qsTr("Введите имя…") + text: root.initialText + selectByMouse: true + // Русская клавиатура: первая буква — заглавная + inputMethodHints: Qt.ImhNoPredictiveText + Component.onCompleted: { + forceActiveFocus() + selectAll() + } + onAccepted: root.tryConfirm() + onTextChanged: root.errorText = "" + } + + Label { + id: errorLabel + Layout.fillWidth: true + visible: root.errorText.length > 0 + color: "#b00020" + wrapMode: Text.Wrap + text: root.errorText + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + Button { + text: qsTr("Отмена") + onClicked: root.reject() + } + Button { + text: root.actionLabel + enabled: nameField.text.trim().length > 0 + onClicked: root.tryConfirm() + } + } + } + + function tryConfirm() { + const n = nameField.text.trim() + if (n.length === 0) { + root.errorText = qsTr("Имя не может быть пустым.") + return + } + root.confirmed(n) + root.accept() + } +} \ No newline at end of file diff --git a/qml/panels/FileTreePanel.qml b/qml/panels/FileTreePanel.qml index 27ad909..19770cf 100644 --- a/qml/panels/FileTreePanel.qml +++ b/qml/panels/FileTreePanel.qml @@ -2,84 +2,371 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -// Левая панель: файловое дерево. Фаза 0 — каркас: -// кнопка «+» над деревом с меню-бутербродом («Файл»/«Папка»), -// кнопка хранилища внизу слева. Само дерево и операции — Фаза 3. +// Левая панель: файловое дерево хранилища (Фаза 3). +// Создание/переименование/удаление/перемещение, иконки, стрелки раскрытия, +// отступы подпапок, ПКМ, drag&drop. Кнопка хранилища внизу слева. Item { id: root signal openVaultRequested() - signal newFileRequested() - signal newFolderRequested() + signal openFileRequested(string path) - ColumnLayout { - anchors.fill: parent - spacing: 0 + function baseName(p) { + const idx = p.lastIndexOf("/") + return idx >= 0 ? p.substring(idx + 1) : p + } - // Заголовок панели: кнопка «+» + название - Rectangle { - Layout.fillWidth: true - implicitHeight: 36 - color: "whitesmoke" - RowLayout { - anchors.fill: parent - anchors.leftMargin: 4 - spacing: 4 + // ----- Заголовок: «+» + название ----- + Rectangle { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 36 + color: "whitesmoke" - Button { - id: addButton - text: "+" - implicitWidth: 28 - implicitHeight: 26 - ToolTip.visible: hovered - ToolTip.text: qsTr("Создать") - onClicked: addMenu.popup() - } + RowLayout { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + spacing: 4 - Label { - text: qsTr("Файлы") - Layout.fillWidth: true - elide: Text.ElideRight - color: "#555" - } + Button { + id: addButton + text: "+" + implicitWidth: 28 + implicitHeight: 26 + enabled: fileStore.ready + ToolTip.visible: hovered + ToolTip.text: qsTr("Создать файл или папку") + onClicked: addMenu.popup() } - } - // Место файлового дерева (Фаза 3) - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - color: "#fafafa" Label { - anchors.centerIn: parent - text: qsTr("Дерево файлов\n(Фаза 3)") - horizontalAlignment: Text.AlignHCenter - color: "#bbb" + text: fileStore.ready ? qsTr("Файлы") : qsTr("Хранилище не выбрано") + Layout.fillWidth: true + elide: Text.ElideRight + color: "#555" } } - - // Кнопка хранилища внизу слева - Button { - id: vaultButton - Layout.fillWidth: true - text: qsTr("▨ Хранилище…") - onClicked: root.openVaultRequested() - } } - // Меню-«бутерброд» при нажатии «+» + Rectangle { + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: vaultButton.top + color: "#fafafa" + + // ПКМ по пустому месту дерева — то же меню, что и «+» + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) + addMenu.x = mouse.x; addMenu.y = mouse.y; addMenu.popup() + } + } + + ListView { + id: treeView + anchors.fill: parent + clip: true + model: fileStore + currentIndex: -1 + focus: true + ScrollBar.vertical: ScrollBar {} + + // Перемещение на пустое место = в корень хранилища + DropArea { + anchors.fill: parent + onDropped: (drop) => { + if (drop.source && drop.source.dragPath && fileStore.ready) { + const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault) + if (err.length > 0) showStatus(err, false) + } + } + } + + delegate: Rectangle { + id: rowRect + required property string display + required property string path + required property int depth + required property bool isDir + required property bool expanded + required property bool hasChildren + required property string icon + + width: treeView.width + height: 30 + color: rowRect.dragging ? "#d6e4ff" + : (treeView.currentIndex === index ? "#e6f0ff" + : (index % 2 === 0 ? "#fafafa" : "#f4f4f4")) + + property string dragPath: "" + property bool dragging: false + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 4 + depth * 16 + anchors.rightMargin: 6 + spacing: 4 + + Text { + text: isDir ? (expanded ? "\u25BE" : "\u25B8") : "" + font.pixelSize: 12 + Layout.preferredWidth: isDir ? 16 : 8 + horizontalAlignment: Text.AlignHCenter + color: "#666" + opacity: hasChildren ? 1 : 0.35 + MouseArea { + anchors.fill: parent + visible: hasChildren + onClicked: fileStore.toggleExpand(index) + } + } + + Text { + text: rowRect.iconSymbol(icon) + font.pixelSize: 14 + } + + Text { + text: display + Layout.fillWidth: true + elide: Text.ElideRight + color: isDir ? "#1a1a1a" : "#333" + font.bold: isDir + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + onPressed: { + treeView.currentIndex = index + } + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) { + rowMenu.x = mouse.x; rowMenu.y = mouse.y + rowMenu.openFor(path, isDir) + } + } + onDoubleClicked: (mouse) => { + if (mouse.button !== Qt.LeftButton) return + if (isDir) + fileStore.toggleExpand(index) + else + root.openFileRequested(path) + } + onPressAndHold: (mouse) => { + if (mouse.button !== Qt.LeftButton || rowRect.dragPath.length > 0) return + rowRect.dragging = true + rowRect.dragPath = path + } + } + + // Перетаскивание элемента + Drag.active: rowRect.dragging + Drag.source: rowRect + Drag.hotSpot: Qt.point(width / 2, height / 2) + Drag.keys: [ "application/x-flora" ] + Drag.onDragFinished: rowRect.finishDrag() + Drag.onActiveChanged: if (!Drag.active) rowRect.finishDrag() + + function finishDrag() { + dragging = false + dragPath = "" + } + + // Приём перетаскивания в папку + DropArea { + anchors.fill: parent + onEntered: (drag) => { drag.accepted = rowRect.isDir } + onDropped: (drop) => { + if (!rowRect.isDir || !drop.source || !drop.source.dragPath) return + const err = fileStore.moveItem(drop.source.dragPath, rowRect.path) + if (err.length > 0) root.showStatus(err, false) + } + } + + function iconSymbol(kind) { + switch (kind) { + case "folder": return "\uD83D\uDCC2" + case "folder-empty": return "\uD83D\uDCC1" + case "file-md": return "\uD83D\uDCDD" + case "file-html": return "\uD83C\uDF10" + case "file-pdf": return "\uD83D\uDCD5" + case "file-img": return "\uD83D\uDDBC" + case "file-code": return "\uD83D\uDCD1" + case "file-text": return "\uD83D\uDCD6" + default: return "\uD83D\uDCC4" + } + } + } + } + + Label { + anchors.centerIn: parent + visible: !fileStore.ready + text: qsTr("Откройте хранилище,\nчтобы увидеть файлы") + horizontalAlignment: Text.AlignHCenter + color: "#bbb" + } + } + + // Кнопка хранилища внизу слева + Button { + id: vaultButton + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + text: qsTr("▨ Хранилище…") + onClicked: root.openVaultRequested() + } + + // Статус-строка + property string statusText: "" + Timer { id: statusTimer; interval: 4000; onTriggered: root.statusText = "" } + function showStatus(text, ok) { + root.statusText = (ok ? "✓ " : "✕ ") + text + statusTimer.restart() + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: vaultButton.top + height: 24 + visible: statusText.length > 0 + color: statusText.startsWith("✓") ? "#e5f6e5" : "#fdeaea" + Label { + anchors.fill: parent + anchors.margins: 6 + text: root.statusText + elide: Text.ElideRight + font.pixelSize: 11 + color: statusText.startsWith("✓") ? "#256029" : "#9c1c1c" + } + } + + // ----- Меню «+» (файл/папка) ----- Menu { id: addMenu - x: addButton.x - y: addButton.y + addButton.height - width: 140 - Action { + width: 150 + MenuItem { text: qsTr("Файл") - onTriggered: root.newFileRequested() + onClicked: { + nameDialog.dialogTitle = qsTr("Новый файл") + nameDialog.actionLabel = qsTr("Создать") + nameDialog.initialText = "" + nameDialog.open() + pendingCreate.kind = "file" + } } - Action { + MenuItem { text: qsTr("Папка") - onTriggered: root.newFolderRequested() + onClicked: { + nameDialog.dialogTitle = qsTr("Новая папка") + nameDialog.actionLabel = qsTr("Создать") + nameDialog.initialText = "" + nameDialog.open() + pendingCreate.kind = "folder" + } + } + } + + // ----- Контекстное меню строки ----- + Menu { + id: rowMenu + property string targetPath: "" + property bool targetIsDir: false + + function openFor(path, isDir) { + targetPath = path + targetIsDir = isDir + popup() + } + + MenuItem { + text: qsTr("Открыть") + onClicked: { + if (rowMenu.targetIsDir) { + const i = rowIndexOf(rowMenu.targetPath) + if (i >= 0) fileStore.toggleExpand(i) + } else { + root.openFileRequested(rowMenu.targetPath) + } + } + } + MenuItem { + text: qsTr("Переименовать") + onClicked: { + renameTarget.path = rowMenu.targetPath + nameDialog.dialogTitle = qsTr("Переименовать") + nameDialog.actionLabel = qsTr("Переименовать") + nameDialog.initialText = root.baseName(rowMenu.targetPath) + nameDialog.open() + pendingCreate.kind = "rename" + } + } + MenuItem { + text: qsTr("Удалить") + onClicked: { + const p = rowMenu.targetPath + const isDir = rowMenu.targetIsDir + deleteTarget.path = p + deleteDialog.doubleConfirm = isDir && fileStore.itemHasChildren(rowIndexOf(p)) + if (isDir) + deleteDialog.message = fileStore.itemHasChildren(rowIndexOf(p)) + ? qsTr("Удалить папку «%1» со всем её содержимым?").arg(root.baseName(p)) + : qsTr("Удалить пустую папку «%1»?").arg(root.baseName(p)) + else + deleteDialog.message = qsTr("Удалить файл «%1»?").arg(root.baseName(p)) + deleteDialog.open() + } + } + } + + function rowIndexOf(p) { + const n = fileStore.rowCount() + for (let i = 0; i < n; ++i) + if (fileStore.itemPath(i) === p) return i + return -1 + } + + function parentForCreate() { + if (treeView.currentIndex >= 0 && fileStore.itemIsDir(treeView.currentIndex)) + return fileStore.itemPath(treeView.currentIndex) + return fileStore.vault + } + + property var renameTarget: ({ path: "" }) + property var deleteTarget: ({ path: "" }) + property var pendingCreate: ({ kind: "file" }) + + NameDialog { + id: nameDialog + onConfirmed: (name) => { + let err = "" + if (pendingCreate.kind === "file") { + err = fileStore.createFile(parentForCreate(), name) + } else if (pendingCreate.kind === "folder") { + err = fileStore.createFolder(parentForCreate(), name) + } else if (pendingCreate.kind === "rename") { + err = fileStore.renameItem(renameTarget.path, name) + } + if (err.length > 0) showStatus(err, false) + } + } + + ConfirmDialog { + id: deleteDialog + onConfirmed: { + const err = fileStore.removeItem(deleteTarget.path) + if (err.length > 0) showStatus(err, false) } } } \ No newline at end of file diff --git a/src/app/WindowController.cpp b/src/app/WindowController.cpp new file mode 100644 index 0000000..e524a9a --- /dev/null +++ b/src/app/WindowController.cpp @@ -0,0 +1,88 @@ +#include "app/WindowController.h" + +#include +#include +#include + +#include "core/FileStore.h" + +WindowController::WindowController(QQmlApplicationEngine *engine, + const QString &mainWindowUrl, + QObject *parent) + : QObject(parent) + , m_engine(engine) + , m_mainWindowUrl(mainWindowUrl) +{ +} + +int WindowController::windowCount() const +{ + return m_windows.size(); +} + +QStringList WindowController::openVaults() const +{ + QStringList list; + for (const Window &w : m_windows) + if (!w.vault.isEmpty()) + list.append(w.vault); + return list; +} + +int WindowController::indexOfVault(const QString &vaultPath) const +{ + for (int i = 0; i < m_windows.size(); ++i) + if (m_windows[i].vault == vaultPath) + return i; + return -1; +} + +QObject *WindowController::openWindow(const QString &vaultPath) +{ + if (!m_engine) + return nullptr; + + if (!m_component) { + m_component = new QQmlComponent(m_engine, QUrl(m_mainWindowUrl), this); + if (m_component->isError()) { + qWarning("WindowController: %s", qPrintable(m_component->errorString())); + return nullptr; + } + } + + QQmlContext *ctx = new QQmlContext(m_engine->rootContext(), this); + FileStore *store = new FileStore(ctx); + ctx->setContextProperty("fileStore", store); + + QObject *obj = m_component->create(ctx); + if (!obj) { + qWarning("WindowController: create failed: %s", + qPrintable(m_component->errorString())); + return nullptr; + } + + m_windows.append({vaultPath, obj, store}); + connect(obj, &QObject::destroyed, this, [this, obj]() { + for (int i = 0; i < m_windows.size(); ++i) { + if (m_windows[i].object == obj) { + const QString vault = m_windows[i].vault; + m_windows.removeAt(i); + emit windowsChanged(); + if (!vault.isEmpty()) + emit windowClosed(vault); + break; + } + } + }); + + obj->setProperty("windowVault", vaultPath); + // Закрытие окна уничтожает QML-объект (чтобы последнее окно завершало приложение). + obj->setProperty("destroyOnWindowClose", true); + if (!vaultPath.isEmpty() && QDir(vaultPath).exists()) + store->openVault(vaultPath); + + emit windowsChanged(); + if (!vaultPath.isEmpty()) + emit windowOpened(vaultPath); + return obj; +} \ No newline at end of file diff --git a/src/app/WindowController.h b/src/app/WindowController.h new file mode 100644 index 0000000..154ad39 --- /dev/null +++ b/src/app/WindowController.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +class FileStore; + +// Создание окон приложения (мульти-хранилища: новое хранилище — новое окно). +class WindowController : public QObject +{ + Q_OBJECT + + Q_PROPERTY(int windowCount READ windowCount NOTIFY windowsChanged) + Q_PROPERTY(QStringList openVaults READ openVaults NOTIFY windowsChanged) + +public: + WindowController(QQmlApplicationEngine *engine, const QString &mainWindowUrl, + QObject *parent = nullptr); + + int windowCount() const; + QStringList openVaults() const; + + // Открывает окно с заданным хранилищем. Возвращает nullptr при ошибке. + Q_INVOKABLE QObject *openWindow(const QString &vaultPath); + + Q_INVOKABLE int indexOfVault(const QString &vaultPath) const; + +signals: + void windowsChanged(); + void windowOpened(const QString &vaultPath); + void windowClosed(const QString &vaultPath); + +private: + struct Window { + QString vault; + QPointer object; + FileStore *fileStore = nullptr; + }; + + QQmlApplicationEngine *m_engine; + QString m_mainWindowUrl; + QList m_windows; + QQmlComponent *m_component = nullptr; +}; \ No newline at end of file diff --git a/src/core/FileStore.cpp b/src/core/FileStore.cpp new file mode 100644 index 0000000..97f6c05 --- /dev/null +++ b/src/core/FileStore.cpp @@ -0,0 +1,372 @@ +#include "core/FileStore.h" + +#include +#include +#include + +FileStore::FileStore(QObject *parent) + : QAbstractListModel(parent) +{ + m_debounce.setSingleShot(true); + m_debounce.setInterval(300); + connect(&m_debounce, &QTimer::timeout, this, &FileStore::rebuild); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, + this, &FileStore::scheduleRebuild); +} + +QModelIndex FileStore::index(int row, int column, const QModelIndex &parent) const +{ + if (parent.isValid() || column != 0 || row < 0 || row >= m_rows.size()) + return QModelIndex(); + return createIndex(row, 0); +} + +QModelIndex FileStore::parent(const QModelIndex &child) const +{ + Q_UNUSED(child) + return QModelIndex(); +} + +int FileStore::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_rows.size(); +} + +QVariant FileStore::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) + return QVariant(); + const Row &r = m_rows.at(index.row()); + switch (role) { + case NameRole: return r.name; + case PathRole: return r.path; + case DepthRole: return r.depth; + case IsDirRole: return r.isDir; + case ExpandedRole: return r.expanded; + case HasChildrenRole: return r.hasChildren; + case IconRole: return iconFor(r); + default: return QVariant(); + } +} + +QHash FileStore::roleNames() const +{ + QHash roles; + roles[NameRole] = "display"; + roles[PathRole] = "path"; + roles[DepthRole] = "depth"; + roles[IsDirRole] = "isDir"; + roles[ExpandedRole] = "expanded"; + roles[HasChildrenRole] = "hasChildren"; + roles[IconRole] = "icon"; + return roles; +} + +QString FileStore::iconFor(const Row &r) const +{ + if (r.isDir) + return r.hasChildren ? QStringLiteral("folder") : QStringLiteral("folder-empty"); + const QString ext = QFileInfo(r.name).suffix().toLower(); + if (ext == "md" || ext == "markdown") return QStringLiteral("file-md"); + if (ext == "html" || ext == "htm") return QStringLiteral("file-html"); + if (ext == "pdf") return QStringLiteral("file-pdf"); + if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "gif" + || ext == "svg" || ext == "webp" || ext == "bmp") + return QStringLiteral("file-img"); + if (ext == "txt" || ext == "log") return QStringLiteral("file-text"); + if (ext == "c" || ext == "cpp" || ext == "h" || ext == "hpp" || ext == "py" + || ext == "go" || ext == "rs" || ext == "js" || ext == "ts" || ext == "json" + || ext == "yaml" || ext == "yml" || ext == "sh" || ext == "bash" || ext == "css") + return QStringLiteral("file-code"); + return QStringLiteral("file"); +} + +QString FileStore::openVault(const QString &path) +{ + const QString clean = QDir::cleanPath(path); + if (!QDir(clean).exists()) + return QStringLiteral("Папка «%1» не существует.").arg(clean); + + m_vault = clean; + m_expanded.clear(); + rebuild(); + registerWatcher(); + + emit vaultChanged(); + emit readyChanged(); + return QString(); +} + +void FileStore::rebuild() +{ + if (!m_watcher.directories().isEmpty()) + m_watcher.removePaths(m_watcher.directories()); + QVector rows; + collectDir(m_vault, 0, rows); + + beginResetModel(); + m_rows = std::move(rows); + endResetModel(); + + // Регистрируем все каталоги для слежения + for (const Row &r : m_rows) { + if (r.isDir && !m_watcher.directories().contains(r.path)) + m_watcher.addPath(r.path); + } +} + +void FileStore::collectDir(const QString &dir, int depth, QVector &out) const +{ + QDir d(dir); + d.setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::NoSymLinks + | QDir::System); + const QFileInfoList entries = d.entryInfoList(); + + QVector dirs, files; + for (const QFileInfo &fi : entries) { + const QString path = QDir::cleanPath(fi.absoluteFilePath()); + if (isIgnored(path)) + continue; + Row row; + row.path = path; + row.name = fi.fileName(); + row.depth = depth; + row.isDir = fi.isDir(); + if (row.isDir) { + const int childCount = QDir(path) + .entryList(QDir::AllEntries | QDir::NoDotAndDotDot + | QDir::System) + .count(); + row.hasChildren = childCount > 0; + } + row.expanded = row.isDir && m_expanded.contains(path); + (row.isDir ? dirs : files).append(row); + } + + std::sort(dirs.begin(), dirs.end(), [](const Row &a, const Row &b) { + return a.name.toLower() < b.name.toLower(); + }); + std::sort(files.begin(), files.end(), [](const Row &a, const Row &b) { + return a.name.toLower() < b.name.toLower(); + }); + + for (const Row &r : dirs) { + out.append(r); + if (r.expanded) + collectDir(r.path, depth + 1, out); + } + for (const Row &r : files) + out.append(r); +} + +bool FileStore::isIgnored(const QString &path) const +{ + const QString name = QFileInfo(path).fileName(); + return name == QLatin1String(".git") || name == QLatin1String(".flora") + || name == QLatin1String(".stfolder"); +} + +void FileStore::registerWatcher() +{ + const auto dirs = m_watcher.directories(); + for (const QString &d : dirs) + m_watcher.removePath(d); + QVector dirsToWatch; + for (const Row &r : m_rows) + if (r.isDir) + dirsToWatch.append(r.path); + if (!dirsToWatch.isEmpty()) + m_watcher.addPaths(dirsToWatch); + if (!m_vault.isEmpty() && QDir(m_vault).exists()) + m_watcher.addPath(m_vault); +} + +void FileStore::scheduleRebuild() +{ + if (!m_vault.isEmpty()) + m_debounce.start(); +} + +void FileStore::toggleExpand(int row) +{ + if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir) + return; + const QString path = m_rows[row].path; + if (m_expanded.contains(path)) + m_expanded.remove(path); + else + m_expanded.insert(path); + rebuild(); +} + +void FileStore::expandAll() +{ + for (const Row &r : m_rows) + if (r.isDir) + m_expanded.insert(r.path); + rebuild(); +} + +void FileStore::collapseAll() +{ + m_expanded.clear(); + rebuild(); +} + +int FileStore::rowOf(const QString &path) const +{ + for (int i = 0; i < m_rows.size(); ++i) + if (m_rows[i].path == path) + return i; + return -1; +} + +QString FileStore::validateName(const QString &name) const +{ + const QString n = name.trimmed(); + if (n.isEmpty()) + return QStringLiteral("Имя не может быть пустым."); + if (n.contains('/') || n.contains('\\') || n == "." || n == "..") + return QStringLiteral("Имя содержит недопустимые символы (/, \\)."); + if (n.length() > 200) + return QStringLiteral("Имя слишком длинное."); + return QString(); +} + +QString FileStore::parentDirOfRow(int row) const +{ + if (row <= 0) + return m_vault; + const Row &cur = m_rows[row]; + for (int i = row - 1; i >= 0; --i) + if (m_rows[i].isDir && m_rows[i].depth < cur.depth) + return m_rows[i].path; + return m_vault; +} + +QString FileStore::createFile(const QString &parentPath, const QString &name) +{ + const QString dir = parentPath.isEmpty() ? m_vault : parentPath; + const QString err = validateName(name); + if (!err.isEmpty()) + return err; + const QString path = QDir(dir).filePath(name); + if (QFileInfo::exists(path)) + return QStringLiteral("Файл с таким именем уже существует."); + QFile f(path); + if (!f.open(QIODevice::WriteOnly)) + return QStringLiteral("Не удалось создать файл: %1").arg(path); + f.close(); + rebuild(); + return QString(); +} + +QString FileStore::createFolder(const QString &parentPath, const QString &name) +{ + const QString dir = parentPath.isEmpty() ? m_vault : parentPath; + const QString err = validateName(name); + if (!err.isEmpty()) + return err; + const QString path = QDir(dir).filePath(name); + if (QFileInfo::exists(path)) + return QStringLiteral("Папка с таким именем уже существует."); + if (!QDir().mkpath(path)) + return QStringLiteral("Не удалось создать папку: %1").arg(path); + m_expanded.insert(path); + rebuild(); + return QString(); +} + +QString FileStore::renameItem(const QString &path, const QString &newName) +{ + const QString n = newName.trimmed(); + const QString err = validateName(n); + if (!err.isEmpty()) + return err; + QFileInfo fi(path); + const QString newPath = QDir(fi.absolutePath()).filePath(n); + if (path == newPath) + return QString(); + if (QFileInfo::exists(newPath)) + return QStringLiteral("Имя уже занято."); + if (!QFile::rename(path, newPath) && !QDir().rename(path, newPath)) + return QStringLiteral("Не удалось переименовать: %1").arg(path); + if (fi.isDir()) { + const QString oldAbs = QDir::cleanPath(path); + const QString newAbs = QDir::cleanPath(newPath); + if (m_expanded.remove(oldAbs)) + m_expanded.insert(newAbs); + } + rebuild(); + return QString(); +} + +QString FileStore::removeItem(const QString &path) +{ + QFileInfo fi(path); + if (fi.isDir()) { + QDir d(path); + if (!d.removeRecursively()) + return QStringLiteral("Не удалось удалить папку «%1».").arg(path); + m_expanded.remove(QDir::cleanPath(path)); + } else { + if (!QFile::remove(path)) + return QStringLiteral("Не удалось удалить файл «%1».").arg(path); + } + rebuild(); + return QString(); +} + +QString FileStore::moveItem(const QString &srcPath, const QString &destDir) +{ + if (srcPath.isEmpty() || destDir.isEmpty()) + return QStringLiteral("Не указаны пути."); + const QString src = QDir::cleanPath(srcPath); + const QString dstDir = QDir::cleanPath(destDir); + if (src == dstDir || QFileInfo(dstDir).filePath() == QFileInfo(src).absolutePath()) + return QString(); + + // Запрещаем перемещение папки в саму себя + if (src.startsWith(dstDir + '/')) + return QStringLiteral("Нельзя переместить папку в саму себя."); + + const QFileInfo fi(src); + const QString newPath = QDir(dstDir).filePath(fi.fileName()); + if (QFileInfo::exists(newPath)) + return QStringLiteral("Элемент с таким именем уже есть в папке назначения."); + + bool ok = false; + if (fi.isDir()) + ok = QDir().rename(src, newPath); + else + ok = QFile::rename(src, newPath); + if (!ok) + return QStringLiteral("Не удалось переместить «%1».").arg(src); + + rebuild(); + return QString(); +} + +QString FileStore::itemName(int row) const +{ + if (row < 0 || row >= m_rows.size()) + return QString(); + return m_rows[row].name; +} + +QString FileStore::itemPath(int row) const +{ + if (row < 0 || row >= m_rows.size()) + return QString(); + return m_rows[row].path; +} + +bool FileStore::itemIsDir(int row) const +{ + return row >= 0 && row < m_rows.size() && m_rows[row].isDir; +} + +bool FileStore::itemHasChildren(int row) const +{ + return row >= 0 && row < m_rows.size() && m_rows[row].hasChildren; +} \ No newline at end of file diff --git a/src/core/FileStore.h b/src/core/FileStore.h new file mode 100644 index 0000000..0d98c4f --- /dev/null +++ b/src/core/FileStore.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include + +// Дерево хранилища в виде плоского списка «видимых строк» (раскрытые папки). +// Даёт полный контроль над иконками, стрелками, отступами, ПКМ и drag&drop. +class FileStore : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(QString vault READ vault NOTIFY vaultChanged) + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + +public: + enum Roles { + NameRole = Qt::DisplayRole, + PathRole = Qt::UserRole + 1, + DepthRole, + IsDirRole, + ExpandedRole, + HasChildrenRole, + IconRole + }; + Q_ENUM(Roles) + + explicit FileStore(QObject *parent = nullptr); + + QString vault() const { return m_vault; } + bool ready() const { return !m_vault.isEmpty(); } + + QModelIndex index(int row, int column = 0, + const QModelIndex &parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex &child) const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + // Открывает хранилище и строит дерево. Возвращает текст ошибки или "". + Q_INVOKABLE QString openVault(const QString &path); + + Q_INVOKABLE void toggleExpand(int row); + Q_INVOKABLE void expandAll(); + Q_INVOKABLE void collapseAll(); + + Q_INVOKABLE QString createFile(const QString &parentPath, const QString &name); + Q_INVOKABLE QString createFolder(const QString &parentPath, const QString &name); + Q_INVOKABLE QString renameItem(const QString &path, const QString &newName); + Q_INVOKABLE QString removeItem(const QString &path); + Q_INVOKABLE QString moveItem(const QString &srcPath, const QString &destDir); + + Q_INVOKABLE QString itemName(int row) const; + Q_INVOKABLE QString itemPath(int row) const; + Q_INVOKABLE bool itemIsDir(int row) const; + Q_INVOKABLE bool itemHasChildren(int row) const; + +signals: + void vaultChanged(); + void readyChanged(); + void fileActivated(const QString &path); + void statusMessage(const QString &message); + +private: + struct Row { + QString path; + QString name; + int depth = 0; + bool isDir = false; + bool expanded = false; + bool hasChildren = false; + bool ignored = false; // скрытые/служебные (.git, .flora) + }; + + void rebuild(); + void collectDir(const QString &dir, int depth, QVector &out) const; + bool isIgnored(const QString &path) const; + int rowOf(const QString &path) const; + void registerWatcher(); + void scheduleRebuild(); + +QString validateName(const QString &name) const; + QString parentDirOfRow(int row) const; + QString iconFor(const Row &row) const; + + QString m_vault; + QVector m_rows; + QSet m_expanded; + QFileSystemWatcher m_watcher; + QTimer m_debounce; +}; \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 9be2f04..5ca739b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include #include "app/VaultManager.h" +#include "app/WindowController.h" #include "core/SettingsStore.h" #include "git/GitManager.h" #include "sync/SyncManager.h" @@ -42,7 +43,14 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("gitManager", &gitManager); engine.rootContext()->setContextProperty("syncManager", &syncManager); engine.rootContext()->setContextProperty("vaultManager", &vaultManager); - engine.loadFromModule("Flora", "MainWindow"); + + WindowController windowController( + &engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml")); + engine.rootContext()->setContextProperty("windowController", &windowController); + + // Основное окно = последнее (основное) хранилище. Если хранилищ нет — + // откроется мастер первого запуска. + windowController.openWindow(settingsStore.currentVault()); if (engine.rootObjects().isEmpty()) return -1; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2564506..3ec7290 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,4 +34,14 @@ 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) \ No newline at end of file +add_test(NAME flora_vault_test COMMAND flora_vault_test) + +# Тест FileStore (файловое дерево хранилища). +add_executable(flora_filestore_test filestore_test.cpp + ../src/core/FileStore.cpp + ../src/core/FileStore.h +) +target_include_directories(flora_filestore_test PRIVATE ../src) +target_link_libraries(flora_filestore_test PRIVATE + Qt6::Core) +add_test(NAME flora_filestore_test COMMAND flora_filestore_test) \ No newline at end of file diff --git a/tests/filestore_test.cpp b/tests/filestore_test.cpp new file mode 100644 index 0000000..5c69329 --- /dev/null +++ b/tests/filestore_test.cpp @@ -0,0 +1,134 @@ +// Тест FileStore (Фаза 3): дерево хранилища, создание/переименование/ +// удаление/перемещение, раскрытие папок, иконки, валидация имён. +#include +#include +#include +#include +#include + +#include "core/FileStore.h" + +static int fail(const char *msg, const QString &detail = QString()) +{ + std::fprintf(stderr, "FAIL: %s %s\n", msg, qPrintable(detail)); + return 1; +} + +static int rowOf(FileStore &fs, const QString &path) +{ + const int n = fs.rowCount(); + for (int i = 0; i < n; ++i) + if (fs.itemPath(i) == path) + return i; + return -1; +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + + QTemporaryDir dir; + if (!dir.isValid()) + return fail("temp dir"); + + FileStore fs; + const QString vault = dir.path(); + QString err = fs.openVault(vault); + if (!err.isEmpty()) + return fail("openVault", err); + + // --- создание --- + err = fs.createFile(vault, "note.md"); + if (!err.isEmpty()) + return fail("createFile", err); + err = fs.createFolder(vault, "sub"); + if (!err.isEmpty()) + return fail("createFolder", err); + err = fs.createFolder(dir.path() + "/sub", "deep"); + if (!err.isEmpty()) + return fail("createFolder deep", err); + err = fs.createFile(dir.path() + "/sub", "inner.txt"); + if (!err.isEmpty()) + return fail("createFile inner", err); + + const QString note = dir.path() + "/note.md"; + const QString sub = dir.path() + "/sub"; + const QString deep = dir.path() + "/sub/deep"; + const QString inner = dir.path() + "/sub/inner.txt"; + + // Папка sub содержит children; deep пустая + if (rowOf(fs, sub) < 0) + return fail("sub не в дереве"); + + // --- иконки (sub автоматически раскрыта после создания) --- + { + const QString subIcon = fs.data(fs.index(rowOf(fs, sub)), FileStore::IconRole).toString(); + if (subIcon != "folder") + return fail("иконка sub", subIcon); + const int ri = rowOf(fs, deep); + if (ri < 0) + return fail("deep не видна (авто-раскрытие)", QString()); + const QString deepIcon = fs.data(fs.index(ri), FileStore::IconRole).toString(); + if (deepIcon != "folder-empty") + return fail("иконка deep", deepIcon); + } + + // --- раскрытие/сворачивание --- + { + const int subRow = rowOf(fs, sub); + if (subRow < 0) + return fail("sub отсутствует"); + if (rowOf(fs, inner) < 0 || rowOf(fs, deep) < 0) + return fail("вложенное не видно при раскрытой sub"); + + fs.toggleExpand(subRow); // сворачиваем + if (rowOf(fs, inner) >= 0 || rowOf(fs, deep) >= 0) + return fail("свёрнутая sub показывает вложенное"); + + fs.toggleExpand(rowOf(fs, sub)); // раскрываем снова + if (rowOf(fs, inner) < 0 || rowOf(fs, deep) < 0) + return fail("повторное раскрытие не работает"); + } + + // --- переименование --- + err = fs.renameItem(deep, "deep2"); + if (!err.isEmpty()) + return fail("renameItem", err); + if (!QDir(dir.path() + "/sub/deep2").exists()) + return fail("rename: папка не переименована"); + + // --- перемещение --- + err = fs.moveItem(note, sub); + if (!err.isEmpty()) + return fail("moveItem", err); + if (!QFile::exists(dir.path() + "/sub/note.md")) + return fail("move: файл не перемещён"); + + // --- удаление --- + err = fs.removeItem(dir.path() + "/sub/inner.txt"); + if (!err.isEmpty()) + return fail("removeItem file", err); + err = fs.removeItem(dir.path() + "/sub/deep2"); + if (!err.isEmpty()) + return fail("removeItem dir", err); + err = fs.removeItem(sub); + if (!err.isEmpty()) + return fail("removeItem sub", err); + if (QDir(sub).exists()) + return fail("папка осталась после удаления"); + + // --- валидация --- + if (fs.createFile(vault, "bad/name").isEmpty()) + return fail("имя с '/' принято"); + if (fs.createFile(vault, "").isEmpty()) + return fail("пустое имя принято"); + if (fs.renameItem(dir.path() + "/note.md", "../escape").isEmpty()) + return fail("переименование с '..' принято"); + + // --- невалидное хранилище --- + if (fs.openVault("/нет/такого/пути_xyz").isEmpty()) + return fail("openVault неверный путь"); + + std::printf("FILESTORE TEST OK\n"); + return 0; +} \ No newline at end of file