Prod: полный аудит и стабилизация — исправлены критические баги, расширено покрытие тестами, подготовлен бандл

- FileStore: защита от выхода за пределы хранилища (isInsideVault), валидация имён (запрет скрытых, зарезервированных, управляющих символов), проверка destDir, лимит watcher, dailyNote валидация, openVault проверка isDir
- DocumentManager: лимит 10МБ, кламп автосейва 0.5-120с, защита от OOM, бинарный детект, проверка путей
- VaultIndex: исправлен парсинг тегов (заголовки, инлайн-код), case-insensitive поиск, дедупликация рёбер, защита от traversal, лимит глубины, проверка vaultExists
- MdParser: блокировка всех on* событий, javascript:/data:, esc кавычек, MIME fallback, normalizeLang расширен (c/cpp/py/js и кириллица), wiki глобальный поиск, image лимит 3МБ, защита clipboard
- GitManager: commitAll с GIT_INDEX_ADD_DEFAULT и поддержкой пустых коммитов/удалений, fileGitStatus с cleanPath и проверкой префикса, repoExists/hasCommits с cleanPath, initRepo валидация
- SettingsStore: encodeKey/encodeRel для QSettings Ini, flat хранение viewMode, sync после записи, кламп ширин панелей, миграция старых ключей
- SyncManager: счётчик busy, обработка httpStatus при network error, валидация токена/провайдера/URL, GitHub Enterprise URL, таймаут
- VaultManager: валидация путей (корень, файл vs папка, права), проверка вложенности
- WindowController: per-window FileStore/DocumentManager/VaultIndex, cleanPath, закрытие окна через QQuickWindow::closing, обработка ошибок компонента
- QML: TableEditor пустая строка, WelcomeWizard QUrl парсинг, FileTree фильтр, GraphView лимит 600 узлов, RightPanel debounce rebuild, Theme singleton, MonthCalendar и др.
- Тесты: добавлены flora_settings_test, flora_sync_test, flora_extended_test, flora_integration_test — покрытие всех модулей, 20/20 зелёных
- Сборка: обновлён Flora.app (4.6М бинарь -> 173М бандл, ad-hoc подпись, проверен offscreen запуск, версия 0.0.1 + cmark 0.29 + libgit2 1.9)
- .gitignore: добавлены Testing/, coverage, gcda/gcov, __pycache__, .tmp, test-vault и др.
This commit is contained in:
2026-09-01 19:25:07 +03:00
parent 0858b48cb3
commit 95c03040c7
41 changed files with 3868 additions and 1098 deletions
+200 -161
View File
@@ -1,10 +1,8 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
// Левая панель: файловое дерево хранилища (Фаза 3).
// Создание/переименование/удаление/перемещение, иконки, стрелки раскрытия,
// отступы подпапок, ПКМ, drag&drop. Кнопка хранилища внизу слева.
Item {
id: root
@@ -16,26 +14,33 @@ Item {
return idx >= 0 ? p.substring(idx + 1) : p
}
// ----- Заголовок: «+» + название -----
property string filterText: ""
// ----- Заголовок -----
Rectangle {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 36
color: "whitesmoke"
height: 44
color: Theme.headerBg
border.color: Theme.divider
border.width: 0; // нижняя линия отдельно
Rectangle { anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right; height: 1; color: Theme.divider }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
spacing: 4
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.topMargin: 6
anchors.bottomMargin: 6
spacing: 6
Button {
id: addButton
text: "+"
implicitWidth: 28
implicitHeight: 26
implicitWidth: 32
implicitHeight: 28
enabled: fileStore.ready
ToolTip.visible: hovered
ToolTip.text: qsTr("Создать файл или папку")
@@ -46,38 +51,99 @@ Item {
text: fileStore.ready ? qsTr("Файлы") : qsTr("Хранилище не выбрано")
Layout.fillWidth: true
elide: Text.ElideRight
color: "#555"
color: Theme.textSecondary
font.bold: true
font.pixelSize: 13
}
ToolButton {
id: searchToggle
checkable: true
text: "\u2315"
font.pixelSize: 14
ToolTip.text: qsTr("Поиск")
ToolTip.visible: hovered
}
ToolButton {
text: "\u2630"
font.pixelSize: 12
ToolTip.text: qsTr("Развернуть / свернуть")
ToolTip.visible: hovered
onClicked: expandMenu.popup()
}
}
}
// ----- Поле поиска -----
Rectangle {
id: searchBar
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: vaultButton.top
color: "#fafafa"
height: searchToggle.checked ? 38 : 0
visible: height > 0
clip: true
color: Theme.bgAlt
border.color: Theme.divider
Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
// ПКМ по пустому месту дерева — то же меню, что и «+»
RowLayout {
anchors.fill: parent
anchors.margins: 6
spacing: 6
TextField {
id: searchField
Layout.fillWidth: true
placeholderText: qsTr("Поиск файлов…")
font.pixelSize: 12
onTextChanged: root.filterText = text.trim()
Keys.onEscapePressed: { clear(); searchToggle.checked = false }
function clear() { text = ""; root.filterText = "" }
}
ToolButton {
text: "\u2715"
visible: searchField.text.length > 0
onClicked: searchField.clear()
}
}
}
// Меню развернуть/свернуть
Menu {
id: expandMenu
MenuItem { text: qsTr("Развернуть всё"); onClicked: fileStore.expandAll() }
MenuItem { text: qsTr("Свернуть всё"); onClicked: fileStore.collapseAll() }
}
Rectangle {
id: treeContainer
anchors.top: searchBar.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: vaultButton.top
color: Theme.bg
// ПКМ по пустому месту
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton)
addMenu.x = mouse.x; addMenu.y = mouse.y; addMenu.popup()
addMenu.popup()
}
}
ListView {
id: treeView
anchors.fill: parent
anchors.topMargin: 4
clip: true
model: fileStore
currentIndex: -1
focus: true
ScrollBar.vertical: ScrollBar {}
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
spacing: 1
// Перемещение на пустое место = в корень хранилища
DropArea {
anchors.fill: parent
onDropped: (drop) => {
@@ -99,31 +165,46 @@ Item {
required property string icon
required property int index
// Фильтрация: показываем только совпадающие; папки с детьми не показываем автоматически чтобы не захламлять
readonly property bool matchesFilter: root.filterText.length === 0 || display.toLowerCase().indexOf(root.filterText.toLowerCase()) !== -1
visible: matchesFilter
height: matchesFilter ? 32 : 0
width: treeView.width
height: 30
color: rowRect.dragging ? "#d6e4ff"
: (treeView.currentIndex === index ? "#e6f0ff"
: (index % 2 === 0 ? "#fafafa" : "#f4f4f4"))
color: {
if (dragging) return Theme.accentMuted
if (treeView.currentIndex === index) return Theme.surfaceSelected
if (hover.hovered) return Theme.surfaceHover
return "transparent"
}
border.color: treeView.currentIndex === index ? Theme.accent : "transparent"
border.width: treeView.currentIndex === index ? 1 : 0
radius: 4
property string dragPath: ""
property bool dragging: false
RowLayout {
anchors.fill: parent
anchors.leftMargin: 4 + depth * 16
anchors.rightMargin: 6
spacing: 4
anchors.leftMargin: 6 + depth * 14
anchors.rightMargin: 8
spacing: 6
Text {
text: isDir ? (expanded ? "\u25BE" : "\u25B8") : ""
font.pixelSize: 12
Layout.preferredWidth: isDir ? 16 : 8
horizontalAlignment: Text.AlignHCenter
color: "#666"
opacity: hasChildren ? 1 : 0.35
// Стрелка
Item {
Layout.preferredWidth: isDir ? 16 : 0
Layout.preferredHeight: 16
visible: isDir
Text {
anchors.centerIn: parent
text: expanded ? "\u25BE" : "\u25B8"
font.pixelSize: 12
color: Theme.textMuted
opacity: hasChildren ? 1 : 0.25
}
MouseArea {
anchors.fill: parent
visible: hasChildren
enabled: hasChildren
cursorShape: Qt.PointingHandCursor
onClicked: fileStore.toggleExpand(index)
}
}
@@ -137,30 +218,45 @@ Item {
text: display
Layout.fillWidth: true
elide: Text.ElideRight
color: isDir ? "#1a1a1a" : "#333"
color: isDir ? Theme.text : Theme.textSecondary
font.bold: isDir
font.pixelSize: 12
}
// быстрый бейдж для папок с детьми
Rectangle {
visible: isDir && hasChildren
implicitWidth: countLabel.implicitWidth + 8
implicitHeight: 16
radius: 8
color: Theme.tagChipBg
Label {
id: countLabel
anchors.centerIn: parent
text: "" // можно показать число детей, но пока стрелка
font.pixelSize: 9
color: Theme.textMuted
visible: false
}
}
}
HoverHandler { id: hover }
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
onPressed: {
treeView.currentIndex = index
}
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)
if (isDir) fileStore.toggleExpand(index)
else root.openFileRequested(path)
}
onPressAndHold: (mouse) => {
if (mouse.button !== Qt.LeftButton || rowRect.dragPath.length > 0) return
@@ -169,20 +265,15 @@ Item {
}
}
// Перетаскивание элемента
Drag.active: rowRect.dragging
Drag.source: rowRect
Drag.hotSpot: Qt.point(width / 2, height / 2)
Drag.keys: [ "application/x-flora" ]
Drag.keys: ["application/x-flora"]
Drag.onDragFinished: rowRect.finishDrag()
Drag.onActiveChanged: if (!Drag.active) rowRect.finishDrag()
function finishDrag() {
dragging = false
dragPath = ""
}
function finishDrag() { dragging = false; dragPath = "" }
// Приём перетаскивания в папку
DropArea {
anchors.fill: parent
onEntered: (drag) => { drag.accepted = rowRect.isDir }
@@ -209,88 +300,92 @@ Item {
}
}
Label {
Column {
anchors.centerIn: parent
visible: !fileStore.ready
text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
horizontalAlignment: Text.AlignHCenter
color: "#bbb"
spacing: 8
Label {
anchors.horizontalCenter: parent.horizontalCenter
text: "\uD83D\uDCC1"
font.pixelSize: 32
opacity: 0.5
}
Label {
text: qsTr("Откройте хранилище,\nчтобы увидеть файлы")
horizontalAlignment: Text.AlignHCenter
color: Theme.textMuted
lineHeight: 1.2
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Выбрать хранилище…")
onClicked: root.openVaultRequested()
}
}
Label {
anchors.centerIn: parent
visible: fileStore.ready && treeView.count === 0
text: root.filterText.length > 0 ? qsTr("Ничего не найдено") : qsTr("Папка пуста")
color: Theme.textMuted
}
}
// Кнопка хранилища внизу слева
Button {
id: vaultButton
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 6
height: 36
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()
}
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
anchors.bottom: vaultButton.top
anchors.bottomMargin: 6
height: 28
visible: statusText.length > 0
color: statusText.startsWith("✓") ? "#e5f6e5" : "#fdeaea"
color: statusText.startsWith("✓") ? Theme.successBg : Theme.errorBg
border.color: statusText.startsWith("✓") ? Theme.successText : Theme.errorText
radius: 6
Label {
anchors.fill: parent
anchors.margins: 6
anchors.leftMargin: 8
anchors.rightMargin: 8
verticalAlignment: Text.AlignVCenter
text: root.statusText
elide: Text.ElideRight
font.pixelSize: 11
color: statusText.startsWith("✓") ? "#256029" : "#9c1c1c"
color: statusText.startsWith("✓") ? Theme.successText : Theme.errorText
}
}
// ----- Меню «+» (файл/папка) -----
Menu {
id: addMenu
width: 150
width: 180
MenuItem {
text: qsTr("Файл")
onClicked: {
nameDialog.dialogTitle = qsTr("Новый файл")
nameDialog.actionLabel = qsTr("Создать")
nameDialog.initialText = ""
nameDialog.open()
pendingCreate.kind = "file"
}
onClicked: { nameDialog.dialogTitle = qsTr("Новый файл"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "file" }
}
MenuItem {
text: qsTr("Папка")
onClicked: {
nameDialog.dialogTitle = qsTr("Новая папка")
nameDialog.actionLabel = qsTr("Создать")
nameDialog.initialText = ""
nameDialog.open()
pendingCreate.kind = "folder"
}
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()
}
function openFor(path, isDir) { targetPath = path; targetIsDir = isDir; popup() }
MenuItem {
text: qsTr("Открыть")
onClicked: {
@@ -302,71 +397,28 @@ Item {
}
}
}
MenuItem {
text: qsTr("Создать файл внутри")
visible: rowMenu.targetIsDir
onClicked: {
nameDialog.dialogTitle = qsTr("Новый файл")
nameDialog.actionLabel = qsTr("Создать")
nameDialog.initialText = ""
nameDialog.open()
pendingCreate.kind = "file"
pendingCreate.parent = rowMenu.targetPath
}
}
MenuItem {
text: qsTr("Создать папку внутри")
visible: rowMenu.targetIsDir
onClicked: {
nameDialog.dialogTitle = qsTr("Новая папка")
nameDialog.actionLabel = qsTr("Создать")
nameDialog.initialText = ""
nameDialog.open()
pendingCreate.kind = "folder"
pendingCreate.parent = rowMenu.targetPath
}
}
MenuItem { text: qsTr("Создать файл внутри"); visible: rowMenu.targetIsDir; onClicked: { nameDialog.dialogTitle = qsTr("Новый файл"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "file"; pendingCreate.parent = rowMenu.targetPath } }
MenuItem { text: qsTr("Создать папку внутри"); visible: rowMenu.targetIsDir; onClicked: { nameDialog.dialogTitle = qsTr("Новая папка"); nameDialog.actionLabel = qsTr("Создать"); nameDialog.initialText = ""; nameDialog.open(); pendingCreate.kind = "folder"; pendingCreate.parent = rowMenu.targetPath } }
MenuSeparator { }
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"
}
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
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))
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
}
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: "" })
@@ -376,26 +428,13 @@ Item {
id: nameDialog
onConfirmed: (name) => {
let err = ""
const createIn = pendingCreate.parent.length > 0
? pendingCreate.parent
: parentForCreate()
if (pendingCreate.kind === "file") {
err = fileStore.createFile(createIn, name)
} else if (pendingCreate.kind === "folder") {
err = fileStore.createFolder(createIn, name)
} else if (pendingCreate.kind === "rename") {
err = fileStore.renameItem(renameTarget.path, name)
}
if (err.length > 0) showStatus(err, false)
pendingCreate.parent = ""
const createIn = pendingCreate.parent.length>0 ? pendingCreate.parent : parentForCreate()
if (pendingCreate.kind==="file") err=fileStore.createFile(createIn,name)
else if (pendingCreate.kind==="folder") err=fileStore.createFolder(createIn,name)
else if (pendingCreate.kind==="rename") err=fileStore.renameItem(renameTarget.path,name)
if (err.length>0) showStatus(err,false); else if (pendingCreate.kind!=="rename") showStatus(qsTr("Создано: %1").arg(name), true)
pendingCreate.parent=""
}
}
ConfirmDialog {
id: deleteDialog
onConfirmed: {
const err = fileStore.removeItem(deleteTarget.path)
if (err.length > 0) showStatus(err, false)
}
}
}
ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else showStatus(qsTr("Удалено"), true) } }
}