Фазы 2-3: мульти-окна (WindowController, per-window FileStore), файловое дерево (FileStore+FileTreePanel, иконки/раскрытие/ПКМ/+drag&drop/подтверждения), NameDialog/ConfirmDialog; тест filestore (13/13)
This commit is contained in:
@@ -36,6 +36,8 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/third_party/deps.cmake)
|
|||||||
|
|
||||||
set(FLORA_HEADERS
|
set(FLORA_HEADERS
|
||||||
src/app/VaultManager.h
|
src/app/VaultManager.h
|
||||||
|
src/app/WindowController.h
|
||||||
|
src/core/FileStore.h
|
||||||
src/core/SettingsStore.h
|
src/core/SettingsStore.h
|
||||||
src/git/GitManager.h
|
src/git/GitManager.h
|
||||||
src/sync/SyncManager.h
|
src/sync/SyncManager.h
|
||||||
@@ -43,6 +45,8 @@ set(FLORA_HEADERS
|
|||||||
|
|
||||||
set(FLORA_SOURCES
|
set(FLORA_SOURCES
|
||||||
src/app/VaultManager.cpp
|
src/app/VaultManager.cpp
|
||||||
|
src/app/WindowController.cpp
|
||||||
|
src/core/FileStore.cpp
|
||||||
src/core/SettingsStore.cpp
|
src/core/SettingsStore.cpp
|
||||||
src/git/GitManager.cpp
|
src/git/GitManager.cpp
|
||||||
src/sync/SyncManager.cpp
|
src/sync/SyncManager.cpp
|
||||||
@@ -96,6 +100,8 @@ qt_add_qml_module(flora
|
|||||||
qml/panels/RightPanel.qml
|
qml/panels/RightPanel.qml
|
||||||
qml/wizard/WelcomeWizard.qml
|
qml/wizard/WelcomeWizard.qml
|
||||||
qml/dialogs/ProviderSetupGuide.qml
|
qml/dialogs/ProviderSetupGuide.qml
|
||||||
|
qml/dialogs/NameDialog.qml
|
||||||
|
qml/dialogs/ConfirmDialog.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Тесты ---
|
# --- Тесты ---
|
||||||
|
|||||||
+50
-16
@@ -3,7 +3,7 @@ import QtQuick.Controls
|
|||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
|
||||||
// Главное окно: меню вверху, три панели (дерево / редактор / правая).
|
// Главное окно: меню вверху, три панели (дерево / редактор / правая).
|
||||||
// Фазы 0-1: каркас + мастер первого запуска. Контент панелей — в следующих фазах.
|
// Мульти-хранилища: каждое окно привязано к своему хранилищу (windowVault).
|
||||||
ApplicationWindow {
|
ApplicationWindow {
|
||||||
id: window
|
id: window
|
||||||
width: 1280
|
width: 1280
|
||||||
@@ -12,16 +12,29 @@ ApplicationWindow {
|
|||||||
title: "Flora"
|
title: "Flora"
|
||||||
color: "white"
|
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 {
|
WelcomeWizard {
|
||||||
id: welcomeWizard
|
id: welcomeWizard
|
||||||
firstRun: vaultManager.vaults.length === 0
|
firstRun: vaultManager.vaults.length === 0
|
||||||
onFinished: vaultSwitcher.close()
|
onFinished: {
|
||||||
}
|
if (fileStore.ready) {
|
||||||
|
// Добавлено новое хранилище при уже открытом — новое окно.
|
||||||
Component.onCompleted: {
|
windowController.openWindow(welcomeWizard.vaultPath)
|
||||||
if (vaultManager.vaults.length === 0)
|
} else {
|
||||||
welcomeWizard.open()
|
// Первый запуск: применяем хранилище к этому окну.
|
||||||
|
fileStore.openVault(welcomeWizard.vaultPath)
|
||||||
|
root_status("Хранилище открыто")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openWizard() {
|
function openWizard() {
|
||||||
@@ -29,6 +42,21 @@ ApplicationWindow {
|
|||||||
welcomeWizard.open()
|
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 {
|
menuBar: MenuBar {
|
||||||
Menu {
|
Menu {
|
||||||
title: qsTr("&Файл")
|
title: qsTr("&Файл")
|
||||||
@@ -77,7 +105,7 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Переключатель хранилищ (кнопка внизу слева, в FileTreePanel)
|
// Переключатель хранилищ (кнопка внизу слева)
|
||||||
Popup {
|
Popup {
|
||||||
id: vaultSwitcher
|
id: vaultSwitcher
|
||||||
x: 12
|
x: 12
|
||||||
@@ -104,10 +132,13 @@ ApplicationWindow {
|
|||||||
delegate: ItemDelegate {
|
delegate: ItemDelegate {
|
||||||
width: ListView.view.width
|
width: ListView.view.width
|
||||||
text: modelData
|
text: modelData
|
||||||
highlighted: modelData === vaultManager.currentVault
|
highlighted: modelData === window.activeVault
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (modelData !== vaultManager.currentVault) {
|
if (modelData === window.activeVault) {
|
||||||
vaultManager.selectVault(modelData)
|
vaultSwitcher.close()
|
||||||
|
} else {
|
||||||
|
windowController.openWindow(modelData)
|
||||||
|
vaultSwitcher.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,8 +151,8 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: qsTr("Текущее: %1").arg(vaultManager.currentVault.length > 0
|
text: qsTr("Текущее: %1").arg(window.activeVault.length > 0
|
||||||
? vaultManager.currentVault : "—")
|
? window.activeVault : "—")
|
||||||
color: "#666"
|
color: "#666"
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -144,9 +175,12 @@ ApplicationWindow {
|
|||||||
SplitView.preferredWidth: window.width * 0.15
|
SplitView.preferredWidth: window.width * 0.15
|
||||||
SplitView.minimumWidth: 120
|
SplitView.minimumWidth: 120
|
||||||
SplitView.maximumWidth: 500
|
SplitView.maximumWidth: 500
|
||||||
|
|
||||||
onOpenVaultRequested: vaultSwitcher.open()
|
onOpenVaultRequested: vaultSwitcher.open()
|
||||||
onNewFileRequested: console.log("Новый файл (Фаза 3)")
|
onOpenFileRequested: (path) => {
|
||||||
onNewFolderRequested: console.log("Новая папка (Фаза 3)")
|
// Фаза 4: открытие вкладки в редакторе. Пока — лог.
|
||||||
|
console.log("Открыть файл: " + path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorPanel {
|
EditorPanel {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+345
-58
@@ -2,84 +2,371 @@ import QtQuick
|
|||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
|
||||||
// Левая панель: файловое дерево. Фаза 0 — каркас:
|
// Левая панель: файловое дерево хранилища (Фаза 3).
|
||||||
// кнопка «+» над деревом с меню-бутербродом («Файл»/«Папка»),
|
// Создание/переименование/удаление/перемещение, иконки, стрелки раскрытия,
|
||||||
// кнопка хранилища внизу слева. Само дерево и операции — Фаза 3.
|
// отступы подпапок, ПКМ, drag&drop. Кнопка хранилища внизу слева.
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
signal openVaultRequested()
|
signal openVaultRequested()
|
||||||
signal newFileRequested()
|
signal openFileRequested(string path)
|
||||||
signal newFolderRequested()
|
|
||||||
|
|
||||||
ColumnLayout {
|
function baseName(p) {
|
||||||
anchors.fill: parent
|
const idx = p.lastIndexOf("/")
|
||||||
spacing: 0
|
return idx >= 0 ? p.substring(idx + 1) : p
|
||||||
|
}
|
||||||
|
|
||||||
// Заголовок панели: кнопка «+» + название
|
// ----- Заголовок: «+» + название -----
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
id: header
|
||||||
implicitHeight: 36
|
anchors.top: parent.top
|
||||||
color: "whitesmoke"
|
anchors.left: parent.left
|
||||||
RowLayout {
|
anchors.right: parent.right
|
||||||
anchors.fill: parent
|
height: 36
|
||||||
anchors.leftMargin: 4
|
color: "whitesmoke"
|
||||||
spacing: 4
|
|
||||||
|
|
||||||
Button {
|
RowLayout {
|
||||||
id: addButton
|
anchors.fill: parent
|
||||||
text: "+"
|
anchors.leftMargin: 4
|
||||||
implicitWidth: 28
|
anchors.rightMargin: 4
|
||||||
implicitHeight: 26
|
spacing: 4
|
||||||
ToolTip.visible: hovered
|
|
||||||
ToolTip.text: qsTr("Создать")
|
|
||||||
onClicked: addMenu.popup()
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
Button {
|
||||||
text: qsTr("Файлы")
|
id: addButton
|
||||||
Layout.fillWidth: true
|
text: "+"
|
||||||
elide: Text.ElideRight
|
implicitWidth: 28
|
||||||
color: "#555"
|
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 {
|
Label {
|
||||||
anchors.centerIn: parent
|
text: fileStore.ready ? qsTr("Файлы") : qsTr("Хранилище не выбрано")
|
||||||
text: qsTr("Дерево файлов\n(Фаза 3)")
|
Layout.fillWidth: true
|
||||||
horizontalAlignment: Text.AlignHCenter
|
elide: Text.ElideRight
|
||||||
color: "#bbb"
|
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 {
|
Menu {
|
||||||
id: addMenu
|
id: addMenu
|
||||||
x: addButton.x
|
width: 150
|
||||||
y: addButton.y + addButton.height
|
MenuItem {
|
||||||
width: 140
|
|
||||||
Action {
|
|
||||||
text: qsTr("Файл")
|
text: qsTr("Файл")
|
||||||
onTriggered: root.newFileRequested()
|
onClicked: {
|
||||||
|
nameDialog.dialogTitle = qsTr("Новый файл")
|
||||||
|
nameDialog.actionLabel = qsTr("Создать")
|
||||||
|
nameDialog.initialText = ""
|
||||||
|
nameDialog.open()
|
||||||
|
pendingCreate.kind = "file"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Action {
|
MenuItem {
|
||||||
text: qsTr("Папка")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -4,6 +4,7 @@
|
|||||||
#include <QCommandLineParser>
|
#include <QCommandLineParser>
|
||||||
|
|
||||||
#include "app/VaultManager.h"
|
#include "app/VaultManager.h"
|
||||||
|
#include "app/WindowController.h"
|
||||||
#include "core/SettingsStore.h"
|
#include "core/SettingsStore.h"
|
||||||
#include "git/GitManager.h"
|
#include "git/GitManager.h"
|
||||||
#include "sync/SyncManager.h"
|
#include "sync/SyncManager.h"
|
||||||
@@ -42,7 +43,14 @@ int main(int argc, char *argv[])
|
|||||||
engine.rootContext()->setContextProperty("gitManager", &gitManager);
|
engine.rootContext()->setContextProperty("gitManager", &gitManager);
|
||||||
engine.rootContext()->setContextProperty("syncManager", &syncManager);
|
engine.rootContext()->setContextProperty("syncManager", &syncManager);
|
||||||
engine.rootContext()->setContextProperty("vaultManager", &vaultManager);
|
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())
|
if (engine.rootObjects().isEmpty())
|
||||||
return -1;
|
return -1;
|
||||||
|
|||||||
+11
-1
@@ -34,4 +34,14 @@ target_include_directories(flora_vault_test PRIVATE ../src)
|
|||||||
target_link_libraries(flora_vault_test PRIVATE
|
target_link_libraries(flora_vault_test PRIVATE
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
Flora_libgit2_static)
|
Flora_libgit2_static)
|
||||||
add_test(NAME flora_vault_test COMMAND flora_vault_test)
|
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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user