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:
+98
-31
@@ -1,21 +1,21 @@
|
||||
import QtQuick
|
||||
import Flora
|
||||
|
||||
// Небольшое полотно графа связей: узлы-файлы (синие) и теги (зелёные),
|
||||
// рёбра — связи; узлы можно перетаскивать, клик по файлу открывает его.
|
||||
Canvas {
|
||||
id: g
|
||||
|
||||
property var nodes: [] // [{key,name,type}]
|
||||
property var edges: [] // [{from,to}]
|
||||
property var nodes: []
|
||||
property var edges: []
|
||||
signal fileClicked(string path)
|
||||
|
||||
property var pos: ({}) // key -> {x,y}
|
||||
property var pos: ({})
|
||||
property string dragKey: ""
|
||||
property real zoom: 1.0
|
||||
property real panX: 0
|
||||
property real panY: 0
|
||||
property real lastMouseX: 0
|
||||
property real lastMouseY: 0
|
||||
property bool isEmpty: nodes.length === 0
|
||||
|
||||
function nodeForKey(key) {
|
||||
for (let i = 0; i < nodes.length; ++i)
|
||||
@@ -24,15 +24,71 @@ Canvas {
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
pos = {}
|
||||
const n = nodes.length
|
||||
if (n === 0) { requestPaint(); return }
|
||||
if (n === 0) { pos = {}; requestPaint(); return }
|
||||
if (n > 600) {
|
||||
// Слишком много узлов — показываем упрощённо без симуляции чтобы не заморозить UI
|
||||
const cx = width / 2, cy = height / 2
|
||||
const rad = Math.min(width, height) / 2 - 36
|
||||
let newPos = {}
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
|
||||
}
|
||||
pos = newPos; zoom = 1.0; panX = 0; panY = 0; requestPaint(); return
|
||||
}
|
||||
const cx = width / 2, cy = height / 2
|
||||
const rad = Math.min(width, height) / 2 - 30
|
||||
const rad = Math.min(width, height) / 2 - 36
|
||||
let newPos = {}
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||
pos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
|
||||
// небольшой джиттер чтобы не накладывались ровно
|
||||
const jitter = (Math.random() - 0.5) * 8
|
||||
newPos[nodes[i].key] = { x: cx + rad * Math.cos(a) + jitter, y: cy + rad * Math.sin(a) + jitter }
|
||||
}
|
||||
const iterCount = n > 200 ? 25 : (n > 100 ? 40 : 80)
|
||||
// простая force-симуляция для разведения узлов (репульсия + притяжение по рёбрам)
|
||||
for (let iter = 0; iter < iterCount; ++iter) {
|
||||
// репульсия между всеми парами
|
||||
for (let i = 0; i < n; ++i) {
|
||||
for (let j = i + 1; j < n; ++j) {
|
||||
const a = newPos[nodes[i].key]
|
||||
const b = newPos[nodes[j].key]
|
||||
let dx = a.x - b.x, dy = a.y - b.y
|
||||
let dist = Math.sqrt(dx*dx + dy*dy)
|
||||
if (dist < 1) dist = 1
|
||||
if (dist < 70) {
|
||||
const force = (70 - dist) * 0.08
|
||||
const nx = dx / dist * force
|
||||
const ny = dy / dist * force
|
||||
a.x += nx; a.y += ny
|
||||
b.x -= nx; b.y -= ny
|
||||
}
|
||||
}
|
||||
}
|
||||
// притяжение по рёбрам
|
||||
for (let e = 0; e < edges.length; ++e) {
|
||||
const f = newPos[edges[e].from]
|
||||
const t = newPos[edges[e].to]
|
||||
if (!f || !t) continue
|
||||
let dx = t.x - f.x, dy = t.y - f.y
|
||||
let dist = Math.sqrt(dx*dx + dy*dy)
|
||||
if (dist < 1) continue
|
||||
const target = 90
|
||||
const force = (dist - target) * 0.02
|
||||
const nx = dx / dist * force
|
||||
const ny = dy / dist * force
|
||||
f.x += nx; f.y += ny
|
||||
t.x -= nx; t.y -= ny
|
||||
}
|
||||
// удержание в границах
|
||||
for (let k in newPos) {
|
||||
const p = newPos[k]
|
||||
p.x = Math.max(30, Math.min(width - 30, p.x))
|
||||
p.y = Math.max(30, Math.min(height - 30, p.y))
|
||||
}
|
||||
}
|
||||
pos = newPos
|
||||
zoom = 1.0; panX = 0; panY = 0
|
||||
requestPaint()
|
||||
}
|
||||
@@ -41,6 +97,17 @@ Canvas {
|
||||
onWidthChanged: relayout()
|
||||
onHeightChanged: relayout()
|
||||
|
||||
// пустое состояние
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: g.isEmpty
|
||||
text: qsTr("Нет связей\nДобавьте [[ссылки]] или #теги")
|
||||
color: Theme.textMuted
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.pixelSize: 11
|
||||
lineHeight: 1.3
|
||||
}
|
||||
|
||||
function paintCanvas() {
|
||||
const ctx = getContext("2d")
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
@@ -51,9 +118,8 @@ Canvas {
|
||||
ctx.scale(zoom, zoom)
|
||||
ctx.translate(-width / 2, -height / 2)
|
||||
|
||||
// рёбра
|
||||
ctx.lineWidth = 1 / zoom
|
||||
ctx.strokeStyle = "#b9c6d6"
|
||||
ctx.strokeStyle = Theme.graphEdge
|
||||
for (let i = 0; i < edges.length; ++i) {
|
||||
const f = pos[edges[i].from]
|
||||
const t = pos[edges[i].to]
|
||||
@@ -64,7 +130,6 @@ Canvas {
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// узлы
|
||||
ctx.textAlign = "center"
|
||||
for (let i = 0; i < nodes.length; ++i) {
|
||||
const nd = nodes[i]
|
||||
@@ -74,13 +139,16 @@ Canvas {
|
||||
const r = isFile ? 6 : 5
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = isFile ? (nd.key === g.dragKey ? "#1a6ce0" : "#3f7fd0")
|
||||
: "#3aa06a"
|
||||
if (isFile) {
|
||||
ctx.fillStyle = (nd.key === g.dragKey) ? Theme.accentHover : Theme.graphNodeFile
|
||||
} else {
|
||||
ctx.fillStyle = Theme.graphNodeTag
|
||||
}
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = "#ffffff"
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = Theme.bg
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = "#5a6a7d"
|
||||
ctx.fillStyle = Theme.graphLabel
|
||||
ctx.font = (9 / zoom) + "px sans-serif"
|
||||
const txt = nd.name.length > 14 ? nd.name.substring(0, 13) + "…" : nd.name
|
||||
ctx.fillText(txt, p.x, p.y + r + 10 / zoom)
|
||||
@@ -94,6 +162,8 @@ Canvas {
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton
|
||||
hoverEnabled: true
|
||||
cursorShape: g.dragKey.length > 0 ? Qt.ClosedHandCursor : Qt.ArrowCursor
|
||||
onWheel: (w) => {
|
||||
const factor = w.angleDelta.y > 0 ? 1.15 : 0.87
|
||||
g.zoom = Math.max(0.2, Math.min(5.0, g.zoom * factor))
|
||||
@@ -108,18 +178,16 @@ Canvas {
|
||||
for (const key in g.pos) {
|
||||
const p = g.pos[key]
|
||||
const dx = gx - p.x, dy = gy - p.y
|
||||
if (dx * dx + dy * dy <= 256) {
|
||||
g.dragKey = key
|
||||
break
|
||||
}
|
||||
if (dx * dx + dy * dy <= 256) { g.dragKey = key; break }
|
||||
}
|
||||
}
|
||||
onPositionChanged: (m) => {
|
||||
if (!pressed) return
|
||||
if (g.dragKey.length > 0 && g.pos[g.dragKey]) {
|
||||
const gx = (m.x - width / 2 - panX) / zoom + width / 2
|
||||
const gy = (m.y - height / 2 - panY) / zoom + height / 2
|
||||
g.pos[g.dragKey] = { x: gx, y: gy }
|
||||
} else {
|
||||
} else if (pressed) {
|
||||
g.panX += m.x - g.lastMouseX
|
||||
g.panY += m.y - g.lastMouseY
|
||||
}
|
||||
@@ -128,24 +196,23 @@ Canvas {
|
||||
g.requestPaint()
|
||||
}
|
||||
onReleased: (m) => {
|
||||
if (g.dragKey.length === 0) {
|
||||
const gx = (m.x - width / 2 - panX) / zoom + width / 2
|
||||
const gy = (m.y - height / 2 - panY) / zoom + height / 2
|
||||
const wasDrag = g.dragKey.length > 0
|
||||
const gx = (m.x - width / 2 - panX) / zoom + width / 2
|
||||
const gy = (m.y - height / 2 - panY) / zoom + height / 2
|
||||
if (!wasDrag) {
|
||||
for (const key in g.pos) {
|
||||
const p = g.pos[key]
|
||||
const dx = gx - p.x, dy = gy - p.y
|
||||
if (dx * dx + dy * dy <= 144) {
|
||||
const nd = g.nodeForKey(key)
|
||||
if (nd && nd.type === "file")
|
||||
g.fileClicked(nd.key)
|
||||
if (nd && nd.type === "file") g.fileClicked(nd.key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
g.dragKey = ""
|
||||
g.requestPaint()
|
||||
}
|
||||
onDoubleClicked: {
|
||||
g.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout()
|
||||
}
|
||||
onDoubleClicked: { g.zoom = 1.0; g.panX = 0; g.panY = 0; g.relayout() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user