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
+237 -149
View File
@@ -1,221 +1,309 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
// Правая панель: календарь (заметки дня), «Ссылки и теги» активного файла,
// граф связей (файлы + теги, рёбра по ссылкам).
Item {
id: root
function activePath() {
return documentManager.pathAt(documentManager.activeIndex)
}
function activePath() { return documentManager.pathAt(documentManager.activeIndex) }
function refreshLinksTags() {
const p = root.activePath()
linksModel.clear()
if (p.length > 0) {
const links = vaultIndex.linksOfFile(p)
for (let i = 0; i < links.length; ++i) {
linksModel.append({ text: links[i].text, path: links[i].path })
}
for (let i = 0; i < links.length; ++i) linksModel.append({ text: links[i].text, path: links[i].path })
}
tagsModel.clear()
const tags = p.length > 0 ? vaultIndex.tagsOfFile(p) : []
for (let i = 0; i < tags.length; ++i)
tagsModel.append({ tag: tags[i] })
for (let i = 0; i < tags.length; ++i) tagsModel.append({ tag: tags[i] })
}
Component.onCompleted: {
if (fileStore.ready)
vaultIndex.rebuild(fileStore.vault)
}
Component.onCompleted: { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
Connections {
target: fileStore
function onVaultChanged() {
if (fileStore.ready)
vaultIndex.rebuild(fileStore.vault)
}
function onReadyChanged() {
if (fileStore.ready)
vaultIndex.rebuild(fileStore.vault)
}
function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
function onReadyChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
}
Connections {
target: documentManager
function onActiveIndexChanged() { root.refreshLinksTags() }
function onOpenTabsChanged() { root.refreshLinksTags() }
function onContentChanged(i) {
if (i === documentManager.activeIndex) root.refreshLinksTags()
}
}
Connections {
target: vaultIndex
function onIndexChanged() { root.refreshLinksTags() }
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); rebuildDebounce.restart() } }
function onDocumentSaved() { rebuildDebounce.restart() }
}
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
Timer { id: rebuildDebounce; interval: 800; onTriggered: if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
ColumnLayout {
// Делаем панели ресайзабельными внутри правой колонки
SplitView {
anchors.fill: parent
spacing: 0
orientation: Qt.Vertical
handle: Rectangle { implicitHeight: 1; color: Theme.divider }
// ---------- Календарь ----------
// Календарь
Rectangle {
Layout.fillWidth: true
implicitHeight: 218
color: "#fafafa"
id: calBox
SplitView.preferredHeight: 240
SplitView.minimumHeight: 180
SplitView.maximumHeight: 360
color: Theme.bgAlt
border.color: Theme.divider
ColumnLayout {
anchors.fill: parent
spacing: 0
Label {
text: qsTr("Календарь")
font.bold: true
color: "#555"
padding: 6
}
MonthCalendar {
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
onDaySelected: (ds) => {
const path = fileStore.dailyNotePath(ds)
if (path.length > 0)
documentManager.openDocument(path)
}
} }
}
// ---------- Ссылки и теги активного файла ----------
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: "#f4f4f4"
ColumnLayout {
anchors.fill: parent
anchors.margins: 6
spacing: 4
Label {
text: qsTr("Ссылки и теги")
font.bold: true
color: "#555"
}
Label {
text: root.activePath().length > 0
? qsTr("Файл: %1").arg(root.baseName(activePath()))
: qsTr("Нет активного файла")
elide: Text.ElideMiddle
color: "#888"
Layout.fillWidth: true
font.pixelSize: 11
}
Label { text: qsTr("Ссылки"); color: "#666"; font.pixelSize: 11 }
ListView {
id: linksList
Layout.fillWidth: true
Layout.preferredHeight: Math.min(120, linksModel.count * 22)
model: ListModel { id: linksModel }
clip: true
delegate: ItemDelegate {
width: ListView.view.width
height: 22
text: model.text
font.pixelSize: 12
onClicked: documentManager.openDocument(model.path)
}
}
Label {
visible: linksModel.count === 0
text: qsTr("— нет ссылок —")
color: "#bbb"
font.pixelSize: 11
}
Label { text: qsTr("Теги"); color: "#666"; font.pixelSize: 11 }
Flow {
Layout.fillWidth: true
readonly property var m: ListModel { id: tagsModel }
Repeater {
model: tagsModel
delegate: TagChip {
text: "#" + model.tag
onClicked: {
tagFilesModel.clear()
const files = vaultIndex.filesWithTag(model.tag)
for (let i = 0; i < files.length; ++i)
tagFilesModel.append({ path: files[i], name: baseName(files[i]) })
tagFilesTitle.text = qsTr("Файлы с тегом #%1 (%2)").arg(model.tag).arg(files.length)
tagFilesPopup.open()
}
height: 28
color: "transparent"
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8; anchors.rightMargin: 4
Label { text: qsTr("Календарь"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
ToolButton {
text: "\u2637"
font.pixelSize: 12
ToolTip.text: qsTr("Сегодня")
ToolTip.visible: hovered
onClicked: cal.currentMonth = new Date()
}
}
}
Item { Layout.fillHeight: true }
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
MonthCalendar {
id: cal
Layout.fillWidth: true
Layout.fillHeight: true
Layout.margins: 4
onDaySelected: (ds) => {
const path = fileStore.dailyNotePath(ds)
if (path.length > 0) documentManager.openDocument(path)
}
}
}
}
// ---------- Граф связей ----------
// Ссылки и теги
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: "#eef1f5"
id: linksBox
SplitView.preferredHeight: 260
SplitView.minimumHeight: 120
color: Theme.bg
ScrollView {
anchors.fill: parent
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ColumnLayout {
width: parent.width - 12
x: 6
y: 6
spacing: 8
RowLayout {
Layout.fillWidth: true
Label { text: qsTr("Ссылки и теги"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
Label {
text: vaultIndex.fileCount + " " + qsTr("файлов")
color: Theme.textFaint
font.pixelSize: 10
}
}
Label {
text: root.activePath().length > 0 ? qsTr("Файл: %1").arg(root.baseName(activePath())) : qsTr("Нет активного файла")
elide: Text.ElideMiddle
color: Theme.textMuted
Layout.fillWidth: true
font.pixelSize: 11
}
// Ссылки
Rectangle {
Layout.fillWidth: true
color: Theme.bgAlt
radius: 8
border.color: Theme.border
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 4
Label { text: qsTr("Ссылки"); color: Theme.textSecondary; font.pixelSize: 11; font.bold: true }
Repeater {
model: ListModel { id: linksModel }
// placeholder, используем ListView внутри
}
ListView {
id: linksList
Layout.fillWidth: true
Layout.preferredHeight: Math.min(140, contentHeight)
model: linksModel
clip: true
delegate: ItemDelegate {
width: ListView.view.width
height: 28
text: model.text
font.pixelSize: 12
palette.text: Theme.text
background: Rectangle { color: hovered ? Theme.surfaceHover : "transparent"; radius: 6 }
onClicked: documentManager.openDocument(model.path)
}
ScrollBar.vertical: ScrollBar {}
}
Label {
visible: linksModel.count === 0
text: qsTr("— нет ссылок —")
color: Theme.textFaint
font.pixelSize: 11
font.italic: true
}
}
}
// Теги
Rectangle {
Layout.fillWidth: true
color: Theme.bgAlt
radius: 8
border.color: Theme.border
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 6
Label { text: qsTr("Теги"); color: Theme.textSecondary; font.pixelSize: 11; font.bold: true }
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: tagsModel
delegate: TagChip {
text: "#" + model.tag
onClicked: {
tagFilesModel.clear()
const files = vaultIndex.filesWithTag(model.tag)
for (let i=0;i<files.length;++i) tagFilesModel.append({ path: files[i], name: baseName(files[i]) })
tagFilesTitle.text = qsTr("Файлы с тегом #%1 (%2)").arg(model.tag).arg(files.length)
tagFilesPopup.open()
}
}
}
Label {
visible: tagsModel.count === 0
text: qsTr("— нет тегов —")
color: Theme.textFaint
font.pixelSize: 11
font.italic: true
}
}
}
}
Item { Layout.fillHeight: true }
}
}
}
// Граф
Rectangle {
id: graphBox
SplitView.fillHeight: true
SplitView.minimumHeight: 160
color: Theme.graphBg
ColumnLayout {
anchors.fill: parent
spacing: 0
RowLayout {
Rectangle {
Layout.fillWidth: true
Label {
text: qsTr("Граф связей")
font.bold: true
color: "#555"
padding: 6
}
Item { Layout.fillWidth: true }
Button {
text: qsTr("Обновить")
font.pixelSize: 11
implicitHeight: 22
onClicked: vaultIndex.rebuild(fileStore.vault)
height: 32
color: "transparent"
RowLayout {
anchors.fill: parent
anchors.leftMargin: 8; anchors.rightMargin: 4
Label { text: qsTr("Граф связей"); font.bold: true; color: Theme.textSecondary; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
Label { text: vaultIndex.nodes.length + "·" + vaultIndex.edges.length; color: Theme.textFaint; font.pixelSize: 10 }
ToolButton {
text: "\u21BB"
font.pixelSize: 12
ToolTip.text: qsTr("Обновить")
ToolTip.visible: hovered
onClicked: vaultIndex.rebuild(fileStore.vault)
}
ToolButton {
text: "\u2630"
ToolTip.text: qsTr("Справка")
ToolTip.visible: hovered
onClicked: graphHelp.open()
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
GraphView {
id: graph
Layout.fillWidth: true
Layout.fillHeight: true
nodes: vaultIndex.nodes
edges: vaultIndex.edges
onFileClicked: (path) => documentManager.openDocument(path)
}
// легенда
Row {
Layout.fillWidth: true
Layout.margins: 6
spacing: 12
Row { spacing: 4; Rectangle { width: 10; height: 10; radius: 5; color: Theme.graphNodeFile; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("Файл"); color: Theme.textMuted; font.pixelSize: 10 } }
Row { spacing: 4; Rectangle { width: 10; height: 10; radius: 5; color: Theme.graphNodeTag; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("Тег"); color: Theme.textMuted; font.pixelSize: 10 } }
Label { text: qsTr("Колесо — зум, перетаскивание — перемещение"); color: Theme.textFaint; font.pixelSize: 9; elide: Text.ElideRight; Layout.fillWidth: true; horizontalAlignment: Text.AlignRight }
}
}
}
}
function baseName(p) {
const idx = p.lastIndexOf("/")
return idx >= 0 ? p.substring(idx + 1) : p
}
function baseName(p) { const idx = p.lastIndexOf("/"); return idx >= 0 ? p.substring(idx+1) : p }
Popup {
id: tagFilesPopup
modal: true
anchors.centerIn: parent
width: 300
width: 360
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
ColumnLayout {
width: parent.width
Label { id: tagFilesTitle; font.bold: true }
ListView {
Layout.preferredHeight: Math.min(220, contentHeight)
width: parent.width - 16
x: 8; y: 8
Label { id: tagFilesTitle; font.bold: true; color: Theme.text }
ScrollView {
Layout.preferredHeight: Math.min(260, contentHeight)
Layout.fillWidth: true
model: ListModel { id: tagFilesModel }
clip: true
delegate: ItemDelegate {
width: ListView.view.width
text: model.name
onClicked: {
documentManager.openDocument(model.path)
tagFilesPopup.close()
ListView {
model: ListModel { id: tagFilesModel }
delegate: ItemDelegate {
width: ListView.view.width
text: model.name
onClicked: { documentManager.openDocument(model.path); tagFilesPopup.close() }
}
}
}
Button { text: qsTr("Закрыть"); Layout.alignment: Qt.AlignRight; onClicked: tagFilesPopup.close() }
}
}
}
Popup {
id: graphHelp
anchors.centerIn: parent
width: 320
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
ColumnLayout {
width: parent.width - 16
x: 8; y: 8
spacing: 8
Label { text: qsTr("Граф связей"); font.bold: true; color: Theme.text }
Label { text: qsTr("• Синие узлы — файлы, зелёные — теги\n• Клик по файлу — открыть\n• Перетаскивание — переместить узел\n• Колесо — зум, перетаскивание фона — панорама\n• Двойной клик — сброс"); color: Theme.textSecondary; wrapMode: Text.Wrap; Layout.fillWidth: true; font.pixelSize: 11 }
Button { text: qsTr("Понятно"); Layout.alignment: Qt.AlignRight; onClicked: graphHelp.close() }
}
}
// скрытая модель тегов (для Flow)
ListModel { id: tagsModel }
}