Фазы 2-3: мульти-окна (WindowController, per-window FileStore), файловое дерево (FileStore+FileTreePanel, иконки/раскрытие/ПКМ/+drag&drop/подтверждения), NameDialog/ConfirmDialog; тест filestore (13/13)

This commit is contained in:
Flora (opencode)
2026-08-17 01:02:05 +03:00
parent ed633ae036
commit c740c9a756
12 changed files with 1305 additions and 76 deletions
+6
View File
@@ -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
)
# --- Тесты ---
+49 -15
View File
@@ -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()
onFinished: {
if (fileStore.ready) {
// Добавлено новое хранилище при уже открытом — новое окно.
windowController.openWindow(welcomeWizard.vaultPath)
} else {
// Первый запуск: применяем хранилище к этому окну.
fileStore.openVault(welcomeWizard.vaultPath)
root_status("Хранилище открыто")
}
}
Component.onCompleted: {
if (vaultManager.vaults.length === 0)
welcomeWizard.open()
}
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 {
+76
View File
@@ -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()
}
}
}
}
}
+76
View File
@@ -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()
}
}
+313 -26
View File
@@ -2,28 +2,33 @@ 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
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 36
color: "whitesmoke"
RowLayout {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
spacing: 4
Button {
@@ -31,13 +36,14 @@ Item {
text: "+"
implicitWidth: 28
implicitHeight: 26
enabled: fileStore.ready
ToolTip.visible: hovered
ToolTip.text: qsTr("Создать")
ToolTip.text: qsTr("Создать файл или папку")
onClicked: addMenu.popup()
}
Label {
text: qsTr("Файлы")
text: fileStore.ready ? qsTr("Файлы") : qsTr("Хранилище не выбрано")
Layout.fillWidth: true
elide: Text.ElideRight
color: "#555"
@@ -45,14 +51,167 @@ Item {
}
}
// Место файлового дерева (Фаза 3)
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
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
text: qsTr("Дерево файлов\n(Фаза 3)")
visible: !fileStore.ready
text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
horizontalAlignment: Text.AlignHCenter
color: "#bbb"
}
@@ -61,25 +220,153 @@ Item {
// Кнопка хранилища внизу слева
Button {
id: vaultButton
Layout.fillWidth: true
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)
}
}
}
+88
View File
@@ -0,0 +1,88 @@
#include "app/WindowController.h"
#include <QDir>
#include <QQmlComponent>
#include <QQmlContext>
#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;
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <QObject>
#include <QQmlApplicationEngine>
#include <QPointer>
#include <QList>
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<QObject> object;
FileStore *fileStore = nullptr;
};
QQmlApplicationEngine *m_engine;
QString m_mainWindowUrl;
QList<Window> m_windows;
QQmlComponent *m_component = nullptr;
};
+372
View File
@@ -0,0 +1,372 @@
#include "core/FileStore.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
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<int, QByteArray> FileStore::roleNames() const
{
QHash<int, QByteArray> 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<Row> 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<Row> &out) const
{
QDir d(dir);
d.setFilter(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::NoSymLinks
| QDir::System);
const QFileInfoList entries = d.entryInfoList();
QVector<Row> 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<QString> 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;
}
+92
View File
@@ -0,0 +1,92 @@
#pragma once
#include <QAbstractListModel>
#include <QFileSystemWatcher>
#include <QTimer>
#include <QSet>
// Дерево хранилища в виде плоского списка «видимых строк» (раскрытые папки).
// Даёт полный контроль над иконками, стрелками, отступами, ПКМ и 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<int, QByteArray> 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<Row> &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<Row> m_rows;
QSet<QString> m_expanded;
QFileSystemWatcher m_watcher;
QTimer m_debounce;
};
+9 -1
View File
@@ -4,6 +4,7 @@
#include <QCommandLineParser>
#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;
+10
View File
@@ -35,3 +35,13 @@ target_link_libraries(flora_vault_test PRIVATE
Qt6::Core
Flora_libgit2_static)
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)
+134
View File
@@ -0,0 +1,134 @@
// Тест FileStore (Фаза 3): дерево хранилища, создание/переименование/
// удаление/перемещение, раскрытие папок, иконки, валидация имён.
#include <QCoreApplication>
#include <QTemporaryDir>
#include <QDir>
#include <QFile>
#include <cstdio>
#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;
}