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
+174 -65
View File
@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Flora
Item {
id: pane
@@ -13,17 +14,22 @@ Item {
signal openBeside()
signal closePane()
function insertText(t) {
textDisplay.insert(textDisplay.cursorPosition, t)
}
function insertText(t) { textDisplay.insert(textDisplay.cursorPosition, t) }
function refresh() {
const idx = pane.docIndex
if (idx >= 0) {
const c = documentManager.contentAt(idx)
textDisplay.text = c
if (textDisplay.text !== c && !textDisplay.activeFocus)
textDisplay.text = c
if (!pane.editMode) {
viewDisplay.text = mdParser.render(c, fileStore.vault, filePathDir(documentManager.pathAt(idx)))
} else {
viewDisplay.text = ""
}
} else {
textDisplay.text = ""
viewDisplay.text = ""
}
}
@@ -37,102 +43,205 @@ Item {
Connections {
target: documentManager
function onActiveIndexChanged() {
if (documentManager.activeIndex === pane.docIndex)
refresh()
}
function onContentChanged(idx) { if (idx === pane.docIndex && !textDisplay.activeFocus) refresh() }
function onActiveIndexChanged() { if (documentManager.activeIndex === pane.docIndex) refresh() }
}
Component.onCompleted: refresh()
// --- Текстовый редактор (сверху до низу, без шапки) ---
TextEdit {
id: textDisplay
function applyInline(pre, post) {
if (!pane.editMode) return
textDisplay.forceActiveFocus()
const s = textDisplay.selectionStart, e = textDisplay.selectionEnd
if (s !== e) {
const sel = textDisplay.selectedText
textDisplay.remove(s, e)
textDisplay.insert(s, pre + sel + post)
textDisplay.select(s, s + pre.length + sel.length + post.length)
} else {
const pos = textDisplay.cursorPosition
textDisplay.insert(pos, pre + post)
textDisplay.cursorPosition = pos + pre.length
}
documentManager.updateContent(pane.docIndex, textDisplay.text)
}
function applyHeading(level) {
if (!pane.editMode) return
textDisplay.forceActiveFocus()
const txt = textDisplay.text
const pos = textDisplay.cursorPosition
let start = txt.lastIndexOf("\n", pos - 1) + 1
let end = txt.indexOf("\n", pos)
if (end < 0) end = txt.length
let line = txt.substring(start, end)
line = line.replace(/^#{1,6}\s*/, "")
const newLine = "#".repeat(level) + " " + line
textDisplay.remove(start, end)
textDisplay.insert(start, newLine)
textDisplay.cursorPosition = start + newLine.length
documentManager.updateContent(pane.docIndex, textDisplay.text)
}
function applyLinePrefix(prefix) {
if (!pane.editMode) return
textDisplay.forceActiveFocus()
const txt = textDisplay.text
const s = textDisplay.selectionStart, e = textDisplay.selectionEnd
let start = txt.lastIndexOf("\n", s - 1) + 1
let finish = txt.indexOf("\n", e)
if (finish < 0) finish = txt.length
const block = txt.substring(start, finish)
const newBlock = block.split("\n").map(l => prefix + l).join("\n")
textDisplay.remove(start, finish)
textDisplay.insert(start, newBlock)
textDisplay.select(start, start + newBlock.length)
documentManager.updateContent(pane.docIndex, textDisplay.text)
}
function applyTaskToggle() { applyLinePrefix("- [ ] ") }
function applyCallout(kind) { applyLinePrefix("> [!" + kind + "] ") }
function applyFloatWrap() {
if (!pane.editMode) return
textDisplay.forceActiveFocus()
textDisplay.insert(textDisplay.cursorPosition, "![[image.png|float:left|width:40%]]")
documentManager.updateContent(pane.docIndex, textDisplay.text)
}
function applyColor(color, highlightMode) {
if (highlightMode) applyInline('<span style="background-color:' + color + '">', '</span>')
else applyInline('<span style="color:' + color + '">', '</span>')
}
function applySubSup(kind) {
if (kind === "sup") applyInline("<sup>", "</sup>")
else applyInline("<sub>", "</sub>")
}
// --- Текстовый редактор ---
ScrollView {
id: editScroll
anchors.fill: parent
anchors.bottomMargin: 32
anchors.bottomMargin: 36
visible: pane.editMode
clip: true
text: documentManager.activeIndex >= 0
? documentManager.contentAt(documentManager.activeIndex)
: ""
color: "black"
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
font.pixelSize: 14
wrapMode: TextEdit.NoWrap
selectByMouse: true
onTextEdited: documentManager.updateContent(pane.docIndex, text)
}
// --- Режим просмотра ---
TextEdit {
id: viewDisplay
anchors.fill: parent
anchors.bottomMargin: 32
visible: !pane.editMode
readOnly: true
clip: true
text: pane.editMode ? "" : mdParser.render(
documentManager.contentAt(pane.docIndex),
fileStore.vault,
filePathDir(documentManager.pathAt(pane.docIndex))
)
textFormat: TextEdit.RichText
color: "black"
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
font.pixelSize: 14
wrapMode: TextEdit.Wrap
onLinkActivated: (href) => {
if (href.startsWith("flora://task/")) {
const line = Number(href.substring(14))
if (line > 0) {
const lines = textDisplay.text.split("\n")
const ln = lines[line - 1]
if (ln !== undefined) {
if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
textDisplay.text = lines.join("\n")
documentManager.updateContent(pane.docIndex, textDisplay.text)
}
}
} else if (href.startsWith("flora://open/")) {
documentManager.openDocument(decodeURIComponent(href.substring(13)))
}
ScrollBar.vertical.policy: ScrollBar.AsNeeded
TextArea {
id: textDisplay
placeholderText: qsTr("Начните писать…")
placeholderTextColor: Theme.textFaint
color: Theme.editorText
selectionColor: Theme.selectionBg
selectedTextColor: Theme.text
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
font.pixelSize: 14
wrapMode: TextArea.NoWrap
selectByMouse: true
background: Rectangle { color: Theme.editorBg }
onTextChanged: if (activeFocus && text !== documentManager.contentAt(pane.docIndex)) editDebounce.restart()
}
}
Timer { id: editDebounce; interval: 250; onTriggered: documentManager.updateContent(pane.docIndex, textDisplay.text) }
// --- Серая панель ВНИЗУ: кнопки ✎👁 × ---
ScrollView {
id: viewScroll
anchors.fill: parent
anchors.bottomMargin: 36
visible: !pane.editMode
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
TextArea {
id: viewDisplay
readOnly: true
textFormat: Text.RichText
color: Theme.text
selectionColor: Theme.selectionBg
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
font.pixelSize: 14
wrapMode: TextArea.Wrap
background: Rectangle { color: Theme.editorBg }
onLinkActivated: (href) => {
if (href.startsWith("flora://copycode/")) {
const id = href.substring(17)
const code = mdParser.getCodeBlock(id)
if (code.length > 0) { mdParser.copyToClipboard(code); copyFeedback.visible = true; copyTimer.restart() }
return
}
if (href.startsWith("flora://task/")) {
const line = Number(href.substring(14))
if (line > 0) {
const lines = textDisplay.text.split("\n")
const ln = lines[line - 1]
if (ln !== undefined) {
if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
textDisplay.text = lines.join("\n")
documentManager.updateContent(pane.docIndex, textDisplay.text)
}
}
} else if (href.startsWith("flora://open/")) {
documentManager.openDocument(decodeURIComponent(href.substring(13)))
} else {
Qt.openUrlExternally(href)
}
}
}
// Тост копирования
Rectangle {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 8
visible: copyFeedback.visible
color: "#333"
radius: 6
width: copyFeedback.implicitWidth + 16
height: copyFeedback.implicitHeight + 10
z: 11
Label { id: copyFeedback; anchors.centerIn: parent; visible: false; text: qsTr("Скопировано!"); color: "white"; font.pixelSize: 11 }
}
Timer { id: copyTimer; interval: 1500; onTriggered: copyFeedback.visible = false }
}
// --- Нижняя панель ---
Rectangle {
id: toolbar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 32
height: 36
z: 10
color: "#e0e0e0"
border.color: "#ccc"
color: Theme.toolbarBg
border.color: Theme.divider
RowLayout {
anchors.fill: parent
anchors.rightMargin: 4
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 4
Item { Layout.fillWidth: true }
Label {
visible: pane.docIndex >= 0
text: pane.docIndex >= 0 ? documentManager.titleAt(pane.docIndex) : ""
color: Theme.textMuted
font.pixelSize: 11
elide: Text.ElideMiddle
Layout.fillWidth: true
}
ToolButton {
text: "✎"
checkable: true
checked: pane.editMode
ToolTip.text: qsTr("Редактирование")
ToolTip.visible: hovered
onClicked: pane.editMode = true
}
ToolButton {
text: "👁"
checkable: true
checked: !pane.editMode
ToolTip.text: qsTr("Просмотр")
ToolTip.visible: hovered
onClicked: pane.editMode = false
}
ToolButton {
text: "\u2715"
visible: pane.closable
ToolTip.text: qsTr("Закрыть")
ToolTip.visible: hovered
onClicked: pane.closePane()
}
}