Фазы 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
+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;